loopforge 3.0.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 (178) hide show
  1. package/README.md +128 -145
  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 +28 -20
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +82 -173
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +25 -16
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +25 -33
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +38 -138
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/enforcement-gate.d.ts +76 -0
  18. package/dist/enforcement-gate.d.ts.map +1 -0
  19. package/dist/enforcement-gate.js +1071 -0
  20. package/dist/enforcement-gate.js.map +1 -0
  21. package/dist/engine.d.ts +99 -0
  22. package/dist/engine.d.ts.map +1 -0
  23. package/dist/engine.js +839 -0
  24. package/dist/engine.js.map +1 -0
  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/evidence-provider.d.ts +21 -23
  30. package/dist/evidence-provider.d.ts.map +1 -1
  31. package/dist/evidence-provider.js +74 -34
  32. package/dist/evidence-provider.js.map +1 -1
  33. package/dist/generate-schema.js +1 -84
  34. package/dist/generate-schema.js.map +1 -1
  35. package/dist/index.d.ts +19 -22
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +16 -15
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-compiler.d.ts +41 -3
  40. package/dist/loop-compiler.d.ts.map +1 -1
  41. package/dist/loop-compiler.js +632 -273
  42. package/dist/loop-compiler.js.map +1 -1
  43. package/dist/loop-extras-parser.d.ts +2 -2
  44. package/dist/loop-extras-parser.d.ts.map +1 -1
  45. package/dist/loop-extras-parser.js +2 -2
  46. package/dist/loop-extras-parser.js.map +1 -1
  47. package/dist/loop-projection.d.ts +26 -0
  48. package/dist/loop-projection.d.ts.map +1 -0
  49. package/dist/loop-projection.js +142 -0
  50. package/dist/loop-projection.js.map +1 -0
  51. package/dist/loop-store.d.ts +112 -24
  52. package/dist/loop-store.d.ts.map +1 -1
  53. package/dist/loop-store.js +365 -218
  54. package/dist/loop-store.js.map +1 -1
  55. package/dist/mcp/round-lifecycle.d.ts +216 -0
  56. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  57. package/dist/mcp/round-lifecycle.js +926 -0
  58. package/dist/mcp/round-lifecycle.js.map +1 -0
  59. package/dist/mcp/server.d.ts +2 -1
  60. package/dist/mcp/server.d.ts.map +1 -1
  61. package/dist/mcp/server.js +28 -42
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/mcp/session.d.ts +53 -101
  64. package/dist/mcp/session.d.ts.map +1 -1
  65. package/dist/mcp/session.js +306 -1105
  66. package/dist/mcp/session.js.map +1 -1
  67. package/dist/mcp/tools.d.ts +13 -16
  68. package/dist/mcp/tools.d.ts.map +1 -1
  69. package/dist/mcp/tools.js +479 -413
  70. package/dist/mcp/tools.js.map +1 -1
  71. package/dist/policy-metrics.d.ts +12 -17
  72. package/dist/policy-metrics.d.ts.map +1 -1
  73. package/dist/policy-metrics.js +95 -161
  74. package/dist/policy-metrics.js.map +1 -1
  75. package/dist/policy.d.ts +44 -35
  76. package/dist/policy.d.ts.map +1 -1
  77. package/dist/policy.js +31 -115
  78. package/dist/policy.js.map +1 -1
  79. package/dist/prompt-assembler.d.ts +37 -6
  80. package/dist/prompt-assembler.d.ts.map +1 -1
  81. package/dist/prompt-assembler.js +389 -160
  82. package/dist/prompt-assembler.js.map +1 -1
  83. package/dist/prompt-policy.d.ts +1 -8
  84. package/dist/prompt-policy.d.ts.map +1 -1
  85. package/dist/prompt-policy.js +8 -26
  86. package/dist/prompt-policy.js.map +1 -1
  87. package/dist/protocol.d.ts +357 -229
  88. package/dist/protocol.d.ts.map +1 -1
  89. package/dist/protocol.js +139 -15
  90. package/dist/protocol.js.map +1 -1
  91. package/dist/replay.d.ts +5 -13
  92. package/dist/replay.d.ts.map +1 -1
  93. package/dist/replay.js +19 -55
  94. package/dist/replay.js.map +1 -1
  95. package/dist/round-coordinator.d.ts +20 -24
  96. package/dist/round-coordinator.d.ts.map +1 -1
  97. package/dist/round-coordinator.js +99 -92
  98. package/dist/round-coordinator.js.map +1 -1
  99. package/dist/round-driver.d.ts +14 -13
  100. package/dist/round-driver.d.ts.map +1 -1
  101. package/dist/round-driver.js +26 -14
  102. package/dist/round-driver.js.map +1 -1
  103. package/dist/round-transaction.d.ts +15 -18
  104. package/dist/round-transaction.d.ts.map +1 -1
  105. package/dist/round-transaction.js +38 -47
  106. package/dist/round-transaction.js.map +1 -1
  107. package/dist/self-eval.d.ts +53 -0
  108. package/dist/self-eval.d.ts.map +1 -0
  109. package/dist/self-eval.js +284 -0
  110. package/dist/self-eval.js.map +1 -0
  111. package/dist/storage.d.ts +9 -4
  112. package/dist/storage.d.ts.map +1 -1
  113. package/dist/storage.js +18 -6
  114. package/dist/storage.js.map +1 -1
  115. package/dist/token-utils.d.ts +15 -3
  116. package/dist/token-utils.d.ts.map +1 -1
  117. package/dist/token-utils.js +24 -5
  118. package/dist/token-utils.js.map +1 -1
  119. package/dist/verification-gate.d.ts +108 -0
  120. package/dist/verification-gate.d.ts.map +1 -0
  121. package/dist/verification-gate.js +971 -0
  122. package/dist/verification-gate.js.map +1 -0
  123. package/loop_policy.json +44 -15
  124. package/package.json +5 -10
  125. package/skills/perception/SKILL.md +182 -199
  126. package/dist/canonical-event-writer.d.ts +0 -20
  127. package/dist/canonical-event-writer.d.ts.map +0 -1
  128. package/dist/canonical-event-writer.js +0 -131
  129. package/dist/canonical-event-writer.js.map +0 -1
  130. package/dist/compiler-subgoals.d.ts +0 -7
  131. package/dist/compiler-subgoals.d.ts.map +0 -1
  132. package/dist/compiler-subgoals.js +0 -93
  133. package/dist/compiler-subgoals.js.map +0 -1
  134. package/dist/derive-id.d.ts +0 -12
  135. package/dist/derive-id.d.ts.map +0 -1
  136. package/dist/derive-id.js +0 -31
  137. package/dist/derive-id.js.map +0 -1
  138. package/dist/mcp/session-projection.d.ts +0 -5
  139. package/dist/mcp/session-projection.d.ts.map +0 -1
  140. package/dist/mcp/session-projection.js +0 -18
  141. package/dist/mcp/session-projection.js.map +0 -1
  142. package/dist/mcp/session-queue.d.ts +0 -3
  143. package/dist/mcp/session-queue.d.ts.map +0 -1
  144. package/dist/mcp/session-queue.js +0 -18
  145. package/dist/mcp/session-queue.js.map +0 -1
  146. package/dist/mcp/session-types.d.ts +0 -72
  147. package/dist/mcp/session-types.d.ts.map +0 -1
  148. package/dist/mcp/session-types.js +0 -2
  149. package/dist/mcp/session-types.js.map +0 -1
  150. package/dist/round-commit-store.d.ts +0 -12
  151. package/dist/round-commit-store.d.ts.map +0 -1
  152. package/dist/round-commit-store.js +0 -13
  153. package/dist/round-commit-store.js.map +0 -1
  154. package/dist/round-gate.d.ts +0 -31
  155. package/dist/round-gate.d.ts.map +0 -1
  156. package/dist/round-gate.js +0 -259
  157. package/dist/round-gate.js.map +0 -1
  158. package/dist/round-recovery.d.ts +0 -17
  159. package/dist/round-recovery.d.ts.map +0 -1
  160. package/dist/round-recovery.js +0 -117
  161. package/dist/round-recovery.js.map +0 -1
  162. package/dist/round-report.d.ts +0 -16
  163. package/dist/round-report.d.ts.map +0 -1
  164. package/dist/round-report.js +0 -401
  165. package/dist/round-report.js.map +0 -1
  166. package/dist/runtime-services.d.ts +0 -59
  167. package/dist/runtime-services.d.ts.map +0 -1
  168. package/dist/runtime-services.js +0 -460
  169. package/dist/runtime-services.js.map +0 -1
  170. package/dist/vault-format.d.ts +0 -4
  171. package/dist/vault-format.d.ts.map +0 -1
  172. package/dist/vault-format.js +0 -4
  173. package/dist/vault-format.js.map +0 -1
  174. package/dist/vault-types.d.ts +0 -24
  175. package/dist/vault-types.d.ts.map +0 -1
  176. package/dist/vault-types.js +0 -2
  177. package/dist/vault-types.js.map +0 -1
  178. package/loopforge-policy.json +0 -340
