loopforge 3.9.2 → 4.0.7

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 (285) hide show
  1. package/README.md +236 -112
  2. package/dist/audit.d.ts +73 -15
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +131 -61
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +152 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +382 -206
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +41 -12
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +0 -2
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +0 -4
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/committed-round.d.ts +105 -141
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +249 -372
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/constraint-lifecycle.d.ts +73 -0
  22. package/dist/constraint-lifecycle.d.ts.map +1 -0
  23. package/dist/constraint-lifecycle.js +174 -0
  24. package/dist/constraint-lifecycle.js.map +1 -0
  25. package/dist/context-boundary.d.ts +83 -0
  26. package/dist/context-boundary.d.ts.map +1 -0
  27. package/dist/context-boundary.js +101 -0
  28. package/dist/context-boundary.js.map +1 -0
  29. package/dist/contract-items.d.ts +12 -8
  30. package/dist/contract-items.d.ts.map +1 -1
  31. package/dist/contract-items.js +31 -26
  32. package/dist/contract-items.js.map +1 -1
  33. package/dist/contract-requirement.d.ts +47 -0
  34. package/dist/contract-requirement.d.ts.map +1 -0
  35. package/dist/contract-requirement.js +51 -0
  36. package/dist/contract-requirement.js.map +1 -0
  37. package/dist/criterion-status.d.ts +35 -0
  38. package/dist/criterion-status.d.ts.map +1 -0
  39. package/dist/criterion-status.js +89 -0
  40. package/dist/criterion-status.js.map +1 -0
  41. package/dist/enforcement-gate.d.ts +32 -67
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +203 -439
  44. package/dist/enforcement-gate.js.map +1 -1
  45. package/dist/engine.d.ts +9 -103
  46. package/dist/engine.d.ts.map +1 -1
  47. package/dist/engine.js +28 -887
  48. package/dist/engine.js.map +1 -1
  49. package/dist/errors.d.ts +41 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +69 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/evidence-claims.d.ts +33 -31
  54. package/dist/evidence-claims.d.ts.map +1 -1
  55. package/dist/evidence-claims.js +58 -72
  56. package/dist/evidence-claims.js.map +1 -1
  57. package/dist/evidence-posture-view.d.ts +19 -0
  58. package/dist/evidence-posture-view.d.ts.map +1 -0
  59. package/dist/evidence-posture-view.js +116 -0
  60. package/dist/evidence-posture-view.js.map +1 -0
  61. package/dist/evidence-posture.d.ts +73 -0
  62. package/dist/evidence-posture.d.ts.map +1 -0
  63. package/dist/evidence-posture.js +281 -0
  64. package/dist/evidence-posture.js.map +1 -0
  65. package/dist/evidence-provider.d.ts +55 -37
  66. package/dist/evidence-provider.d.ts.map +1 -1
  67. package/dist/evidence-provider.js +182 -332
  68. package/dist/evidence-provider.js.map +1 -1
  69. package/dist/explain.d.ts +167 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +373 -58
  72. package/dist/explain.js.map +1 -1
  73. package/dist/field-accounting.d.ts.map +1 -1
  74. package/dist/field-accounting.js +3 -22
  75. package/dist/field-accounting.js.map +1 -1
  76. package/dist/generate-schema.js +13 -8
  77. package/dist/generate-schema.js.map +1 -1
  78. package/dist/git-boundary.d.ts +128 -0
  79. package/dist/git-boundary.d.ts.map +1 -0
  80. package/dist/git-boundary.js +625 -0
  81. package/dist/git-boundary.js.map +1 -0
  82. package/dist/git-facts.d.ts +22 -0
  83. package/dist/git-facts.d.ts.map +1 -0
  84. package/dist/git-facts.js +29 -0
  85. package/dist/git-facts.js.map +1 -0
  86. package/dist/git-motion.d.ts +37 -0
  87. package/dist/git-motion.d.ts.map +1 -0
  88. package/dist/git-motion.js +59 -0
  89. package/dist/git-motion.js.map +1 -0
  90. package/dist/history.d.ts +44 -0
  91. package/dist/history.d.ts.map +1 -0
  92. package/dist/history.js +77 -0
  93. package/dist/history.js.map +1 -0
  94. package/dist/index.d.ts +19 -9
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +12 -4
  97. package/dist/index.js.map +1 -1
  98. package/dist/init.d.ts +16 -0
  99. package/dist/init.d.ts.map +1 -1
  100. package/dist/init.js +80 -14
  101. package/dist/init.js.map +1 -1
  102. package/dist/loop-compiler.d.ts +51 -19
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +423 -507
  105. package/dist/loop-compiler.js.map +1 -1
  106. package/dist/loop-store.d.ts +6 -5
  107. package/dist/loop-store.d.ts.map +1 -1
  108. package/dist/loop-store.js +153 -35
  109. package/dist/loop-store.js.map +1 -1
  110. package/dist/mcp/round-lifecycle.d.ts +285 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1629 -574
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts +5 -0
  115. package/dist/mcp/server.d.ts.map +1 -1
  116. package/dist/mcp/server.js +61 -19
  117. package/dist/mcp/server.js.map +1 -1
  118. package/dist/mcp/session.d.ts +81 -39
  119. package/dist/mcp/session.d.ts.map +1 -1
  120. package/dist/mcp/session.js +963 -271
  121. package/dist/mcp/session.js.map +1 -1
  122. package/dist/mcp/submission-boundary.d.ts +23 -31
  123. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  124. package/dist/mcp/submission-boundary.js +242 -82
  125. package/dist/mcp/submission-boundary.js.map +1 -1
  126. package/dist/mcp/submission-handle.d.ts +66 -0
  127. package/dist/mcp/submission-handle.d.ts.map +1 -0
  128. package/dist/mcp/submission-handle.js +173 -0
  129. package/dist/mcp/submission-handle.js.map +1 -0
  130. package/dist/mcp/tools.d.ts +17 -7
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +763 -244
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/open-question.d.ts +7 -0
  135. package/dist/open-question.d.ts.map +1 -0
  136. package/dist/open-question.js +14 -0
  137. package/dist/open-question.js.map +1 -0
  138. package/dist/policy-metrics.d.ts +31 -21
  139. package/dist/policy-metrics.d.ts.map +1 -1
  140. package/dist/policy-metrics.js +117 -64
  141. package/dist/policy-metrics.js.map +1 -1
  142. package/dist/policy.d.ts +15 -40
  143. package/dist/policy.d.ts.map +1 -1
  144. package/dist/policy.js +13 -13
  145. package/dist/policy.js.map +1 -1
  146. package/dist/progress.d.ts +45 -0
  147. package/dist/progress.d.ts.map +1 -0
  148. package/dist/progress.js +136 -0
  149. package/dist/progress.js.map +1 -0
  150. package/dist/prompt-assembler.d.ts +28 -9
  151. package/dist/prompt-assembler.d.ts.map +1 -1
  152. package/dist/prompt-assembler.js +592 -603
  153. package/dist/prompt-assembler.js.map +1 -1
  154. package/dist/prompt-coverage.d.ts +39 -0
  155. package/dist/prompt-coverage.d.ts.map +1 -0
  156. package/dist/prompt-coverage.js +80 -0
  157. package/dist/prompt-coverage.js.map +1 -0
  158. package/dist/prompt-freshness.d.ts +25 -0
  159. package/dist/prompt-freshness.d.ts.map +1 -0
  160. package/dist/prompt-freshness.js +88 -0
  161. package/dist/prompt-freshness.js.map +1 -0
  162. package/dist/prompt-policy.d.ts +14 -4
  163. package/dist/prompt-policy.d.ts.map +1 -1
  164. package/dist/prompt-policy.js +29 -10
  165. package/dist/prompt-policy.js.map +1 -1
  166. package/dist/protocol.d.ts +875 -419
  167. package/dist/protocol.d.ts.map +1 -1
  168. package/dist/protocol.js +73 -99
  169. package/dist/protocol.js.map +1 -1
  170. package/dist/recovery-guidance.d.ts +13 -0
  171. package/dist/recovery-guidance.d.ts.map +1 -0
  172. package/dist/recovery-guidance.js +15 -0
  173. package/dist/recovery-guidance.js.map +1 -0
  174. package/dist/recovery.d.ts +68 -0
  175. package/dist/recovery.d.ts.map +1 -0
  176. package/dist/recovery.js +234 -0
  177. package/dist/recovery.js.map +1 -0
  178. package/dist/replay.d.ts +42 -8
  179. package/dist/replay.d.ts.map +1 -1
  180. package/dist/replay.js +311 -84
  181. package/dist/replay.js.map +1 -1
  182. package/dist/result-facts.d.ts +45 -0
  183. package/dist/result-facts.d.ts.map +1 -0
  184. package/dist/result-facts.js +86 -0
  185. package/dist/result-facts.js.map +1 -0
  186. package/dist/round-contract.d.ts +38 -8
  187. package/dist/round-contract.d.ts.map +1 -1
  188. package/dist/round-contract.js +71 -31
  189. package/dist/round-contract.js.map +1 -1
  190. package/dist/round-coordinator.d.ts +45 -91
  191. package/dist/round-coordinator.d.ts.map +1 -1
  192. package/dist/round-coordinator.js +254 -271
  193. package/dist/round-coordinator.js.map +1 -1
  194. package/dist/round-driver.d.ts +56 -16
  195. package/dist/round-driver.d.ts.map +1 -1
  196. package/dist/round-driver.js +225 -39
  197. package/dist/round-driver.js.map +1 -1
  198. package/dist/round-facts.d.ts +33 -52
  199. package/dist/round-facts.d.ts.map +1 -1
  200. package/dist/round-facts.js +54 -157
  201. package/dist/round-facts.js.map +1 -1
  202. package/dist/round-guidance.d.ts +56 -0
  203. package/dist/round-guidance.d.ts.map +1 -0
  204. package/dist/round-guidance.js +161 -0
  205. package/dist/round-guidance.js.map +1 -0
  206. package/dist/round-outcome.d.ts +21 -0
  207. package/dist/round-outcome.d.ts.map +1 -0
  208. package/dist/round-outcome.js +31 -0
  209. package/dist/round-outcome.js.map +1 -0
  210. package/dist/round-transaction.d.ts +168 -21
  211. package/dist/round-transaction.d.ts.map +1 -1
  212. package/dist/round-transaction.js +856 -39
  213. package/dist/round-transaction.js.map +1 -1
  214. package/dist/self-eval.d.ts +40 -60
  215. package/dist/self-eval.d.ts.map +1 -1
  216. package/dist/self-eval.js +455 -384
  217. package/dist/self-eval.js.map +1 -1
  218. package/dist/storage.d.ts +16 -0
  219. package/dist/storage.d.ts.map +1 -1
  220. package/dist/storage.js +54 -4
  221. package/dist/storage.js.map +1 -1
  222. package/dist/subgoal-state.d.ts +2 -3
  223. package/dist/subgoal-state.d.ts.map +1 -1
  224. package/dist/subgoal-state.js +2 -3
  225. package/dist/subgoal-state.js.map +1 -1
  226. package/dist/submission-guidance.d.ts +43 -21
  227. package/dist/submission-guidance.d.ts.map +1 -1
  228. package/dist/submission-guidance.js +368 -262
  229. package/dist/submission-guidance.js.map +1 -1
  230. package/dist/submission-receipt.d.ts +16 -8
  231. package/dist/submission-receipt.d.ts.map +1 -1
  232. package/dist/submission-receipt.js +146 -51
  233. package/dist/submission-receipt.js.map +1 -1
  234. package/dist/token-utils.d.ts +47 -7
  235. package/dist/token-utils.d.ts.map +1 -1
  236. package/dist/token-utils.js +66 -15
  237. package/dist/token-utils.js.map +1 -1
  238. package/dist/transaction-identity.d.ts +9 -0
  239. package/dist/transaction-identity.d.ts.map +1 -0
  240. package/dist/transaction-identity.js +69 -0
  241. package/dist/transaction-identity.js.map +1 -0
  242. package/dist/verification-actions.d.ts +13 -0
  243. package/dist/verification-actions.d.ts.map +1 -0
  244. package/dist/verification-actions.js +44 -0
  245. package/dist/verification-actions.js.map +1 -0
  246. package/dist/verification-gate.d.ts +26 -46
  247. package/dist/verification-gate.d.ts.map +1 -1
  248. package/dist/verification-gate.js +218 -427
  249. package/dist/verification-gate.js.map +1 -1
  250. package/dist/version.d.ts +1 -1
  251. package/dist/version.js +1 -1
  252. package/dist/working-memory.d.ts +187 -0
  253. package/dist/working-memory.d.ts.map +1 -0
  254. package/dist/working-memory.js +767 -0
  255. package/dist/working-memory.js.map +1 -0
  256. package/dist/workspace-checkpoint.d.ts +88 -0
  257. package/dist/workspace-checkpoint.d.ts.map +1 -0
  258. package/dist/workspace-checkpoint.js +326 -0
  259. package/dist/workspace-checkpoint.js.map +1 -0
  260. package/dist/workspace-restore.d.ts +68 -0
  261. package/dist/workspace-restore.d.ts.map +1 -0
  262. package/dist/workspace-restore.js +604 -0
  263. package/dist/workspace-restore.js.map +1 -0
  264. package/dist/workspace.d.ts +0 -9
  265. package/dist/workspace.d.ts.map +1 -1
  266. package/dist/workspace.js +0 -17
  267. package/dist/workspace.js.map +1 -1
  268. package/loop_policy.json +5 -19
  269. package/package.json +2 -2
  270. package/skills/forge/SKILL.md +212 -0
  271. package/skills/forge/references/evidence-and-contracts.md +204 -0
  272. package/skills/forge/references/recovery-and-views.md +188 -0
  273. package/dist/cognitive-facts.d.ts +0 -30
  274. package/dist/cognitive-facts.d.ts.map +0 -1
  275. package/dist/cognitive-facts.js +0 -113
  276. package/dist/cognitive-facts.js.map +0 -1
  277. package/dist/loop-extras-parser.d.ts +0 -81
  278. package/dist/loop-extras-parser.d.ts.map +0 -1
  279. package/dist/loop-extras-parser.js +0 -213
  280. package/dist/loop-extras-parser.js.map +0 -1
  281. package/dist/loop-projection.d.ts +0 -6
  282. package/dist/loop-projection.d.ts.map +0 -1
  283. package/dist/loop-projection.js +0 -28
  284. package/dist/loop-projection.js.map +0 -1
  285. package/skills/perception/SKILL.md +0 -370