@@ -0,0 +1,1071 @@
1
+ /** Enforcement Gate — Layer 2 round-boundary runtime enforcement (v1.13).
2
+ *
3
+ * Pure-function module. Receives the verification gate's findings plus
4
+ * round-level context and decides whether to accept the round, reject it
5
+ * (force the agent to redo), or terminate the loop.
6
+ *
7
+ * This is the "runtime" that prompt-only constraint systems lack.
8
+ * Verification gate detects WHAT is wrong; enforcement gate decides
9
+ * what to DO about it.
10
+ *
11
+ * Decision semantics:
12
+ * - accept: round passes all checks; advance to next round as normal.
13
+ * - reject: agent's self-evaluation or round output is invalid; the
14
+ * agent receives a rejection prompt and must redo the SAME
15
+ * round. Round counter does NOT increment.
16
+ * - terminate: loop has reached an unrecoverable state; stop immediately
17
+ * with stopReason "enforcement_terminated".
18
+ */
19
+ import { makeEnforcementResult } from "./protocol.js";
20
+ import { entryRound, machineProgressSeries, CHECK_SUCCESS_WITH_REMAINING_CRITERIA, CHECK_RECURRING_VIOLATION, CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE, CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED, CHECK_REQUIRED_COMMAND_FAILED, CHECK_COMMAND_EVIDENCE_MISMATCH, CHECK_OUTCOME_SUCCESS_CONTRADICTION } from "./verification-gate.js";
21
+ import { effectiveSuccess } from "./self-eval.js";
22
+ import { deriveConstraintId, deriveCriterionId, deriveSubGoalId } from "./loop-compiler.js";
23
+ import { getPolicy } from "./policy.js";
24
+ import { STABLE_ID_RE, isRecord } from "./token-utils.js";
25
+ import { isProcessResult } from "./round-transaction.js";
26
+ // ═══════════════════════════════════════════════════════════════════════════
27
+ // Helpers
28
+ // ═══════════════════════════════════════════════════════════════════════════
29
+ /** Extract progress_estimate from a vault entry's execution_evidence.
30
+ * Returns null if no execution evidence is available. */
31
+ function entryProgress(entry) {
32
+ const evidence = entry.execution_evidence;
33
+ if (!evidence)
34
+ return null;
35
+ const pe = evidence.progress_estimate;
36
+ return typeof pe === "number" ? pe : null;
37
+ }
38
+ /** Collect progress estimates from vault entries into a round→estimate map.
39
+ * Shared by R4 (progress_stall) and R5 (progress_stall_terminal). */
40
+ function collectProgressByRound(vaultEntries, currentRound) {
41
+ const progressByRound = new Map();
42
+ for (const entry of vaultEntries) {
43
+ const rnd = entryRound(entry);
44
+ if (rnd < 1 || rnd >= currentRound)
45
+ continue;
46
+ const pe = entryProgress(entry);
47
+ if (pe !== null)
48
+ progressByRound.set(rnd, pe);
49
+ }
50
+ return progressByRound;
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
+ }
68
+ // ═══════════════════════════════════════════════════════════════════════════
69
+ // v2.10: Backtrack helpers
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
+ }
94
+ /** Check whether a vault entry represents a "clean" round that can serve
95
+ * as a safe restore point. A clean round was accepted (not rejected)
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. */
104
+ function isCleanRound(entry) {
105
+ const committed = committedRoundResult(entry);
106
+ // A rolled-back round (already backtracked) is not a valid restore point.
107
+ if (committed?.action === "backtrack")
108
+ return false;
109
+ // Rejected/terminated rounds are not clean. (They never commit in the
110
+ // real vault — this guards legacy or mixed-shape data.)
111
+ if (committed && (committed.action === "reject" || committed.action === "terminate")) {
112
+ return false;
113
+ }
114
+ // Rounds with error-level verification flags are not clean
115
+ const flags = Array.isArray(entry.verification_flags)
116
+ ? entry.verification_flags
117
+ : committed?.verificationFlags ?? [];
118
+ for (const f of flags) {
119
+ if (f !== null && typeof f === "object" &&
120
+ f.severity === "error")
121
+ return false;
122
+ }
123
+ return true;
124
+ }
125
+ /** Scan backwards from currentRound to find the most recent clean round.
126
+ * Collects discovered_constraints from skipped rounds along the way.
127
+ * Returns null when no clean round is found within maxDepth. */
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.
136
+ const completed = vaultEntries
137
+ .filter((e) => {
138
+ const rnd = entryRound(e);
139
+ return rnd >= 1 && rnd < currentRound && e.task_type !== "loop_lineage";
140
+ })
141
+ .sort((a, b) => entryRound(b) - entryRound(a)); // newest first
142
+ const skippedDiscoveries = [];
143
+ for (let depth = 1; depth <= maxDepth; depth++) {
144
+ const targetRound = currentRound - depth;
145
+ if (targetRound < 1)
146
+ break;
147
+ const entry = completed.find((e) => entryRound(e) === targetRound);
148
+ if (!entry)
149
+ continue;
150
+ // Collect discoveries from rounds we're about to skip (depth > 1)
151
+ if (depth > 1) {
152
+ for (let d = 1; d < depth; d++) {
153
+ const skippedRound = currentRound - d;
154
+ const skippedEntry = completed.find((e) => entryRound(e) === skippedRound);
155
+ if (skippedEntry) {
156
+ const discovered = Array.isArray(skippedEntry.discovered_constraints)
157
+ ? skippedEntry.discovered_constraints.filter((v) => typeof v === "string")
158
+ : [];
159
+ for (const item of discovered) {
160
+ if (!skippedDiscoveries.includes(item))
161
+ skippedDiscoveries.push(item);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ if (isCleanRound(entry)) {
167
+ return { round: targetRound, skippedDiscoveries };
168
+ }
169
+ }
170
+ return null;
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
+ }
203
+ /** Build the backtrack prompt injected at the top of the restored round.
204
+ *
205
+ * v2.13: Includes concrete workspace restore instructions with affected
206
+ * file lists from skipped rounds. The agent must restore the working tree
207
+ * to the clean round's state before proceeding. */
208
+ export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
209
+ /** v2.13: Files changed in the skipped rounds (from evidence snapshots).
210
+ * Used to show the agent exactly what needs to be reverted. */
211
+ skippedFiles = [],
212
+ /** v2.12: Git HEAD commit hash at the backtrack point (current HEAD).
213
+ * The agent must discard work back to the clean round's state. */
214
+ gitHead) {
215
+ const lines = [
216
+ `## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
217
+ "",
218
+ ];
219
+ // ── Why This Happened ────────────────────────────────────────────────
220
+ if (triggerRule === "progress_stall") {
221
+ lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}. ` +
222
+ `The work done in those rounds produced no verifiable progress — ` +
223
+ `no new files changed, no criteria met.`, "", "The approach used in those rounds **did not work**. It should not be repeated.", "");
224
+ }
225
+ else if (triggerRule === "progress_stall_terminal") {
226
+ lines.push("### Why This Happened", "", `Progress was completely flat for multiple rounds leading up to Round ${fromRound}. ` +
227
+ `The agent made **zero forward motion** — the task is not advancing.`, "", "A **radically different** strategy is needed. The previous approach produced nothing.", "");
228
+ }
229
+ // ── v2.13: Workspace Restore ──────────────────────────────────────────
230
+ lines.push("### ⚠️ Workspace Restore Required", "", `Your working directory still contains changes from the **failed** ` +
231
+ `rounds (${toRound + 1}–${fromRound}). You MUST discard those changes ` +
232
+ `before starting Round ${toRound + 1}. Working on top of stale changes ` +
233
+ `will cause the verification gate to reject your next submission.`, "");
234
+ if (skippedFiles.length > 0) {
235
+ lines.push("**Files modified in skipped rounds (must be reverted):**", "");
236
+ for (const f of skippedFiles.slice(0, 15)) {
237
+ lines.push(`- \`${f}\``);
238
+ }
239
+ if (skippedFiles.length > 15) {
240
+ lines.push(`- … and ${skippedFiles.length - 15} more files`);
241
+ }
242
+ lines.push("");
243
+ }
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 ` +
252
+ `to their state at Round ${toRound}. Re-read the state file below — ` +
253
+ `it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
254
+ "> your workspace no longer contains the failed changes. Submissions " +
255
+ "> with unrestored files will be **rejected**.", "");
256
+ // ── What Must Change ──────────────────────────────────────────────────
257
+ lines.push("### What Must Change", "", "- Do **NOT** repeat the approach used in the skipped rounds.", "- Try a **different** task decomposition or technique.", "- If the current sub-goal is stuck, consider canceling it " +
258
+ "(`canceled_subtasks`) and working on a different one.", "- Use the `prompt_requests.confusion_points` field in your self-evaluation " +
259
+ "to flag anything you don't understand about the task or the blockage.", "");
260
+ // ── Preserved Discoveries ─────────────────────────────────────────────
261
+ if (skippedDiscoveries.length > 0) {
262
+ lines.push("### Preserved Discoveries", "", "These were discovered in the skipped rounds and remain valid:", "");
263
+ for (const d of skippedDiscoveries) {
264
+ lines.push(`- ${d}`);
265
+ }
266
+ lines.push("");
267
+ }
268
+ // ── Instructions ──────────────────────────────────────────────────────
269
+ lines.push("### Instructions", "", `You have been rolled back to Round ${toRound}. ` +
270
+ `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 " +
271
+ "into the active constraints.", "4. Do NOT retrace the steps that led to the stall. Choose a new path.", "");
272
+ return lines.join("\n");
273
+ }
274
+ // ═══════════════════════════════════════════════════════════════════════════
275
+ // Individual enforcement rules — each returns EnforcementResult | null
276
+ // Rules are ordered by priority. The first non-null result wins.
277
+ // ═══════════════════════════════════════════════════════════════════════════
278
+ /** R1: Agent claims success but success_criteria_remaining has items.
279
+ * This is a lie — the agent must either finish the criteria or
280
+ * set success=false honestly. Triggered by the verification gate's
281
+ * "success_with_remaining_criteria" error flag. */
282
+ function enforceSuccessWithRemainingCriteria(flags) {
283
+ const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITH_REMAINING_CRITERIA && f.severity === "error");
284
+ if (!flag)
285
+ return null;
286
+ return makeEnforcementResult({
287
+ action: "reject",
288
+ reason: flag.detail,
289
+ fix_instructions: "You set success=true but success criteria remain unmet. " +
290
+ "Either: (a) complete the remaining criteria and re-submit your self-evaluation, " +
291
+ "or (b) set success=false and honestly report what remains to be done.",
292
+ check: "success_with_remaining_criteria",
293
+ });
294
+ }
295
+ /** R2: Same constraint violation appears in 3 consecutive rounds.
296
+ * The agent is repeating the same mistake. Triggered by the verification
297
+ * gate's "recurring_violation" error flag. */
298
+ function enforceRecurringViolation(flags) {
299
+ const flag = flags.find((f) => f.check === CHECK_RECURRING_VIOLATION && f.severity === "error");
300
+ if (!flag)
301
+ return null;
302
+ return makeEnforcementResult({
303
+ action: "reject",
304
+ reason: flag.detail,
305
+ fix_instructions: "The same constraint violation has appeared in 3 consecutive rounds. " +
306
+ "You must: (a) explain WHY this violation keeps occurring, " +
307
+ "and (b) propose a DIFFERENT approach than what you used in the last 3 rounds. " +
308
+ "Do NOT retry the same strategy — it has failed 3 times.",
309
+ check: "recurring_violation",
310
+ });
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
+ }
336
+ /** R3: Agent claims success but did nothing verifiable.
337
+ * v1.17: execution_evidence is now MANDATORY for structured self-evaluations.
338
+ * Missing evidence when success=true → reject (agent must provide evidence).
339
+ * Empty evidence (no files + no tests) when success=true → reject. */
340
+ function enforceEmptySuccess(selfEval, _flags, skipEvidenceRules = false) {
341
+ if (skipEvidenceRules)
342
+ return null; // Requires execution_evidence
343
+ if (!effectiveSuccess(selfEval))
344
+ return null;
345
+ const ev = selfEval.execution_evidence;
346
+ // v1.17: Missing evidence when claiming success → reject.
347
+ // The agent MUST provide execution_evidence to back up a success claim.
348
+ if (!ev) {
349
+ return makeEnforcementResult({
350
+ action: "reject",
351
+ reason: "Agent claims success but provided no execution_evidence. " +
352
+ "Every successful round MUST include execution_evidence with " +
353
+ "files_changed, test_results, and progress_estimate.",
354
+ fix_instructions: "You must provide execution_evidence in your self-evaluation: " +
355
+ "(a) list the files you changed in execution_evidence.files_changed, " +
356
+ "(b) run tests and report results in execution_evidence.test_results, " +
357
+ "(c) estimate your progress in execution_evidence.progress_estimate. " +
358
+ "If you genuinely completed the task without file changes or tests, " +
359
+ "explain why in detail in your output_summary.",
360
+ check: "empty_success",
361
+ });
362
+ }
363
+ const filesEmpty = ev.files_changed.length === 0;
364
+ const testsNotRun = ev.test_results === null;
365
+ if (!filesEmpty || !testsNotRun)
366
+ return null;
367
+ return makeEnforcementResult({
368
+ action: "reject",
369
+ reason: "Agent claims success but execution_evidence shows no files changed " +
370
+ "and no tests were run. There is no verifiable evidence of work.",
371
+ fix_instructions: "You must provide verifiable evidence: " +
372
+ "(a) list the files you changed in execution_evidence.files_changed, " +
373
+ "and (b) run tests and report results in execution_evidence.test_results. " +
374
+ "If you genuinely completed the task without file changes or tests, " +
375
+ "explain why in detail in your output_summary.",
376
+ check: "empty_success",
377
+ });
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
+ }
442
+ /** R4: Progress has stalled for 3+ consecutive rounds.
443
+ * Detected by checking progress_estimate deltas across vault entries.
444
+ * First occurrence → REJECT (agent must change approach).
445
+ * Second consecutive occurrence → TERMINATE (agent cannot recover). */
446
+ function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
447
+ // Need at least 3 rounds of history
448
+ if (currentRound < 3)
449
+ return null;
450
+ let isStalling = false;
451
+ let stallDetail = "";
452
+ let stallThreshold = 0;
453
+ if (skipEvidenceRules) {
454
+ // v3.2: heuristic path has no execution_evidence — fall back to machine
455
+ // observations: three consecutive rounds with no git change = stall.
456
+ // No git signal → keep the legacy skip (the machine cannot observe).
457
+ const machine = machineProgressSeries(vaultEntries, currentRound, 3);
458
+ if (machine === null)
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
+ }
485
+ if (!isStalling)
486
+ return null;
487
+ const escalation = getPolicy().engine.enforcement_escalation_enabled;
488
+ // v2.7: When escalation is enabled, give one extra rejection round with a
489
+ // "Seek Human Guidance" notice before terminating. This lets the agent
490
+ // course-correct with human input rather than being killed immediately.
491
+ if (consecutiveRejections >= 2) {
492
+ return makeEnforcementResult({
493
+ action: "terminate",
494
+ reason: `Progress stalled for 3+ rounds ` +
495
+ `(${stallDetail}) ` +
496
+ `and agent did not resolve after escalation. Terminating loop.`,
497
+ check: "progress_stall",
498
+ });
499
+ }
500
+ if (consecutiveRejections >= 1 && escalation) {
501
+ // v2.10: When backtrack is enabled, escalate to backtrack instead of
502
+ // reject. The agent is rolled back to the last clean round with lessons
503
+ // injected, rather than redoing the same round with an escalation notice.
504
+ if (getPolicy().engine.backtrack_enabled) {
505
+ // v2.14: A backtrack that already committed for this round (the loop
506
+ // is re-walking rolled-back territory with the same stall) must not
507
+ // loop forever — terminate instead. Without this, consecutive
508
+ // rejections reset on every backtrack and the R4 cycle has no exit.
509
+ if (hasCommittedBacktrack(vaultEntries, currentRound)) {
510
+ return makeEnforcementResult({
511
+ action: "terminate",
512
+ reason: `Progress has stalled over the last 3 rounds ` +
513
+ `(${stallDetail}) and a backtrack already fired for ` +
514
+ `this round. The stall persists after rollback — terminating.`,
515
+ check: "progress_stall",
516
+ });
517
+ }
518
+ return makeEnforcementResult({
519
+ action: "backtrack",
520
+ reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
521
+ `${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
522
+ fix_instructions: "Your progress has been flat for 3 rounds. You are being rolled back " +
523
+ "to the last clean round. Do NOT repeat the approach that led to the stall. " +
524
+ "Choose a different technique or task decomposition.",
525
+ check: "progress_stall",
526
+ });
527
+ }
528
+ return makeEnforcementResult({
529
+ action: "reject",
530
+ reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
531
+ `${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
532
+ fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
533
+ "(a) explain what is blocking progress, " +
534
+ "(b) propose a DIFFERENT technique or task decomposition, and " +
535
+ "(c) set a concrete, verifiable goal for the redo of this round. " +
536
+ "Do NOT repeat the same approach — it has not moved progress forward." +
537
+ buildEscalationNotice(),
538
+ check: "progress_stall",
539
+ });
540
+ }
541
+ if (consecutiveRejections >= 1) {
542
+ return makeEnforcementResult({
543
+ action: "terminate",
544
+ reason: `Progress stalled for 3+ rounds ` +
545
+ `(${stallDetail}) ` +
546
+ `and agent did not resolve after previous rejection. Terminating loop.`,
547
+ check: "progress_stall",
548
+ });
549
+ }
550
+ return makeEnforcementResult({
551
+ action: "reject",
552
+ reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
553
+ `${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
554
+ fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
555
+ "(a) explain what is blocking progress, " +
556
+ "(b) propose a DIFFERENT technique or task decomposition, and " +
557
+ "(c) set a concrete, verifiable goal for the redo of this round. " +
558
+ "Do NOT repeat the same approach — it has not moved progress forward.",
559
+ check: "progress_stall",
560
+ });
561
+ }
562
+ /** R5: Progress completely stalled (delta = 0) for N consecutive rounds.
563
+ * Stricter than R4 (which triggers on delta < 5%). R4 gives the agent a chance
564
+ * to change approach via reject; this rule fires when progress has been exactly
565
+ * flat — the agent is making zero forward motion.
566
+ *
567
+ * v2.7: When enforcement_escalation_enabled is true, the first occurrence
568
+ * issues a rejection with an escalation notice instead of terminating.
569
+ * Termination only happens on the second consecutive flatline detection.
570
+ *
571
+ * Uses the engine.max_circuit_breaker policy value as the lookback window. */
572
+ function enforceProgressStallTerminal(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
573
+ const breakerSize = getPolicy().engine.max_circuit_breaker;
574
+ if (currentRound < breakerSize || vaultEntries.length < breakerSize)
575
+ return null;
576
+ let allFlat = false;
577
+ let flatDetail = "";
578
+ if (skipEvidenceRules) {
579
+ // v3.2: heuristic path has no execution_evidence — fall back to machine
580
+ // observations: N consecutive rounds with no git change = flatline.
581
+ // No git signal → keep the legacy skip (the machine cannot observe).
582
+ const machine = machineProgressSeries(vaultEntries, currentRound, breakerSize);
583
+ if (machine === null)
584
+ return null;
585
+ allFlat = machine.every((value) => !value);
586
+ flatDetail = `no machine-observed changes for ${breakerSize} consecutive rounds`;
587
+ }
588
+ else {
589
+ const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
590
+ if (progressByRound.size < breakerSize)
591
+ return null;
592
+ const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
593
+ const recent = sortedRounds.slice(-breakerSize);
594
+ // v2.14: continuity guard (mirrors R4) — "consecutive" means the most
595
+ // recent rounds. Rounds without execution_evidence are unknown motion,
596
+ // not zero motion; taking "the last N rounds that happened to report
597
+ // progress" terminated loops whose recent rounds simply had no data.
598
+ if (recent[0] < currentRound - breakerSize)
599
+ return null;
600
+ // Check that progress values are ALL identical (delta = 0).
601
+ // Use epsilon comparison — JSON round-trips can produce float drift
602
+ // (e.g. 0.3 vs 0.30000000000000004) that would defeat strict === .
603
+ const EPSILON = 1e-10;
604
+ const first = progressByRound.get(recent[0]);
605
+ allFlat = recent.every((r) => Math.abs((progressByRound.get(r) ?? 0) - first) < EPSILON);
606
+ if (!allFlat || first >= 0.95)
607
+ return null;
608
+ flatDetail = `completely flat at ${(first * 100).toFixed(0)}%`;
609
+ }
610
+ if (!allFlat)
611
+ return null;
612
+ const escalation = getPolicy().engine.enforcement_escalation_enabled;
613
+ // v2.7: When escalation is enabled, give one rejection round with a
614
+ // "Seek Human Guidance" notice before terminating.
615
+ if (consecutiveRejections >= 1) {
616
+ return makeEnforcementResult({
617
+ action: "terminate",
618
+ reason: `Progress has been ${flatDetail} ` +
619
+ `for ${breakerSize} consecutive rounds and agent did not resolve ` +
620
+ `after escalation. The agent is making zero forward motion.`,
621
+ check: "progress_stall_terminal",
622
+ });
623
+ }
624
+ if (escalation) {
625
+ // v2.10: When backtrack is enabled, escalate to backtrack instead of
626
+ // reject. The agent is rolled back to the last clean round with a
627
+ // "radically different strategy" lesson injected.
628
+ if (getPolicy().engine.backtrack_enabled) {
629
+ // v2.14: Same deadlock guard as R4 — a backtrack that already fired
630
+ // for this round means the loop is re-walking rolled-back territory.
631
+ if (hasCommittedBacktrack(vaultEntries, currentRound)) {
632
+ return makeEnforcementResult({
633
+ action: "terminate",
634
+ reason: `Progress has been ${flatDetail} ` +
635
+ `for ${breakerSize} consecutive rounds and a backtrack already ` +
636
+ `fired for this round. Flatline persists after rollback — ` +
637
+ `terminating the loop.`,
638
+ check: "progress_stall_terminal",
639
+ });
640
+ }
641
+ return makeEnforcementResult({
642
+ action: "backtrack",
643
+ reason: `Progress has been ${flatDetail} ` +
644
+ `for ${breakerSize} consecutive rounds. The agent is making zero ` +
645
+ `forward motion.`,
646
+ fix_instructions: "Your progress has been exactly flat — zero forward motion. " +
647
+ "You are being rolled back to the last clean round. " +
648
+ "Choose a RADICALLY different approach. The previous one produced nothing.",
649
+ check: "progress_stall_terminal",
650
+ });
651
+ }
652
+ return makeEnforcementResult({
653
+ action: "reject",
654
+ reason: `Progress has been ${flatDetail} ` +
655
+ `for ${breakerSize} consecutive rounds. The agent is making zero ` +
656
+ `forward motion.`,
657
+ fix_instructions: "Your progress has been exactly flat — you are making zero forward " +
658
+ "motion. You must: (a) explain what is fundamentally blocking you, " +
659
+ "(b) propose a radically different approach, and " +
660
+ "(c) set a concrete verifiable goal. " +
661
+ "Do NOT resubmit the same output." +
662
+ buildEscalationNotice(),
663
+ check: "progress_stall_terminal",
664
+ });
665
+ }
666
+ return makeEnforcementResult({
667
+ action: "terminate",
668
+ reason: `Progress has been ${flatDetail} ` +
669
+ `for ${breakerSize} consecutive rounds. The agent is making zero ` +
670
+ `forward motion and cannot recover.`,
671
+ check: "progress_stall_terminal",
672
+ });
673
+ }
674
+ /** R6: Two consecutive rejections → escalate (v2.7) or terminate.
675
+ *
676
+ * v2.7: When enforcement_escalation_enabled is true, the second consecutive
677
+ * rejection issues an escalated rejection with a "Seek Human Guidance" notice
678
+ * instead of terminating immediately. Termination occurs on the third
679
+ * consecutive rejection. When the flag is disabled, behavior is unchanged
680
+ * (terminate at ≥2 rejections). */
681
+ function enforceMaxRejections(consecutiveRejections) {
682
+ const escalation = getPolicy().engine.enforcement_escalation_enabled;
683
+ if (escalation) {
684
+ if (consecutiveRejections >= 3) {
685
+ return makeEnforcementResult({
686
+ action: "terminate",
687
+ reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
688
+ `same issue without resolution. The agent has been unable to correct ` +
689
+ `the identified issue even after escalation. Terminating loop.`,
690
+ check: "max_rejections",
691
+ });
692
+ }
693
+ if (consecutiveRejections >= 2) {
694
+ return makeEnforcementResult({
695
+ action: "reject",
696
+ reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
697
+ `same issue without resolution.`,
698
+ fix_instructions: "You have been rejected multiple times for the same issue. " +
699
+ "You must address ALL items in the Evidence Gap section above. " +
700
+ "If you do not understand why you are being rejected, explain " +
701
+ "your confusion explicitly and ask for clarification." +
702
+ buildEscalationNotice(),
703
+ check: "max_rejections",
704
+ });
705
+ }
706
+ return null;
707
+ }
708
+ if (consecutiveRejections < 2)
709
+ return null;
710
+ return makeEnforcementResult({
711
+ action: "terminate",
712
+ reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
713
+ `same issue without resolution. The agent has been unable to correct the identified issue.`,
714
+ check: "max_rejections",
715
+ });
716
+ }
717
+ /** v2.12: Check if a drift_clarification contains at least one semantic
718
+ * anchor — a constraint/criterion/sub-goal ID (v2.11) or a file-path-like
719
+ * reference. This distinguishes genuine explanations from filler text that
720
+ * just happens to be ≥ 20 characters. */
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
+ }
789
+ return false;
790
+ }
791
+ /** Build a stronger rejection notice as the clarification streak grows. */
792
+ function buildClarificationRejectionNotice(streak) {
793
+ const base = "Your declared next_action from the previous round does not match " +
794
+ "what you actually did this round. You must either: " +
795
+ "(a) explain why you pivoted in the drift_clarification field, " +
796
+ "mentioning specific constraint/criterion/sub-goal IDs or file paths " +
797
+ "that prompted the change, or " +
798
+ "(b) redo this round and do what you said you would do.";
799
+ if (streak >= 2) {
800
+ return (base +
801
+ "\n\n### ⚠️ Repeated Weak Clarification\n\n" +
802
+ `You have submitted drift_clarification without concrete references ` +
803
+ `for ${streak} consecutive rounds. Your explanation MUST include at ` +
804
+ `least one of: a constraint ID (c-XXXXXXXX), criterion ID (cr-XXXXXXXX), ` +
805
+ `sub-goal ID (sg-XXXXXXXX), or a file path you changed.\n\n` +
806
+ `🚨 If the next clarification also lacks concrete anchors, the loop ` +
807
+ `will be terminated.`);
808
+ }
809
+ return base;
810
+ }
811
+ /** R7: Agent declared a next_action but did something unrelated.
812
+ *
813
+ * v2.8: When the agent provides a substantive drift_clarification (≥ 20
814
+ * characters), the rejection is waived — the agent has acknowledged and
815
+ * explained the pivot. An empty or absent clarification still triggers
816
+ * the normal reject/terminate path.
817
+ *
818
+ * v2.12: Clarification now requires a semantic anchor (constraint/criterion/
819
+ * sub-goal ID or file path) in addition to the ≥ 20 char minimum. Weak
820
+ * clarifications (no anchors) increment a streak counter; 3 consecutive
821
+ * weak clarifications terminate the loop.
822
+ *
823
+ * First occurrence → reject (agent must explain the gap or realign).
824
+ * Second consecutive → terminate (agent cannot/will not follow its own plan). */
825
+ function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0, vaultEntries = []) {
826
+ const flag = flags.find((f) => f.check === "intent_drift");
827
+ if (!flag)
828
+ return null;
829
+ const clarification = selfEval.drift_clarification?.trim();
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;
835
+ // v2.12: Substantive clarification = length check + semantic anchor
836
+ const isSubstantive = clarification
837
+ && clarification.length >= 20
838
+ && hasAnchor;
839
+ if (isSubstantive) {
840
+ // Genuine explanation with concrete references → accept the pivot
841
+ return makeEnforcementResult({
842
+ action: "accept",
843
+ reason: "",
844
+ fix_instructions: "",
845
+ check: "intent_drift",
846
+ clarification_accepted: true,
847
+ });
848
+ }
849
+ // v2.12: Clarification present but weak (no semantic anchors)
850
+ if (clarification && clarification.length >= 20 && !hasAnchor) {
851
+ const maxStreak = getPolicy().engine.drift_clarification_max_streak;
852
+ // When maxStreak is 0, disable the check (pre-v2.12 behavior)
853
+ if (maxStreak <= 0) {
854
+ return makeEnforcementResult({
855
+ action: "accept",
856
+ reason: "",
857
+ fix_instructions: "",
858
+ check: "intent_drift",
859
+ clarification_accepted: true,
860
+ });
861
+ }
862
+ const newStreak = driftClarificationStreak + 1;
863
+ if (newStreak >= maxStreak) {
864
+ return makeEnforcementResult({
865
+ action: "terminate",
866
+ reason: `Agent has submitted weak drift_clarification (no concrete IDs or ` +
867
+ `file references) for ${newStreak} consecutive rounds. The agent ` +
868
+ `systematically avoids following its own plan without substantive ` +
869
+ `explanation.`,
870
+ check: "intent_drift",
871
+ });
872
+ }
873
+ return makeEnforcementResult({
874
+ action: "reject",
875
+ reason: flag.detail,
876
+ fix_instructions: buildClarificationRejectionNotice(newStreak),
877
+ check: "intent_drift",
878
+ });
879
+ }
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) {
899
+ return makeEnforcementResult({
900
+ action: "terminate",
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.`,
904
+ check: "intent_drift",
905
+ });
906
+ }
907
+ return makeEnforcementResult({
908
+ action: "reject",
909
+ reason: flag.detail,
910
+ fix_instructions: "Your declared next_action from the previous round does not match " +
911
+ "what you actually did this round. You must either: " +
912
+ "(a) explain why you pivoted and what you learned in the " +
913
+ "drift_clarification field, or " +
914
+ "(b) redo this round and do what you said you would do. " +
915
+ "If you intentionally changed direction, update your objective_refinement " +
916
+ "and set a new next_action that reflects the new plan.",
917
+ check: "intent_drift",
918
+ });
919
+ }
920
+ // ═══════════════════════════════════════════════════════════════════════════
921
+ // Escalation notice builder (v2.7)
922
+ // ═══════════════════════════════════════════════════════════════════════════
923
+ /** Build an escalation notice urging the agent to seek human guidance.
924
+ *
925
+ * Appended to fix_instructions when a rule would otherwise terminate.
926
+ * Gives the agent one final chance to correct course with human input
927
+ * before the loop is irreversibly stopped. */
928
+ function buildEscalationNotice() {
929
+ return [
930
+ "",
931
+ "### 🆘 Escalation — Seek Human Guidance",
932
+ "",
933
+ "You have been rejected multiple times for the same issue. " +
934
+ "Before retrying, pause and ask a human for help:",
935
+ "",
936
+ "1. Explain what you've tried so far and why each approach failed.",
937
+ "2. Ask the human for specific guidance on what to try next.",
938
+ "3. Incorporate their feedback before re-submitting your self-evaluation.",
939
+ "",
940
+ "⚠️ If this round is rejected again, the loop will be **terminated**. " +
941
+ "Do NOT resubmit without meaningful changes.",
942
+ ].join("\n");
943
+ }
944
+ // ═══════════════════════════════════════════════════════════════════════════
945
+ // Diagnostic gap builder (v2.7)
946
+ // ═══════════════════════════════════════════════════════════════════════════
947
+ /** Build a diagnostic gap section from verification flags.
948
+ *
949
+ * Translates each error/warn-level VerificationFlag into a concrete
950
+ * claim-vs-evidence mismatch statement. Info-level flags are excluded
951
+ * because they are purely informational and do not represent gaps.
952
+ *
953
+ * Returns an empty string when there are no diagnostic flags to report. */
954
+ function buildDiagnosticGap(flags) {
955
+ const diagnosticFlags = flags.filter((f) => f.severity === "error" || f.severity === "warn");
956
+ if (diagnosticFlags.length === 0)
957
+ return "";
958
+ const lines = [
959
+ "### Evidence Gap",
960
+ "",
961
+ "The enforcement gate detected mismatches between your claims and the evidence. " +
962
+ "Each item below shows a specific discrepancy — address every one before re-submitting.",
963
+ "",
964
+ ];
965
+ for (const flag of diagnosticFlags) {
966
+ const icon = flag.severity === "error" ? "🚫" : "⚠️";
967
+ lines.push(`- ${icon} **[${flag.check}]** \`${flag.field}\` — ${flag.detail}`);
968
+ }
969
+ lines.push("");
970
+ return lines.join("\n");
971
+ }
972
+ // ═══════════════════════════════════════════════════════════════════════════
973
+ // Main entry point
974
+ // ═══════════════════════════════════════════════════════════════════════════
975
+ /** Enforce round-boundary rules based on the verification gate's findings
976
+ * and the agent's self-evaluation integrity.
977
+ *
978
+ * Rules run in priority order (R1 → R5). The first rule that fires wins.
979
+ *
980
+ * @param selfEval The agent's self-evaluation for the current round.
981
+ * @param verifyResult The verification gate's output (from verifySelfEvaluation).
982
+ * @param currentRound Current round number (1-based, BEFORE increment).
983
+ * @param vaultEntries Vault entries for this loop (used for progress tracking).
984
+ * @param consecutiveRejections How many consecutive rounds have already been rejected.
985
+ * Starts at 0; increments on each reject; resets on accept.
986
+ */
987
+ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0, skipEvidenceRules = false,
988
+ /** v2.12: Current clarification streak for R7 escalation. */
989
+ driftClarificationStreak = 0) {
990
+ const { flags } = verifyResult;
991
+ // Run enforcement rules in priority order.
992
+ // Earlier rules take higher precedence.
993
+ const rules = [
994
+ () => enforceSuccessWithRemainingCriteria(flags),
995
+ () => enforceRecurringViolation(flags),
996
+ () => enforceEmptySuccess(selfEval, flags, skipEvidenceRules),
997
+ // v2.14: R-EVID — a required command failure or self-contradictory
998
+ // outcome claim contradicts the success claim before commit
999
+ () => enforceEvidenceContradiction(flags),
1000
+ // v2.12: R8 — success with zero machine-verifiable evidence
1001
+ () => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
1002
+ () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
1003
+ () => enforceProgressStallTerminal(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
1004
+ () => enforceMaxRejections(consecutiveRejections),
1005
+ () => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
1006
+ // v2.12: R9 — post-backtrack workspace not restored
1007
+ () => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
1008
+ ];
1009
+ for (const rule of rules) {
1010
+ const result = rule();
1011
+ if (result)
1012
+ return result;
1013
+ }
1014
+ // All rules passed — accept the round
1015
+ return makeEnforcementResult({
1016
+ action: "accept",
1017
+ reason: "",
1018
+ fix_instructions: "",
1019
+ });
1020
+ }
1021
+ /** Build a rejection prompt for the agent.
1022
+ *
1023
+ * The prompt clearly states the round was rejected, why, what the agent
1024
+ * must fix, and that the agent must redo the SAME round (not advance).
1025
+ *
1026
+ * v2.7: Accepts verificationFlags to render a diagnostic "Evidence Gap"
1027
+ * section. Each error/warn flag becomes a concrete claim-vs-evidence
1028
+ * mismatch statement so the agent knows exactly what to correct rather
1029
+ * than retrying blindly.
1030
+ *
1031
+ * @param currentRound The round number that was rejected (NOT incremented).
1032
+ * @param task The original loop task description.
1033
+ * @param enforceResult The enforcement decision with reason and fix instructions.
1034
+ * @param verificationFlags The verification gate's findings for this round.
1035
+ * Used to build the Evidence Gap section. */
1036
+ export function buildRejectionPrompt(currentRound, task, enforceResult, verificationFlags = []) {
1037
+ const lines = [
1038
+ "## ⛔ Round " + currentRound + " — REJECTED",
1039
+ "",
1040
+ "Your self-evaluation for Round " + currentRound +
1041
+ " was **rejected** by the enforcement gate.",
1042
+ "",
1043
+ "### Reason",
1044
+ "",
1045
+ enforceResult.reason,
1046
+ "",
1047
+ ];
1048
+ // v2.7: Diagnostic gap — show the agent exactly what doesn't match.
1049
+ const gap = buildDiagnosticGap(verificationFlags);
1050
+ if (gap) {
1051
+ lines.push(gap);
1052
+ }
1053
+ if (enforceResult.fix_instructions) {
1054
+ lines.push("### Required Fix");
1055
+ lines.push("");
1056
+ lines.push(enforceResult.fix_instructions);
1057
+ lines.push("");
1058
+ }
1059
+ lines.push("### Your Task (Round " + currentRound + " — Retry)");
1060
+ lines.push("");
1061
+ lines.push(task);
1062
+ lines.push("");
1063
+ lines.push("### Instructions");
1064
+ lines.push("");
1065
+ lines.push("1. Read and address each issue in **Required Fix** above.", "2. Re-execute **Round " + currentRound +
1066
+ "** — do NOT advance to the next round.", "3. Submit a corrected self-evaluation via `loopforge_next`.", "4. Be honest in your self-evaluation — " +
1067
+ "claiming success when criteria are unmet will be rejected again.", "5. If you believe this rejection is incorrect, " +
1068
+ "explain why in your output_summary and the enforcement gate will re-evaluate.");
1069
+ return lines.join("\n");
1070
+ }
1071
+ //# sourceMappingURL=enforcement-gate.js.map