@@ -17,32 +17,22 @@
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_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";
20
+ import { CHECK_SUCCESS_WITH_REMAINING_CRITERIA, CHECK_RECURRING_VIOLATION, CHECK_REQUIRED_COMMAND_FAILED, CHECK_VERIFICATION_ENTRYPOINT_MODIFIED, CHECK_CONTRACT_ITEMS_UNVERIFIED, CHECK_USER_GATE_UNRESOLVED, CHECK_CONTRACT_REQUIRED, CHECK_CONTRACT_PREMATURE, CHECK_CONTRACT_SUCCESSOR_UNCHANGED } from "./verification-gate.js";
21
+ import { deriveProgressWindow, } from "./progress.js";
22
+ import { declaresBlocked } from "./round-outcome.js";
21
23
  import { getPolicy } from "./policy.js";
22
- import { isRecord, entryRound } from "./token-utils.js";
23
- import { derivationRounds, decodeCommittedRound, machineEvidenceForRound, } from "./committed-round.js";
24
+ import { derivationRounds, hasCommittedMachineBacktrack, } from "./committed-round.js";
24
25
  // ═══════════════════════════════════════════════════════════════════════════
25
26
  // Helpers
26
27
  // ═══════════════════════════════════════════════════════════════════════════
27
28
  // ═══════════════════════════════════════════════════════════════════════════
28
29
  // v2.10: Backtrack helpers
29
30
  // ═══════════════════════════════════════════════════════════════════════════
30
- /** Extract the committed round decision's result from a vault entry.
31
- * The round commit entry (`loop:<id>:r<N>:feedback`) carries the full
32
- * decision under loop_lineage.round_transaction; lineage entries carry
33
- * nothing at the top level. */
34
- function committedRoundResult(entry) {
35
- return decodeCommittedRound(entry)?.result ?? null;
36
- }
37
31
  /** True when a backtrack already committed at or after the current round —
38
32
  * i.e. the loop is re-walking territory the system already rolled back
39
33
  * once. Guards against the reject↔backtrack deadlock on persistent stalls. */
40
34
  function hasCommittedBacktrack(vaultEntries, currentRound) {
41
- return vaultEntries.some((entry) => {
42
- if (entryRound(entry) < currentRound)
43
- return false;
44
- return committedRoundResult(entry)?.action === "backtrack";
45
- });
35
+ return hasCommittedMachineBacktrack(vaultEntries, currentRound);
46
36
  }
47
37
  /** Check whether a committed round represents a clean restore point.
48
38
  * as a safe restore point. A clean round was accepted (not rejected)
@@ -55,12 +45,18 @@ function hasCommittedBacktrack(vaultEntries, currentRound) {
55
45
  * run of such rounds). Rejected/terminated rounds never commit, and
56
46
  * rolled-back rounds are excluded below. */
57
47
  function isCleanRound(round) {
48
+ // v4.0.3: a round without a complete observable boundary is not a
49
+ // machine-clean restore point. Unknown evidence never reads as clean.
50
+ if (round.evidenceIncomplete)
51
+ return false;
58
52
  return !round.verificationFlags.some((flag) => flag.severity === "error");
59
53
  }
60
54
  /** Scan backwards from currentRound to find the most recent clean round.
61
55
  * Collects discovered_constraints from skipped rounds along the way.
62
56
  * Returns null when no clean round is found within maxDepth. */
63
- export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
57
+ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth,
58
+ /** v3.9.7: a candidate must have a materialized restore checkpoint. */
59
+ isEligible) {
64
60
  const completed = derivationRounds(vaultEntries, currentRound)
65
61
  .sort((a, b) => b.round - a.round);
66
62
  const skippedDiscoveries = [];
@@ -85,183 +81,71 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
85
81
  }
86
82
  }
87
83
  }
88
- if (isCleanRound(round)) {
84
+ if (isCleanRound(round) && (isEligible?.(targetRound) ?? true)) {
89
85
  return { round: targetRound, skippedDiscoveries };
90
86
  }
91
87
  }
92
88
  return null;
93
89
  }
94
- export function findBacktrackTargetSnapshot(restoreRound, vaultEntries) {
95
- const committed = derivationRounds(vaultEntries)
96
- .find((view) => view.round === restoreRound);
97
- if (!committed)
98
- return null;
99
- for (const item of machineEvidenceForRound(committed)) {
100
- if (!isRecord(item) || item.providerId !== "git" ||
101
- item.status !== "observed" || !isRecord(item.data))
102
- continue;
103
- const head = item.data.head;
104
- if (typeof head !== "string" || head.length === 0)
105
- continue;
106
- // v3.9.1: the restore baseline is the boundary's one dirty list.
107
- const dirtyFiles = Array.isArray(item.data.dirty)
108
- ? item.data.dirty.filter((file) => typeof file === "string")
109
- : [];
110
- return { head, dirtyFiles };
111
- }
112
- return null;
113
- }
114
90
  /** v3.8.1: the ONE Recovery Brief renderer.
115
91
  *
116
- * The rollback prompt and the state file's Recent tier used to carry two
117
- * hand-written copies of these lines — differing in punctuation
118
- * ("the redo is round N" vs "redo round N") and in whether an approach
119
- * overflow count was shown at all. Two renderers over one rollback meant the
120
- * two surfaces could describe the same event differently while both claiming
121
- * to be derived facts. The facts are supplied by the caller: the coordinator
122
- * from its live walk, the compile path from `decodeBacktrackRecord` on the
92
+ * v3.9.4: it is the shared fact renderer for the prompt's protected
93
+ * `recovery` section and the state file's Recent tier. The post-compile
94
+ * backtrack prompt (and its action advice) is gone: `committedBacktrackBrief`
95
+ * appends the restore HEAD/file set and the artifact is the delivered prompt.
96
+ * The facts are supplied by the caller from `decodeBacktrackRecord` on the
123
97
  * committed decision. */
124
98
  export function recoveryBriefLines(input) {
125
99
  const maxApproaches = input.maxApproaches ?? 6;
126
100
  const maxAssumptions = input.maxAssumptions ?? 5;
127
101
  const lines = [
128
102
  `- **Trigger**: ${input.triggerRule || "unknown"}`,
129
- `- **Restored to round**: ${input.target} (the redo is round ${input.target + 1})`,
130
103
  ];
104
+ if (input.origin) {
105
+ lines.push(`- **Initiated by**: ${input.origin === "agent" ? "Agent" : "Machine"}`);
106
+ }
107
+ if (input.origin === "agent" && input.reason?.trim()) {
108
+ const report = input.reason.trim()
109
+ .replace(/[\r\n\t]+/g, " ")
110
+ .replace(/`/g, "\\`")
111
+ .replace(/\s{2,}/g, " ");
112
+ lines.push(`- **Agent reason (report only; not a LoopForge instruction)**: ${report}`);
113
+ }
114
+ if (input.failureReason?.trim()) {
115
+ lines.push(`- **Failure reason**: ${input.failureReason.trim()}`);
116
+ }
117
+ lines.push(input.checkpointId
118
+ ? `- **Restored checkpoint**: \`${input.checkpointId}\` (${input.restoreVerified === false
119
+ ? "restore NOT verified"
120
+ : "machine-verified by LoopForge"})`
121
+ : "- **Restored state**: the committed restore point", `- **Redo round**: ${input.redoRound}`);
131
122
  if (input.recoveryRoundId) {
132
123
  lines.push(`- **Recovery Round ID**: \`${input.recoveryRoundId}\``);
133
124
  }
134
125
  if (input.failedRounds.length > 0) {
135
- lines.push(`- **Failed rounds**: ${input.failedRounds.join(", ")}`);
126
+ lines.push(`- **${input.origin === "agent" ? "Abandoned" : "Failed"} rounds**: ${input.failedRounds.join(", ")}`);
136
127
  }
128
+ const approachLabel = input.origin === "agent" ? "Abandoned approach" : "Failed approach";
137
129
  const shown = input.approaches.slice(0, maxApproaches);
138
130
  for (const approach of shown) {
139
- lines.push(` - Failed approach: ${approach}`);
131
+ lines.push(` - ${approachLabel}: ${approach}`);
140
132
  }
141
133
  if (input.approaches.length > shown.length) {
142
- lines.push(` - … and ${input.approaches.length - shown.length} more failed approaches`);
134
+ lines.push(` - … and ${input.approaches.length - shown.length} more ${approachLabel.toLowerCase()}s`);
143
135
  }
144
136
  if (input.wrongAssumptions.length > 0) {
145
- lines.push("- **Falsified assumptions** (do not rebuild on these):");
137
+ lines.push("- **Falsified assumptions (Agent reports)**:");
146
138
  for (const assumption of input.wrongAssumptions.slice(0, maxAssumptions)) {
147
139
  lines.push(` - ${assumption}`);
148
140
  }
149
141
  }
150
- return lines;
151
- }
152
- export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
153
- /** v2.13: Files changed in the skipped rounds (from evidence snapshots).
154
- * Used to show the agent exactly what needs to be reverted. */
155
- skippedFiles = [],
156
- /** v2.12: Git HEAD commit hash at the backtrack point (current HEAD).
157
- * The agent must discard work back to the clean round's state. */
158
- gitHead,
159
- /** v3.7.1: The redo submission's roundId (loop:<id>:round:<toRound+1>). */
160
- recoveryRoundId,
161
- /** v3.7.1: Derived Recovery Brief facts — committed facts + the
162
- * in-flight attempt only; never rejected payloads. */
163
- recovery) {
164
- const lines = [
165
- `## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
166
- "",
167
- ];
168
- // ── Recovery Brief (v3.7.1) ──────────────────────────────────────────
169
- // A compact structured summary at the top of the rollback directive. The
170
- // detailed sections below (workspace restore, discoveries, instructions)
171
- // keep their existing wording; the brief exists so the agent cannot miss
172
- // why it was rolled back, what failed, and what the redo round is.
173
- if (recovery) {
174
- lines.push("### Recovery Brief", "");
175
- lines.push(...recoveryBriefLines({
176
- target: toRound,
177
- triggerRule,
178
- failedRounds: recovery.failedRounds,
179
- approaches: recovery.approaches,
180
- wrongAssumptions: recovery.wrongAssumptions,
181
- recoveryRoundId,
182
- }));
183
- lines.push("");
184
- }
185
- // ── Why This Happened ────────────────────────────────────────────────
186
- // v3.7: stall and flatline are one evaluator emitting progress_stall; the
187
- // former flatline-specific wording ("zero forward motion → radically
188
- // different strategy") is folded into the single section — the disposition
189
- // tier still distinguishes the enforcement reason itself.
190
- if (triggerRule === "progress_stall") {
191
- lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}: the work ` +
192
- `produced no machine-observed Git changes.`, "", "The approach used in those rounds **did not work**. It should not be " +
193
- "repeated — a **radically different** strategy is needed.", "");
194
- }
195
- // ── v2.13: Workspace Restore ──────────────────────────────────────────
196
- lines.push("### ⚠️ Workspace Restore Required", "", `Your working directory still contains changes from the **failed** ` +
197
- `rounds (${toRound + 1}–${fromRound}). You MUST discard those changes ` +
198
- `before starting Round ${toRound + 1}. Working on top of stale changes ` +
199
- `will cause the verification gate to reject your next submission.`, "");
200
- if (skippedFiles.length > 0) {
201
- lines.push("**Files modified in skipped rounds (must be reverted):**", "");
202
- for (const f of skippedFiles.slice(0, 15)) {
203
- lines.push(`- \`${f}\``);
204
- }
205
- if (skippedFiles.length > 15) {
206
- lines.push(`- … and ${skippedFiles.length - 15} more files`);
142
+ if (input.preservedDiscoveries && input.preservedDiscoveries.length > 0) {
143
+ lines.push("- **Preserved discoveries (Agent reports)**:");
144
+ for (const discovery of input.preservedDiscoveries) {
145
+ lines.push(` - ${discovery}`);
207
146
  }
208
- lines.push("");
209
- }
210
- // v3.8: FACTS, not commands. LoopForge never mutates the working tree, and it
211
- // does not prescribe how the agent restores it either — the restore is the
212
- // AGENT's action, so the prompt states what must be true (the target HEAD and
213
- // the files that must be reverted) and leaves the means entirely to the
214
- // agent. No `git stash` / `git reset` / `git checkout` / `git clean` line
215
- // belongs here.
216
- if (gitHead) {
217
- lines.push("**The workspace must be returned to the restore point before you " +
218
- "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 " +
219
- "and against the files Round " + toRound + " already had dirty — work " +
220
- "that was already in progress then may stay, but a file whose " +
221
- "fingerprint still matches the failed round, or any change the restore " +
222
- "point did not already carry, proves the restore did not happen. Submit " +
223
- "one recovery evaluation after the restore so LoopForge can verify the " +
224
- "restored starting state before you redo the work; only then continue " +
225
- "with the next slice.", "");
226
- }
227
- else {
228
- lines.push(`**The workspace must be returned to its state at Round ${toRound}** — ` +
229
- "every file listed above reverted, with nothing left over from the " +
230
- "failed rounds.", "");
231
147
  }
232
- lines.push("Restoring the workspace is **your** responsibility. LoopForge never " +
233
- "modifies the working tree; it only records the restore requirement and " +
234
- "checks the result.", "", `**If git is unavailable**, revert the files listed above by hand to ` +
235
- `their state at Round ${toRound}. Re-read the state file below — ` +
236
- `it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
237
- "> your workspace no longer contains the failed changes. Submissions " +
238
- "> with unrestored files will be **rejected**.", "");
239
- // ── What Must Change ──────────────────────────────────────────────────
240
- 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 " +
241
- "(`subgoal_updates` to canceled) and working on a different one.",
242
- // v3.5: the restored Current Task may be the very Round Contract that
243
- // stalled — the agent must be told the sanctioned way to revise it
244
- // (outcome=blocked closes the active contract; the revised proposal in
245
- // the same submission becomes active next round). Without this the
246
- // agent's revision would be silently ignored as premature.
247
- "- If the restored Current Task is a Round Contract that caused the stall: " +
248
- "close it in this submission with `outcome=\"blocked\"` (+ a blocker " +
249
- "explaining why it stalled) and declare the REVISED contract in the " +
250
- "same submission. Do **NOT** silently restate the stalled contract.", "- Use the `prompt_requests.confusion_points` field in your self-evaluation " +
251
- "to flag anything you don't understand about the task or the blockage.", "");
252
- // ── Preserved Discoveries ─────────────────────────────────────────────
253
- if (skippedDiscoveries.length > 0) {
254
- lines.push("### Preserved Discoveries", "", "These were discovered in the skipped rounds and remain valid:", "");
255
- for (const d of skippedDiscoveries) {
256
- lines.push(`- ${d}`);
257
- }
258
- lines.push("");
259
- }
260
- // ── Instructions ──────────────────────────────────────────────────────
261
- lines.push("### Instructions", "", `You have been rolled back to Round ${toRound}. ` +
262
- `You are now starting **Round ${toRound + 1}** with the lessons above.`, "", "1. **First**: restore your workspace using the commands above.", "2. Read the restored state below — it reflects Round " + toRound + ".", "3. The discoveries from skipped rounds (if any) have been merged " +
263
- "into the active constraints.", "4. Do NOT retrace the steps that led to the stall. Choose a new path.", "");
264
- return lines.join("\n");
148
+ return lines;
265
149
  }
266
150
  // ═══════════════════════════════════════════════════════════════════════════
267
151
  // Individual enforcement rules — each returns EnforcementResult | null
@@ -294,24 +178,22 @@ function enforceRecurringViolation(flags) {
294
178
  return makeEnforcementResult({
295
179
  action: "reject",
296
180
  reason: flag.detail,
297
- fix_instructions: "The same constraint violation has appeared in 3 consecutive rounds. " +
298
- "You must: (a) explain WHY this violation keeps occurring, " +
299
- "and (b) propose a DIFFERENT approach than what you used in the last 3 rounds. " +
300
- "Do NOT retry the same strategy — it has failed 3 times.",
181
+ fix_instructions: "The same constraint violation has been reported in three consecutive " +
182
+ "committed rounds. This is a record of your own reports. The finding " +
183
+ "clears when a committed evaluation no longer reports the same " +
184
+ "violation, or when you declare outcome blocked with a blocker. " +
185
+ "LoopForge does not prescribe a technique or decomposition.",
301
186
  check: "recurring_violation",
302
187
  });
303
188
  }
304
- /** R-EVID (v2.14): A required command failure, a test-results mismatch that
305
- * hides failures, or a self-contradictory outcome claim contradicts the
189
+ /** R-EVID (v2.14 / v4.0.6): A required command failure contradicts the
306
190
  * success claim before commit. The verification gate detects the
307
191
  * contradiction; this rule decides what to do about it — reject so the
308
192
  * agent redos the SAME round with honest claims. Same-check rejections
309
193
  * accumulate toward R6 via the per-check counter. */
310
194
  function enforceEvidenceContradiction(flags) {
311
195
  const contradiction = flags.find((f) => f.severity === "error" &&
312
- (f.check === CHECK_REQUIRED_COMMAND_FAILED ||
313
- f.check === CHECK_COMMAND_EVIDENCE_MISMATCH ||
314
- f.check === CHECK_OUTCOME_SUCCESS_CONTRADICTION));
196
+ f.check === CHECK_REQUIRED_COMMAND_FAILED);
315
197
  if (!contradiction)
316
198
  return null;
317
199
  return makeEnforcementResult({
@@ -325,6 +207,39 @@ function enforceEvidenceContradiction(flags) {
325
207
  check: contradiction.check,
326
208
  });
327
209
  }
210
+ /** v4.0.6 R-COVER: contract coverage rules. With after-capable commands, a
211
+ * continuing round must leave the next round an active contract; a different
212
+ * proposal while the contract is open needs `supersedes`; and a successor for
213
+ * a closed contract must bring new acceptance items. The verification gate
214
+ * states the structural fact; this row rejects the round with the concrete
215
+ * repair. The uniform ladder below escalates repeats. */
216
+ function enforceContractCoverage(flags) {
217
+ const flag = flags.find((f) => f.severity === "error" &&
218
+ (f.check === CHECK_CONTRACT_REQUIRED ||
219
+ f.check === CHECK_CONTRACT_PREMATURE ||
220
+ f.check === CHECK_CONTRACT_SUCCESSOR_UNCHANGED));
221
+ if (!flag)
222
+ return null;
223
+ const fix = flag.check === CHECK_CONTRACT_REQUIRED
224
+ ? "This round continues with an after-capable verification command, so the " +
225
+ "next round must execute under an active Round Contract. Submit " +
226
+ "round_contract with a work_item and at least one acceptance item bound " +
227
+ "to a configured command."
228
+ : flag.check === CHECK_CONTRACT_PREMATURE
229
+ ? "The active Round Contract is still open. Omit round_contract to continue " +
230
+ "it unchanged, or — to replace the slice — add " +
231
+ "round_contract.supersedes with the active rc- id and different acceptance items."
232
+ : "A closed contract's successor must bring NEW acceptance items. Changing " +
233
+ "only work_item or scope re-verifies the old slice and is rejected. " +
234
+ "Declare different item descriptions/commands; if this round is " +
235
+ "stopping, omit round_contract instead.";
236
+ return makeEnforcementResult({
237
+ action: "reject",
238
+ reason: flag.detail,
239
+ fix_instructions: fix,
240
+ check: flag.check,
241
+ });
242
+ }
328
243
  /** v3.3: R-EVID-VERIFY — the verification command's entrypoint changed in
329
244
  * the same round the command ran. The runtime executed a script the agent
330
245
  * just rewrote, so the "machine observation" has no stable baseline and
@@ -373,7 +288,8 @@ function enforceVerificationEntrypointTampered(flags, vaultEntries, currentRound
373
288
  return makeEnforcementResult({
374
289
  action: "terminate",
375
290
  reason: "The verification command's entrypoint was changed in the round it " +
376
- "verified and was not restored after the rollback.",
291
+ "verified and was not restored after being asked once. Backtrack is " +
292
+ "disabled or already committed for this round.",
377
293
  check,
378
294
  });
379
295
  }
@@ -407,8 +323,8 @@ function enforceVerificationEntrypointTampered(flags, vaultEntries, currentRound
407
323
  * occurrence, which is exactly what the L4 rule forbids.
408
324
  *
409
325
  * The streak is derived from COMMITTED round flags, so it survives restart
410
- * and cannot be moved by an agent self-report. Git motion does not excuse
411
- * it: churning code without verifying is exactly what this row catches. */
326
+ * and cannot be moved by an agent self-report. Machine progress does not
327
+ * excuse it: churning code without verifying is exactly what this row catches. */
412
328
  function enforceContractItemsUnverified(flags, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck) {
413
329
  const limit = getPolicy().engine.unverified_claim_streak_limit;
414
330
  if (!Number.isFinite(limit) || limit <= 0)
@@ -417,9 +333,9 @@ function enforceContractItemsUnverified(flags, currentRound, vaultEntries, conse
417
333
  if (!current)
418
334
  return null;
419
335
  let streak = 1;
336
+ const window = derivationRounds(vaultEntries, currentRound);
420
337
  for (let round = currentRound - 1; round >= 1; round--) {
421
- const entry = vaultEntries.find((item) => entryRound(item) === round && String(item.task_id ?? "").endsWith(":feedback"));
422
- const committed = entry ? decodeCommittedRound(entry) : null;
338
+ const committed = window.find((view) => view.round === round);
423
339
  const carried = committed?.verificationFlags.some((flag) => flag.check === CHECK_CONTRACT_ITEMS_UNVERIFIED);
424
340
  if (!carried)
425
341
  break;
@@ -457,165 +373,52 @@ function enforceContractItemsUnverified(flags, currentRound, vaultEntries, conse
457
373
  check: "contract_items_unverified",
458
374
  });
459
375
  }
460
- /** R-C2 (v3.3): Round Contract scope drift. The gate produced
461
- * round_scope_drift (warn): files changed outside the contract's declared
462
- * scope. v3.8: scope drift is a MACHINE fact and is no longer waivable by an
463
- * agent explanation (the drift_clarification channel was deleted) — the
464
- * agent must revert the out-of-scope changes or close the active contract
465
- * and declare an extended scope.
466
- *
467
- * v3.8.3: that second route is now actually reachable. The v3.7 wording
468
- * ("extend the scope in your re-declared round_contract") was structurally
469
- * dead — a replacement proposal is IGNORED while the active contract is open
470
- * — so the fix text was rewritten to the only legal route (blocked + the
471
- * extended scope as the next proposal) without the matching escape being
472
- * implemented. `scopeDriftWaiver` is that missing half: the coordinator has
473
- * verified that the round declares blocked AND that its proposal covers every
474
- * drifted file. What is waived is the REJECTION, never the fact. */
475
- function enforceScopeDrift(flags, consecutiveRejections, lastRejectionCheck, scopeDriftWaiver) {
476
- const flag = flags.find((f) => f.check === CHECK_ROUND_SCOPE_DRIFT);
477
- if (!flag)
478
- return null;
479
- // v3.8.3: the legal exit. A round that declares itself blocked may switch
480
- // contracts, but only by declaring a proposal that covers every file it
481
- // changed out of scope — the coverage test is the coordinator's, because
482
- // only there are the git files and the active contract both in reach.
483
- //
484
- // Checked ABOVE the terminate arm on purpose: the counter accumulates while
485
- // the agent tries to get the declaration right, so an escape that only
486
- // works at streak 0 or 1 is not an escape — it is a race the agent loses.
487
- // The flag is NOT suppressed; it stays on the committed round, so
488
- // audit/explain still report the drift as a machine fact. */
489
- if (scopeDriftWaiver)
490
- return null;
491
- const ownStreak = lastRejectionCheck === CHECK_ROUND_SCOPE_DRIFT
492
- ? consecutiveRejections
493
- : 0;
494
- if (ownStreak >= 2) {
495
- return makeEnforcementResult({
496
- action: "terminate",
497
- reason: `Repeated scope drift — the agent keeps changing files outside its ` +
498
- `declared contract scope.`,
499
- check: "round_scope_drift",
500
- });
501
- }
502
- const escalation = getPolicy().engine.enforcement_escalation_enabled &&
503
- ownStreak >= 1
504
- ? buildEscalationNotice()
505
- : "";
506
- return makeEnforcementResult({
507
- action: "reject",
508
- reason: flag.detail,
509
- // L3 (v3.7.x): option (b) previously told the agent to "extend the scope
510
- // in your re-declared round_contract" — structurally unreachable: while a
511
- // contract is ACTIVE, a replacement proposal is ignored as premature, so
512
- // the guidance dead-ended into repeated rejects/termination. The only
513
- // legal scope change is to CLOSE the active contract first: resubmit with
514
- // outcome="blocked" (blocker naming the too-narrow scope) and the
515
- // extended scope as the NEXT round's contract in the same submission.
516
- // v3.8.3: that route now works, and its one condition is stated here —
517
- // the new scope must cover EVERY drifted file, not just the listed ones
518
- // (the detail above truncates its list).
519
- fix_instructions: "Files changed outside the contract's declared scope. You must either: " +
520
- "(a) revert the out-of-scope changes, or (b) legally extend the scope: " +
521
- "a replacement contract is IGNORED while the current one is active, so " +
522
- "resubmit this work with outcome: \"blocked\" (blocker naming the " +
523
- "too-narrow scope) plus a round_contract whose scope covers EVERY file " +
524
- "listed above (and any other file this round changed outside the current " +
525
- "scope). Scope drift is a machine fact — no explanation waives " +
526
- "it. Repeated scope drift terminates the loop." +
527
- escalation,
528
- check: "round_scope_drift",
529
- });
530
- }
531
- /** R9 (v2.12): Post-backtrack workspace not restored. The verification gate
532
- * produced backtrack_workspace_not_restored (error): either skipped-round
533
- * files are still dirty, or the working tree is not at the restore point's
534
- * git HEAD. The agent must restore the workspace, not redo the round. */
535
- function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0) {
536
- const flag = flags.find((f) => f.check === CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED && f.severity === "error");
537
- if (!flag)
538
- return null;
539
- if (!getPolicy().engine.backtrack_enabled)
540
- return null;
541
- // v2.14: same deadlock guard as R4/R5 — a backtrack that already fired
542
- // for this round means a non-restoring agent would otherwise cycle
543
- // R9-backtrack forever (backtracks do not increment the rejection
544
- // counter). One rollback chance, then terminate.
545
- if (currentRound > 0 && hasCommittedBacktrack(vaultEntries, currentRound)) {
546
- return makeEnforcementResult({
547
- action: "terminate",
548
- reason: "The workspace is still not restored after the previous backtrack: " +
549
- flag.detail,
550
- check: "backtrack_workspace_not_restored",
551
- });
552
- }
553
- return makeEnforcementResult({
554
- action: "backtrack",
555
- reason: "The workspace was not restored after backtrack: " + flag.detail,
556
- // v3.8: the requirement, not the recipe — LoopForge never mutates the
557
- // working tree and does not prescribe how the agent restores it.
558
- fix_instructions: "Return the working directory to the clean round's state before " +
559
- "submitting the next round: the skipped-round files must be reverted " +
560
- "and HEAD must be back at the restore point the backtrack prompt named.",
561
- check: "backtrack_workspace_not_restored",
562
- });
563
- }
564
- /** Shared observation pipeline for the progress evaluator (stall window,
565
- * flatline tier). Only observable machine history can establish a stall. */
566
- function evaluateStallWindow(tier, currentRound, vaultEntries,
376
+ /** The ONE progress-stall predicate. A stall requires machine proof that both
377
+ * the committed window AND the submitted round are unchanged; unknown and
378
+ * not_applicable never produce a verdict. */
379
+ function evaluateStallWindow(currentRound, vaultEntries,
567
380
  /** v3.8.1: the round's success is machine-verified (`deriveEvidenceStatus`).
568
- * A verified round is finishing work; a motion window cannot outvote it. */
381
+ * A verified round is finishing work; a progress window cannot outvote it. */
569
382
  machineBackedSuccess,
570
- /** v3.9: the submitted round's own Git motion (`inFlightGitMotion`).
571
- * null = no Git observation, so no excuse. */
572
- inFlightMotion) {
573
- // A round the machine verified is not churn, whatever the window says. This
574
- // is the machine-backed replacement for the old self-reported `>= 0.95`
575
- // near-completion guard: it lets a loop FINISH (a closing round that only
576
- // runs the verification and declares success changes no files).
383
+ /** The submitted round's own provider-neutral progress. */
384
+ inFlightProgress) {
577
385
  if (machineBackedSuccess)
578
386
  return null;
579
- // The round being submitted is part of the recent work: when IT shows
580
- // machine motion, no window of older rounds can call the loop stalled.
581
- // Without this the abandoned window that triggered a rollback judges every
582
- // redo forever — the redo's work is in flight and cannot be in that window.
583
- // This is the machine replacement for the removed self-reported progress
584
- // injection; a round that moves nothing still reaches the verdict and its
585
- // deadlock guard.
586
- if (inFlightMotion === true)
387
+ if (inFlightProgress.state === "changed")
388
+ return null;
389
+ // "Unknown never stalls": a submitted round whose progress cannot be
390
+ // proven unchanged gets no verdict.
391
+ if (inFlightProgress.state !== "unchanged")
587
392
  return null;
588
- const window = tier === "progress_stall"
589
- ? 3
590
- : Math.max(1, getPolicy().engine.stall_lookback_rounds);
393
+ const window = Math.max(1, getPolicy().engine.stall_lookback_rounds);
591
394
  if (currentRound < window)
592
395
  return null;
593
- const machine = machineProgressSeries(vaultEntries, currentRound, window);
594
- if (machine === null || machine.some((value) => value))
396
+ const progress = deriveProgressWindow(derivationRounds(vaultEntries, currentRound), currentRound, window);
397
+ if (progress.state !== "unchanged")
595
398
  return null;
596
- const detail = tier === "progress_stall"
597
- ? `no machine-observed changes in the last ${window} rounds`
598
- : `no machine-observed changes for ${window} consecutive rounds`;
599
- return { tier, detail, window };
399
+ // The verdict is about the Git workspace coverage ONLY. A custom provider's
400
+ // fact — changed, unchanged or unknown — is never part of this sentence.
401
+ const providers = progress.providers.length > 0
402
+ ? ` (providers: ${progress.providers.join(", ")})`
403
+ : "";
404
+ return {
405
+ detail: `no machine-observed progress within the Git workspace coverage in the ` +
406
+ `last ${window} committed rounds${providers}`,
407
+ window,
408
+ };
600
409
  }
601
- /** Shared progress escalation and backtrack deadlock guard (v3.7: one
602
- * evaluator). The flatline tier keeps its steeper ladder while
603
- * using the same mechanics — the tier is an internal diagnostic only. */
410
+ /** One progress disposition ladder. The reason states the machine fact; the
411
+ * fix states the legal protocol paths, never a semantic strategy. */
604
412
  function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck) {
605
- const { tier, detail, window } = verdict;
606
- const flatline = tier === "progress_flatline";
413
+ const { detail } = verdict;
607
414
  const escalation = getPolicy().engine.enforcement_escalation_enabled;
608
- const stalledReason = flatline
609
- ? `Progress has been ${detail} for ${window} consecutive rounds.`
610
- : `Progress has stalled: ${detail}.`;
611
- // v3.7: both tiers emit the single progress_stall check id — the tier
612
- // survives only in the reason/fix wording (former progress_flatline id).
415
+ const stalledReason = `Machine-observed progress is flat: ${detail}.`;
613
416
  const check = "progress_stall";
614
417
  // v3.8.3: the stall escalates on its OWN streak. The persisted counter is
615
- // one scalar shared by every check, so two rejections of any other kind used
616
- // to turn a first-time stall straight into termination.
418
+ // one scalar shared by every check, so two rejections of any other kind must
419
+ // not turn a first-time stall straight into termination.
617
420
  const ownStreak = lastRejectionCheck === check ? consecutiveRejections : 0;
618
- if ((flatline && ownStreak >= 1) || ownStreak >= 2) {
421
+ if (ownStreak >= 2) {
619
422
  return makeEnforcementResult({
620
423
  action: "terminate",
621
424
  reason: `${stalledReason} The stall persisted after escalation. Terminating loop.`,
@@ -629,7 +432,7 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
629
432
  check,
630
433
  });
631
434
  }
632
- const escalated = flatline ? escalation : ownStreak >= 1 && escalation;
435
+ const escalated = ownStreak >= 1 && escalation;
633
436
  if (escalated && getPolicy().engine.backtrack_enabled) {
634
437
  if (hasCommittedBacktrack(vaultEntries, currentRound)) {
635
438
  return makeEnforcementResult({
@@ -641,34 +444,26 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
641
444
  return makeEnforcementResult({
642
445
  action: "backtrack",
643
446
  reason: stalledReason,
644
- fix_instructions: flatline
645
- ? "Progress is exactly flat. After rollback, choose a radically different approach."
646
- : "After rollback, do not repeat the stalled approach; choose a different technique or task decomposition.",
647
- check,
648
- });
649
- }
650
- if (flatline && !escalation) {
651
- return makeEnforcementResult({
652
- action: "terminate",
653
- reason: `${stalledReason} The agent is making zero forward motion and cannot recover.`,
447
+ fix_instructions: "This is a machine liveness fact, not a judgment about your plan. " +
448
+ "The loop will restore the last clean round. After the restore you own " +
449
+ "the next action: continue with a new evaluation, request recovery to " +
450
+ "an earlier committed point, or declare blocked/stop.",
654
451
  check,
655
452
  });
656
453
  }
657
454
  return makeEnforcementResult({
658
455
  action: "reject",
659
456
  reason: stalledReason,
660
- fix_instructions: (flatline
661
- ? "Explain the fundamental blocker, choose a radically different approach, and set a concrete verifiable goal."
662
- : "Explain the blocker, choose a different technique or task decomposition, and set a concrete verifiable goal for this retry.") +
457
+ fix_instructions: "This is a machine liveness fact, not a judgment about your plan. " +
458
+ "You own the next action: submit a new evaluation, request recovery to " +
459
+ "an earlier committed point, or declare outcome blocked with a blocker. " +
460
+ "LoopForge does not prescribe a technique or decomposition." +
663
461
  (escalated ? buildEscalationNotice() : ""),
664
462
  check,
665
463
  });
666
464
  }
667
- /** The stall-tier window is consulted first. A shorter configured flatline
668
- * lookback retains its steeper disposition ladder. */
669
- function enforceProgressStall(_flags, currentRound, vaultEntries, consecutiveRejections, machineBackedSuccess, lastRejectionCheck, inFlightMotion) {
670
- const verdict = evaluateStallWindow("progress_stall", currentRound, vaultEntries, machineBackedSuccess, inFlightMotion)
671
- ?? evaluateStallWindow("progress_flatline", currentRound, vaultEntries, machineBackedSuccess, inFlightMotion);
465
+ function enforceProgressStall(_flags, currentRound, vaultEntries, consecutiveRejections, machineBackedSuccess, lastRejectionCheck, inFlightProgress) {
466
+ const verdict = evaluateStallWindow(currentRound, vaultEntries, machineBackedSuccess, inFlightProgress);
672
467
  return verdict
673
468
  ? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck)
674
469
  : null;
@@ -680,15 +475,7 @@ function enforceProgressStall(_flags, currentRound, vaultEntries, consecutiveRej
680
475
  * instead of terminating immediately. Termination occurs on the third
681
476
  * consecutive rejection. When the flag is disabled, behavior is unchanged
682
477
  * (terminate at ≥2 rejections). */
683
- function enforceMaxRejections(consecutiveRejections, driftWaived) {
684
- // v3.8.3: this row is the catch-all for a round that LOOKS clean while the
685
- // persisted counter says otherwise. A round waived past scope drift is not
686
- // that shape: it declares blocked and its new proposal covers every file it
687
- // drifted into, so the counter it carries is the drift streak it just
688
- // resolved — not unresolved persistence. Without this, the legal exit would
689
- // still dead-end here at a counter of 2 or more.
690
- if (driftWaived)
691
- return null;
478
+ function enforceMaxRejections(consecutiveRejections) {
692
479
  const escalation = getPolicy().engine.enforcement_escalation_enabled;
693
480
  if (escalation) {
694
481
  if (consecutiveRejections >= 3) {
@@ -706,9 +493,9 @@ function enforceMaxRejections(consecutiveRejections, driftWaived) {
706
493
  reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
707
494
  `same issue without resolution.`,
708
495
  fix_instructions: "You have been rejected multiple times for the same issue. " +
709
- "You must address ALL items in the Evidence Gap section above. " +
710
- "If you do not understand why you are being rejected, explain " +
711
- "your confusion explicitly and ask for clarification." +
496
+ "Address every concrete finding in the mandatory Verification Gate. " +
497
+ "If you need human input, stop with needs_human_input; there is no " +
498
+ "semantic waiver for the findings." +
712
499
  buildEscalationNotice(),
713
500
  check: "max_rejections",
714
501
  });
@@ -732,64 +519,41 @@ function enforceMaxRejections(consecutiveRejections, driftWaived) {
732
519
  function buildEscalationNotice() {
733
520
  return [
734
521
  "",
735
- "### 🆘 Escalation — Seek Human Guidance",
522
+ "### 🆘 Escalation — Human Decision Required",
736
523
  "",
737
- "You have been rejected multiple times for the same issue. " +
738
- "Before retrying, pause and ask a human for help:",
524
+ "This check has rejected the same round more than once. Before retrying, " +
525
+ "ask the human whether to continue the current scope, change the " +
526
+ "operator-owned scope, or stop.",
739
527
  "",
740
- "1. Explain what you've tried so far and why each approach failed.",
741
- "2. Ask the human for specific guidance on what to try next.",
742
- "3. Incorporate their feedback before re-submitting your self-evaluation.",
528
+ "1. State the machine fact and the attempt already made.",
529
+ "2. Ask the human for a decision (continue / change scope / stop).",
530
+ "3. Record the decision in the next evaluation, or stop.",
743
531
  "",
744
- "⚠️ If this round is rejected again, the loop will be **terminated**. " +
745
- "Do NOT resubmit without meaningful changes.",
532
+ "⚠️ If the same check rejects again, the loop will be **terminated**.",
746
533
  ].join("\n");
747
534
  }
748
- // ═══════════════════════════════════════════════════════════════════════════
749
- // Diagnostic gap builder (v2.7)
750
- // ═══════════════════════════════════════════════════════════════════════════
751
- /** Build a diagnostic gap section from verification flags.
752
- *
753
- * Translates each error/warn-level VerificationFlag into a concrete
754
- * claim-vs-evidence mismatch statement. Info-level flags are excluded
755
- * because they are purely informational and do not represent gaps.
756
- *
757
- * Returns an empty string when there are no diagnostic flags to report. */
758
- function buildDiagnosticGap(flags) {
759
- const diagnosticFlags = flags.filter((f) => f.severity === "error" || f.severity === "warn");
760
- if (diagnosticFlags.length === 0)
761
- return "";
762
- const lines = [
763
- "### Evidence Gap",
764
- "",
765
- "The enforcement gate detected mismatches between your claims and the evidence. " +
766
- "Each item below shows a specific discrepancy — address every one before re-submitting.",
767
- "",
768
- ];
769
- for (const flag of diagnosticFlags) {
770
- const icon = flag.severity === "error" ? "🚫" : "⚠️";
771
- lines.push(`- ${icon} **[${flag.check}]** \`${flag.field}\` — ${flag.detail}`);
772
- }
773
- lines.push("");
774
- return lines.join("\n");
775
- }
776
535
  const RULE_TABLE = [
777
536
  { checks: ["success_with_remaining_criteria"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
778
537
  { checks: ["recurring_violation"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
779
- { checks: ["required_command_failed", "command_evidence_mismatch", "outcome_success_contradiction"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
538
+ { checks: ["required_command_failed"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
539
+ // v4.0.6: contract coverage — a continuing round must leave an active
540
+ // contract; a different proposal needs an explicit supersede; a successor
541
+ // must change the acceptance items. Reject and let the uniform ladder
542
+ // escalate repeats (three same-check rejections terminate).
543
+ { checks: ["contract_required", "contract_premature", "contract_successor_unchanged"], ladder: "uniform", terminalAfter: 3, noticeOnRepeat: true },
780
544
  // v3.8.3: own ladder — reject → backtrack → terminate. A rewritten
781
545
  // entrypoint is not fixed by asking again (the round's baseline is frozen on
782
546
  // purpose), so the second occurrence rolls the work back instead of killing
783
547
  // the loop. See enforceVerificationEntrypointTampered.
784
548
  { checks: ["verification_entrypoint_modified"], ladder: "internal" },
785
- { checks: ["round_scope_drift"], ladder: "internal" },
549
+ // v3.9.9: round_scope_drift is REPORT-ONLY. It has no enforcement row, no
550
+ // waiver, no rejection counter, and no stall/backtrack interaction.
786
551
  // v3.8.1: "internal" — the handler owns this row's ladder (it must terminate
787
552
  // as `incomplete`, with its own reason). Declaring it uniform as well ran the
788
553
  // escalation notice twice and left the uniform terminate branch dead.
789
554
  { checks: ["contract_items_unverified"], ladder: "internal" },
790
555
  { checks: ["progress_stall"], ladder: "internal" },
791
556
  { checks: ["max_rejections"], ladder: "counter" },
792
- { checks: ["backtrack_workspace_not_restored"], ladder: "internal" },
793
557
  // v3.7.1: cited gates without an approved human decision
794
558
  { checks: ["user_gate_unresolved"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
795
559
  ];
@@ -842,16 +606,24 @@ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries,
842
606
  * check and lets uniform rows escalate on THEIR OWN streak instead of
843
607
  * inheriting an unrelated history. */
844
608
  lastRejectionCheck = "",
845
- /** v3.8.3: the coordinator has verified that this round declares itself
846
- * blocked AND that its new contract proposal's scope covers every file the
847
- * round changed outside the active contract's scope. Computed there, not
848
- * here: neither the observations nor the active contract reach this
849
- * function. See `enforceScopeDrift`. */
850
- scopeDriftWaiver = false,
851
- /** v3.9: the submitted round's own Git motion — see `inFlightGitMotion`.
852
- * null when no Git observation was made. */
853
- inFlightMotion = null) {
609
+ /** v3.9.7: the submitted round's provider-neutral progress. */
610
+ inFlightProgress = {
611
+ state: "unknown",
612
+ providers: [],
613
+ coverages: [],
614
+ }) {
854
615
  const { flags } = verifyResult;
616
+ // v3.9.9: `round_scope_drift` is a retired enforcement check. A session
617
+ // restored from an older runtime may still name it; it is not a current
618
+ // check, and its persisted count must not reach the max_rejections
619
+ // catch-all as unresolved persistence.
620
+ const retiredScopeCheck = lastRejectionCheck === "round_scope_drift";
621
+ const effectiveRejections = retiredScopeCheck ? 0 : consecutiveRejections;
622
+ const effectiveLastCheck = retiredScopeCheck ? "" : lastRejectionCheck;
623
+ // A valid blocked stop is the Agent's protocol action. Liveness/retry rows
624
+ // must not convert it into a same-round rejection; machine-integrity rows
625
+ // still apply below because their facts make the submission untrustworthy.
626
+ const blockedStop = declaresBlocked(selfEval) && selfEval.should_continue === false;
855
627
  // Run enforcement rules in priority order — the array order IS the
856
628
  // priority; the R-labels in the comments are historical (RULE_TABLE is
857
629
  // the semantic description). The first rule that fires wins.
@@ -861,9 +633,11 @@ inFlightMotion = null) {
861
633
  // v2.14: R-EVID — a required command failure, hidden test failures, or
862
634
  // a self-contradictory outcome claim contradicts the success claim
863
635
  () => enforceEvidenceContradiction(flags),
636
+ // v4.0.6: contract coverage (required / premature / successor unchanged)
637
+ () => enforceContractCoverage(flags),
864
638
  // v3.3: R-EVID-VERIFY — the verification command entrypoint changed in
865
639
  // the same round it ran.
866
- () => enforceVerificationEntrypointTampered(flags, vaultEntries, currentRound, consecutiveRejections, lastRejectionCheck),
640
+ () => enforceVerificationEntrypointTampered(flags, vaultEntries, currentRound, effectiveRejections, effectiveLastCheck),
867
641
  // v3.5: contract completion claimed without its verification_plan
868
642
  // commands passing — runs before R-C1: the completion-truth question
869
643
  // outranks boundary nuance.
@@ -873,23 +647,26 @@ inFlightMotion = null) {
873
647
  // success_without_verified_evidence, which is now a warn: report coverage
874
648
  // no longer decides acceptance, and the machine-evidence warn still keeps
875
649
  // an unbacked success out of the trajectory.
876
- // v3.3: R-C2 — files changed outside the contract's declared scope
877
- () => enforceScopeDrift(flags, consecutiveRejections, lastRejectionCheck, scopeDriftWaiver),
650
+ // v3.9.9: scope drift is an INFO fact only; it never appears here.
878
651
  // v3.8: verification debt — contract items claimed met without machine
879
652
  // verification (fires only after the configured streak)
880
- () => enforceContractItemsUnverified(flags, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck),
881
- // v3.7: single progress evaluator (former R4/R5 slots merged) —
653
+ () => blockedStop
654
+ ? null
655
+ : enforceContractItemsUnverified(flags, currentRound, vaultEntries, effectiveRejections, effectiveLastCheck),
656
+ // v3.9.7: one provider-neutral progress evaluator —
882
657
  // reject → backtrack → terminate with the deadlock guard
883
- () => enforceProgressStall(flags, currentRound, vaultEntries, consecutiveRejections, verifyResult.machineBackedSuccess, lastRejectionCheck, inFlightMotion),
658
+ () => blockedStop
659
+ ? null
660
+ : enforceProgressStall(flags, currentRound, vaultEntries, effectiveRejections, verifyResult.machineBackedSuccess, effectiveLastCheck, inFlightProgress),
884
661
  // R6: rejection-counter catch-all (evaluated only when no row above
885
662
  // fired — a clean-looking round carrying a high persisted counter)
886
663
  // v3.7.1: cited gate without an approved human decision
887
664
  () => enforceUserGateUnresolved(flags),
888
- // v3.8.3: the counter is shared, so this row must know that a scope-drift
889
- // waiver already excused the streak it carries (see enforceMaxRejections).
890
- () => enforceMaxRejections(consecutiveRejections, scopeDriftWaiver && flags.some((f) => f.check === CHECK_ROUND_SCOPE_DRIFT)),
891
- // v2.12: R9 — post-backtrack workspace not restored
892
- () => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
665
+ // v3.9.9: round_scope_drift is not a rejection source, so this catch-all
666
+ // has no drift-waiver special case anymore.
667
+ () => blockedStop
668
+ ? null
669
+ : enforceMaxRejections(effectiveRejections),
893
670
  ];
894
671
  // v3.7: uniform-ladder escalation. Rows without their own escalation
895
672
  // ladder (R1/R2/R-EVID/R-EVID-VERIFY) escalate on the session's
@@ -915,8 +692,8 @@ inFlightMotion = null) {
915
692
  // occurrence into termination. The user_gate row is the sharp case:
916
693
  // its recovery is human approval, so a gate cited after unrelated
917
694
  // rejections must reject with instructions, not kill the loop.
918
- const ownStreak = lastRejectionCheck === result.check
919
- ? consecutiveRejections
695
+ const ownStreak = effectiveLastCheck === result.check
696
+ ? effectiveRejections
920
697
  : 0;
921
698
  if (ownStreak >= (row.terminalAfter ?? 2)) {
922
699
  return makeEnforcementResult({
@@ -944,54 +721,41 @@ inFlightMotion = null) {
944
721
  fix_instructions: "",
945
722
  });
946
723
  }
947
- /** Build a rejection prompt for the agent.
948
- *
949
- * The prompt clearly states the round was rejected, why, what the agent
950
- * must fix, and that the agent must redo the SAME round (not advance).
724
+ /** Build the protected retry notice for the agent.
951
725
  *
952
- * v2.7: Accepts verificationFlags to render a diagnostic "Evidence Gap"
953
- * section. Each error/warn flag becomes a concrete claim-vs-evidence
954
- * mismatch statement so the agent knows exactly what to correct rather
955
- * than retrying blindly.
726
+ * v3.9.7: this is a protocol fact, not a second task narrative. It carries
727
+ * the enforcement reason, the required fix and the same-round instruction.
728
+ * Findings are rendered exactly once by the mandatory Verification Gate; the
729
+ * original task/contract is rendered by Objective/Current Task. The notice
730
+ * never promises that an explanation will re-evaluate a machine fact.
956
731
  *
957
732
  * @param currentRound The round number that was rejected (NOT incremented).
958
- * @param task The original loop task description.
733
+ * @param _task Kept for the public signature; not rendered here.
959
734
  * @param enforceResult The enforcement decision with reason and fix instructions.
960
- * @param verificationFlags The verification gate's findings for this round.
961
- * Used to build the Evidence Gap section. */
962
- export function buildRejectionPrompt(currentRound, task, enforceResult, verificationFlags = []) {
735
+ * @param _verificationFlags Kept for the public signature; findings render in
736
+ * the mandatory Verification Gate. */
737
+ export function buildRejectionPrompt(currentRound, _task, enforceResult, _verificationFlags = []) {
963
738
  const lines = [
964
739
  "## ⛔ Round " + currentRound + " — REJECTED",
965
740
  "",
966
- "Your self-evaluation for Round " + currentRound +
967
- " was **rejected** by the enforcement gate.",
741
+ "The enforcement gate rejected this round. This is a machine/protocol",
742
+ "decision, not a judgment about your reasoning strategy.",
968
743
  "",
969
744
  "### Reason",
970
745
  "",
971
746
  enforceResult.reason,
972
747
  "",
973
748
  ];
974
- // v2.7: Diagnostic gap — show the agent exactly what doesn't match.
975
- const gap = buildDiagnosticGap(verificationFlags);
976
- if (gap) {
977
- lines.push(gap);
978
- }
979
749
  if (enforceResult.fix_instructions) {
980
750
  lines.push("### Required Fix");
981
751
  lines.push("");
982
752
  lines.push(enforceResult.fix_instructions);
983
753
  lines.push("");
984
754
  }
985
- lines.push("### Your Task (Round " + currentRound + " — Retry)");
986
- lines.push("");
987
- lines.push(task);
988
- lines.push("");
989
755
  lines.push("### Instructions");
990
756
  lines.push("");
991
757
  lines.push("1. Read and address each issue in **Required Fix** above.", "2. Re-execute **Round " + currentRound +
992
- "** — do NOT advance to the next round.", "3. Submit a corrected self-evaluation via `loopforge_next`.", "4. Be honest in your self-evaluation — " +
993
- "claiming success when criteria are unmet will be rejected again.", "5. If you believe this rejection is incorrect, " +
994
- "explain why in your output_summary and the enforcement gate will re-evaluate.");
758
+ "** — do NOT advance to the next round.", "3. Submit a corrected evaluation via `loopforge_next` using the latest", " `roundId` returned by LoopForge.", "4. If you believe a finding is factually wrong, identify the specific", " machine fact you dispute and submit a corrected evaluation. The gate", " re-evaluates facts, not explanations.");
995
759
  return lines.join("\n");
996
760
  }
997
761
  //# sourceMappingURL=enforcement-gate.js.map