loopforge 2.6.0 → 2.11.0

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 (85) hide show
  1. package/README.md +103 -166
  2. package/dist/canonical-state.d.ts.map +1 -1
  3. package/dist/canonical-state.js +44 -6
  4. package/dist/canonical-state.js.map +1 -1
  5. package/dist/enforcement-gate.d.ts +34 -7
  6. package/dist/enforcement-gate.d.ts.map +1 -1
  7. package/dist/enforcement-gate.js +445 -51
  8. package/dist/enforcement-gate.js.map +1 -1
  9. package/dist/engine.d.ts.map +1 -1
  10. package/dist/engine.js +21 -3
  11. package/dist/engine.js.map +1 -1
  12. package/dist/evidence-provider.d.ts +3 -5
  13. package/dist/evidence-provider.d.ts.map +1 -1
  14. package/dist/evidence-provider.js +24 -10
  15. package/dist/evidence-provider.js.map +1 -1
  16. package/dist/index.d.ts +2 -11
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +2 -11
  19. package/dist/index.js.map +1 -1
  20. package/dist/loop-compiler.d.ts +2 -2
  21. package/dist/loop-compiler.d.ts.map +1 -1
  22. package/dist/loop-compiler.js +135 -42
  23. package/dist/loop-compiler.js.map +1 -1
  24. package/dist/loop-store.d.ts.map +1 -1
  25. package/dist/loop-store.js +3 -5
  26. package/dist/loop-store.js.map +1 -1
  27. package/dist/mcp/server.d.ts.map +1 -1
  28. package/dist/mcp/server.js +42 -8
  29. package/dist/mcp/server.js.map +1 -1
  30. package/dist/mcp/session.d.ts +27 -2
  31. package/dist/mcp/session.d.ts.map +1 -1
  32. package/dist/mcp/session.js +145 -1
  33. package/dist/mcp/session.js.map +1 -1
  34. package/dist/mcp/tools.d.ts.map +1 -1
  35. package/dist/mcp/tools.js +26 -4
  36. package/dist/mcp/tools.js.map +1 -1
  37. package/dist/policy-metrics.d.ts +8 -0
  38. package/dist/policy-metrics.d.ts.map +1 -1
  39. package/dist/policy-metrics.js +11 -0
  40. package/dist/policy-metrics.js.map +1 -1
  41. package/dist/policy.d.ts +52 -0
  42. package/dist/policy.d.ts.map +1 -1
  43. package/dist/policy.js +12 -2
  44. package/dist/policy.js.map +1 -1
  45. package/dist/prompt-assembler.d.ts +3 -1
  46. package/dist/prompt-assembler.d.ts.map +1 -1
  47. package/dist/prompt-assembler.js +427 -29
  48. package/dist/prompt-assembler.js.map +1 -1
  49. package/dist/protocol.d.ts +54 -8
  50. package/dist/protocol.d.ts.map +1 -1
  51. package/dist/protocol.js +7 -1
  52. package/dist/protocol.js.map +1 -1
  53. package/dist/round-coordinator.d.ts +28 -4
  54. package/dist/round-coordinator.d.ts.map +1 -1
  55. package/dist/round-coordinator.js +92 -8
  56. package/dist/round-coordinator.js.map +1 -1
  57. package/dist/round-driver.d.ts +4 -0
  58. package/dist/round-driver.d.ts.map +1 -1
  59. package/dist/round-driver.js +2 -0
  60. package/dist/round-driver.js.map +1 -1
  61. package/dist/round-transaction.d.ts +4 -0
  62. package/dist/round-transaction.d.ts.map +1 -1
  63. package/dist/round-transaction.js +4 -5
  64. package/dist/round-transaction.js.map +1 -1
  65. package/dist/self-eval.d.ts.map +1 -1
  66. package/dist/self-eval.js +30 -0
  67. package/dist/self-eval.js.map +1 -1
  68. package/dist/token-utils.d.ts +3 -0
  69. package/dist/token-utils.d.ts.map +1 -1
  70. package/dist/token-utils.js +6 -0
  71. package/dist/token-utils.js.map +1 -1
  72. package/dist/verification-gate.d.ts +6 -2
  73. package/dist/verification-gate.d.ts.map +1 -1
  74. package/dist/verification-gate.js +41 -5
  75. package/dist/verification-gate.js.map +1 -1
  76. package/package.json +2 -2
  77. package/skills/perception/SKILL.md +105 -30
  78. package/dist/interop.d.ts +0 -35
  79. package/dist/interop.d.ts.map +0 -1
  80. package/dist/interop.js +0 -34
  81. package/dist/interop.js.map +0 -1
  82. package/dist/run.d.ts +0 -51
  83. package/dist/run.d.ts.map +0 -1
  84. package/dist/run.js +0 -200
  85. package/dist/run.js.map +0 -1
@@ -31,6 +31,149 @@ function entryProgress(entry) {
31
31
  const pe = evidence.progress_estimate;
32
32
  return typeof pe === "number" ? pe : null;
33
33
  }
34
+ /** Collect progress estimates from vault entries into a round→estimate map.
35
+ * Shared by R4 (progress_stall) and R5 (progress_stall_terminal). */
36
+ function collectProgressByRound(vaultEntries, currentRound) {
37
+ const progressByRound = new Map();
38
+ for (const entry of vaultEntries) {
39
+ const rnd = entryRound(entry);
40
+ if (rnd < 1 || rnd >= currentRound)
41
+ continue;
42
+ const pe = entryProgress(entry);
43
+ if (pe !== null)
44
+ progressByRound.set(rnd, pe);
45
+ }
46
+ return progressByRound;
47
+ }
48
+ // ═══════════════════════════════════════════════════════════════════════════
49
+ // v2.10: Backtrack helpers
50
+ // ═══════════════════════════════════════════════════════════════════════════
51
+ /** Check whether a vault entry represents a "clean" round that can serve
52
+ * as a safe restore point. A clean round was accepted (not rejected)
53
+ * and had no error-level verification flags. */
54
+ function isCleanRound(entry) {
55
+ const lineage = entry.loop_lineage;
56
+ // Rejected rounds are not clean
57
+ const success = typeof entry.success === "boolean"
58
+ ? entry.success
59
+ : typeof lineage?.success === "boolean" ? lineage.success : undefined;
60
+ if (success === false)
61
+ return false;
62
+ // Rounds with error-level verification flags are not clean
63
+ const flags = entry.verification_flags;
64
+ if (Array.isArray(flags)) {
65
+ for (const f of flags) {
66
+ if (f !== null && typeof f === "object" &&
67
+ f.severity === "error")
68
+ return false;
69
+ }
70
+ }
71
+ return true;
72
+ }
73
+ /** Scan backwards from currentRound to find the most recent clean round.
74
+ * Collects discovered_constraints from skipped rounds along the way.
75
+ * Returns null when no clean round is found within maxDepth. */
76
+ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
77
+ const completed = vaultEntries
78
+ .filter((e) => {
79
+ const rnd = entryRound(e);
80
+ return rnd >= 1 && rnd < currentRound;
81
+ })
82
+ .sort((a, b) => entryRound(b) - entryRound(a)); // newest first
83
+ const skippedDiscoveries = [];
84
+ for (let depth = 1; depth <= maxDepth; depth++) {
85
+ const targetRound = currentRound - depth;
86
+ if (targetRound < 1)
87
+ break;
88
+ const entry = completed.find((e) => entryRound(e) === targetRound);
89
+ if (!entry)
90
+ continue;
91
+ // Collect discoveries from rounds we're about to skip (depth > 1)
92
+ if (depth > 1) {
93
+ for (let d = 1; d < depth; d++) {
94
+ const skippedRound = currentRound - d;
95
+ const skippedEntry = completed.find((e) => entryRound(e) === skippedRound);
96
+ if (skippedEntry) {
97
+ const discovered = Array.isArray(skippedEntry.discovered_constraints)
98
+ ? skippedEntry.discovered_constraints.filter((v) => typeof v === "string")
99
+ : [];
100
+ for (const item of discovered) {
101
+ if (!skippedDiscoveries.includes(item))
102
+ skippedDiscoveries.push(item);
103
+ }
104
+ }
105
+ }
106
+ }
107
+ if (isCleanRound(entry)) {
108
+ return { round: targetRound, skippedDiscoveries };
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+ /** Build the backtrack prompt injected at the top of the restored round.
114
+ *
115
+ * v2.13: Includes concrete workspace restore instructions with affected
116
+ * file lists from skipped rounds. The agent must restore the working tree
117
+ * to the clean round's state before proceeding. */
118
+ export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
119
+ /** v2.13: Files changed in the skipped rounds (from evidence snapshots).
120
+ * Used to show the agent exactly what needs to be reverted. */
121
+ skippedFiles = [],
122
+ /** v2.13: Git HEAD commit hash at the backtrack point (current HEAD).
123
+ * The agent must discard work back to the clean round's state. */
124
+ _gitHead) {
125
+ const lines = [
126
+ `## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
127
+ "",
128
+ ];
129
+ // ── Why This Happened ────────────────────────────────────────────────
130
+ if (triggerRule === "progress_stall") {
131
+ lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}. ` +
132
+ `The work done in those rounds produced no verifiable progress — ` +
133
+ `no new files changed, no criteria met.`, "", "The approach used in those rounds **did not work**. It should not be repeated.", "");
134
+ }
135
+ else if (triggerRule === "progress_stall_terminal") {
136
+ lines.push("### Why This Happened", "", `Progress was completely flat for multiple rounds leading up to Round ${fromRound}. ` +
137
+ `The agent made **zero forward motion** — the task is not advancing.`, "", "A **radically different** strategy is needed. The previous approach produced nothing.", "");
138
+ }
139
+ // ── v2.13: Workspace Restore ──────────────────────────────────────────
140
+ lines.push("### ⚠️ Workspace Restore Required", "", `Your working directory still contains changes from the **failed** ` +
141
+ `rounds (${toRound + 1}–${fromRound}). You MUST discard those changes ` +
142
+ `before starting Round ${toRound + 1}. Working on top of stale changes ` +
143
+ `will cause the verification gate to reject your next submission.`, "");
144
+ if (skippedFiles.length > 0) {
145
+ lines.push("**Files modified in skipped rounds (must be reverted):**", "");
146
+ for (const f of skippedFiles.slice(0, 15)) {
147
+ lines.push(`- \`${f}\``);
148
+ }
149
+ if (skippedFiles.length > 15) {
150
+ lines.push(`- … and ${skippedFiles.length - 15} more files`);
151
+ }
152
+ lines.push("");
153
+ }
154
+ lines.push("**To restore a clean workspace, run:**", "", "```bash", "# Option A: Discard all uncommitted changes (recommended)", "git checkout -- .", "git clean -fd", "", "# Option B: If you have unrelated work to keep", `git stash push -m "backtrack-safety-net-round-${fromRound}"`, "git checkout -- .", "git clean -fd", "```", "", `**If git is unavailable**, manually revert the files listed above ` +
155
+ `to their state at Round ${toRound}. Re-read the state file below — ` +
156
+ `it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
157
+ "> your workspace no longer contains the failed changes. Submissions " +
158
+ "> with unrestored files will be **rejected**.", "");
159
+ // ── What Must Change ──────────────────────────────────────────────────
160
+ 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 " +
161
+ "(`canceled_subtasks`) and working on a different one.", "- Use the `prompt_requests.confusion_points` field in your self-evaluation " +
162
+ "to flag anything you don't understand about the task or the blockage.", "");
163
+ // ── Preserved Discoveries ─────────────────────────────────────────────
164
+ if (skippedDiscoveries.length > 0) {
165
+ lines.push("### Preserved Discoveries", "", "These were discovered in the skipped rounds and remain valid:", "");
166
+ for (const d of skippedDiscoveries) {
167
+ lines.push(`- ${d}`);
168
+ }
169
+ lines.push("");
170
+ }
171
+ // ── Instructions ──────────────────────────────────────────────────────
172
+ lines.push("### Instructions", "", `You have been rolled back to Round ${toRound}. ` +
173
+ `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 " +
174
+ "into the active constraints.", "4. Do NOT retrace the steps that led to the stall. Choose a new path.", "");
175
+ return lines.join("\n");
176
+ }
34
177
  // ═══════════════════════════════════════════════════════════════════════════
35
178
  // Individual enforcement rules — each returns EnforcementResult | null
36
179
  // Rules are ordered by priority. The first non-null result wins.
@@ -122,31 +265,14 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
122
265
  // Need at least 3 rounds of history
123
266
  if (currentRound < 3)
124
267
  return null;
125
- // Collect progress estimates for the last 3 completed rounds from vault
126
- const progressByRound = new Map();
127
- for (const entry of vaultEntries) {
128
- const rnd = entryRound(entry);
129
- // Only consider rounds before the current one
130
- if (rnd < 1 || rnd >= currentRound)
131
- continue;
132
- const pe = entryProgress(entry);
133
- if (pe !== null)
134
- progressByRound.set(rnd, pe);
135
- }
136
- // Need at least 3 data points
268
+ const progressByRound = collectProgressByRound(vaultEntries, currentRound);
137
269
  if (progressByRound.size < 3)
138
270
  return null;
139
- // Get the three most recent rounds with progress data
140
271
  const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
141
272
  const last3 = sortedRounds.slice(-3);
142
- if (last3.length < 3)
143
- return null;
144
- // Verify these are the actual last 3 rounds (contiguous with current)
145
- // Accept rounds that are within [currentRound-3, currentRound-1]
273
+ // Require all three data points to be within [currentRound-3, currentRound-1].
146
274
  const expectedMin = currentRound - 3;
147
- const actualMin = last3[0];
148
- // Allow up to 1 round gap (some rounds may not have progress data)
149
- if (actualMin < expectedMin - 1 || last3[2] > currentRound - 1)
275
+ if (last3[0] < expectedMin || last3[2] > currentRound - 1)
150
276
  return null;
151
277
  const p1 = progressByRound.get(last3[0]);
152
278
  const p2 = progressByRound.get(last3[1]);
@@ -160,7 +286,49 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
160
286
  p3 < 0.95;
161
287
  if (!isStalling)
162
288
  return null;
163
- // If already rejected once for stall → escalate to terminate
289
+ const escalation = getPolicy().engine.enforcement_escalation_enabled;
290
+ // v2.7: When escalation is enabled, give one extra rejection round with a
291
+ // "Seek Human Guidance" notice before terminating. This lets the agent
292
+ // course-correct with human input rather than being killed immediately.
293
+ if (consecutiveRejections >= 2) {
294
+ return makeEnforcementResult({
295
+ action: "terminate",
296
+ reason: `Progress stalled for 3+ rounds ` +
297
+ `(${(p1 * 100).toFixed(0)}% → ${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}%) ` +
298
+ `and agent did not resolve after escalation. Terminating loop.`,
299
+ check: "progress_stall",
300
+ });
301
+ }
302
+ if (consecutiveRejections >= 1 && escalation) {
303
+ // v2.10: When backtrack is enabled, escalate to backtrack instead of
304
+ // reject. The agent is rolled back to the last clean round with lessons
305
+ // injected, rather than redoing the same round with an escalation notice.
306
+ if (getPolicy().engine.backtrack_enabled) {
307
+ return makeEnforcementResult({
308
+ action: "backtrack",
309
+ reason: `Progress has stalled: ${(p1 * 100).toFixed(0)}% → ` +
310
+ `${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}% over the ` +
311
+ `last 3 rounds (delta < ${(stallThreshold * 100).toFixed(0)}% each round).`,
312
+ fix_instructions: "Your progress has been flat for 3 rounds. You are being rolled back " +
313
+ "to the last clean round. Do NOT repeat the approach that led to the stall. " +
314
+ "Choose a different technique or task decomposition.",
315
+ check: "progress_stall",
316
+ });
317
+ }
318
+ return makeEnforcementResult({
319
+ action: "reject",
320
+ reason: `Progress has stalled: ${(p1 * 100).toFixed(0)}% → ` +
321
+ `${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}% over the ` +
322
+ `last 3 rounds (delta < ${(stallThreshold * 100).toFixed(0)}% each round).`,
323
+ fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
324
+ "(a) explain what is blocking progress, " +
325
+ "(b) propose a DIFFERENT technique or task decomposition, and " +
326
+ "(c) set a concrete, verifiable goal for the redo of this round. " +
327
+ "Do NOT repeat the same approach — it has not moved progress forward." +
328
+ buildEscalationNotice(),
329
+ check: "progress_stall",
330
+ });
331
+ }
164
332
  if (consecutiveRejections >= 1) {
165
333
  return makeEnforcementResult({
166
334
  action: "terminate",
@@ -183,34 +351,27 @@ function enforceProgressStall(_selfEval, _flags, currentRound, vaultEntries, con
183
351
  check: "progress_stall",
184
352
  });
185
353
  }
186
- /** R5: Progress completely stalled (delta = 0) for N consecutive rounds → terminate.
354
+ /** R5: Progress completely stalled (delta = 0) for N consecutive rounds.
187
355
  * Stricter than R4 (which triggers on delta < 5%). R4 gives the agent a chance
188
356
  * to change approach via reject; this rule fires when progress has been exactly
189
- * flat — the agent is making zero forward motion and should be stopped.
357
+ * flat — the agent is making zero forward motion.
358
+ *
359
+ * v2.7: When enforcement_escalation_enabled is true, the first occurrence
360
+ * issues a rejection with an escalation notice instead of terminating.
361
+ * Termination only happens on the second consecutive flatline detection.
190
362
  *
191
363
  * Uses the engine.max_circuit_breaker policy value as the lookback window. */
192
- function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntries, skipEvidenceRules = false) {
364
+ function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
193
365
  if (skipEvidenceRules)
194
366
  return null; // Requires execution_evidence
195
367
  const breakerSize = getPolicy().engine.max_circuit_breaker;
196
368
  if (currentRound < breakerSize || vaultEntries.length < breakerSize)
197
369
  return null;
198
- // Collect progress estimates for recent completed rounds.
199
- const progressByRound = new Map();
200
- for (const entry of vaultEntries) {
201
- const rnd = entryRound(entry);
202
- if (rnd < 1 || rnd >= currentRound)
203
- continue;
204
- const pe = entryProgress(entry);
205
- if (pe !== null)
206
- progressByRound.set(rnd, pe);
207
- }
370
+ const progressByRound = collectProgressByRound(vaultEntries, currentRound);
208
371
  if (progressByRound.size < breakerSize)
209
372
  return null;
210
373
  const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
211
374
  const recent = sortedRounds.slice(-breakerSize);
212
- if (recent.length < breakerSize)
213
- return null;
214
375
  // Check that progress values are ALL identical (delta = 0).
215
376
  // Use epsilon comparison — JSON round-trips can produce float drift
216
377
  // (e.g. 0.3 vs 0.30000000000000004) that would defeat strict === .
@@ -219,6 +380,48 @@ function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntr
219
380
  const allFlat = recent.every((r) => Math.abs((progressByRound.get(r) ?? 0) - first) < EPSILON);
220
381
  if (!allFlat || first >= 0.95)
221
382
  return null;
383
+ const escalation = getPolicy().engine.enforcement_escalation_enabled;
384
+ // v2.7: When escalation is enabled, give one rejection round with a
385
+ // "Seek Human Guidance" notice before terminating.
386
+ if (consecutiveRejections >= 1) {
387
+ return makeEnforcementResult({
388
+ action: "terminate",
389
+ reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
390
+ `for ${breakerSize} consecutive rounds and agent did not resolve ` +
391
+ `after escalation. The agent is making zero forward motion.`,
392
+ check: "progress_stall_terminal",
393
+ });
394
+ }
395
+ if (escalation) {
396
+ // v2.10: When backtrack is enabled, escalate to backtrack instead of
397
+ // reject. The agent is rolled back to the last clean round with a
398
+ // "radically different strategy" lesson injected.
399
+ if (getPolicy().engine.backtrack_enabled) {
400
+ return makeEnforcementResult({
401
+ action: "backtrack",
402
+ reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
403
+ `for ${breakerSize} consecutive rounds. The agent is making zero ` +
404
+ `forward motion.`,
405
+ fix_instructions: "Your progress has been exactly flat — zero forward motion. " +
406
+ "You are being rolled back to the last clean round. " +
407
+ "Choose a RADICALLY different approach. The previous one produced nothing.",
408
+ check: "progress_stall_terminal",
409
+ });
410
+ }
411
+ return makeEnforcementResult({
412
+ action: "reject",
413
+ reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
414
+ `for ${breakerSize} consecutive rounds. The agent is making zero ` +
415
+ `forward motion.`,
416
+ fix_instructions: "Your progress has been exactly flat — you are making zero forward " +
417
+ "motion. You must: (a) explain what is fundamentally blocking you, " +
418
+ "(b) propose a radically different approach, and " +
419
+ "(c) set a concrete verifiable goal. " +
420
+ "Do NOT resubmit the same output." +
421
+ buildEscalationNotice(),
422
+ check: "progress_stall_terminal",
423
+ });
424
+ }
222
425
  return makeEnforcementResult({
223
426
  action: "terminate",
224
427
  reason: `Progress has been completely flat at ${(first * 100).toFixed(0)}% ` +
@@ -227,10 +430,40 @@ function enforceProgressStallTerminal(_selfEval, _flags, currentRound, vaultEntr
227
430
  check: "progress_stall_terminal",
228
431
  });
229
432
  }
230
- /** R6: Two consecutive rejections → terminate.
231
- * The agent has been unable or unwilling to fix the identified issues
232
- * across two consecutive enforcement rejections. */
433
+ /** R6: Two consecutive rejections → escalate (v2.7) or terminate.
434
+ *
435
+ * v2.7: When enforcement_escalation_enabled is true, the second consecutive
436
+ * rejection issues an escalated rejection with a "Seek Human Guidance" notice
437
+ * instead of terminating immediately. Termination occurs on the third
438
+ * consecutive rejection. When the flag is disabled, behavior is unchanged
439
+ * (terminate at ≥2 rejections). */
233
440
  function enforceMaxRejections(consecutiveRejections) {
441
+ const escalation = getPolicy().engine.enforcement_escalation_enabled;
442
+ if (escalation) {
443
+ if (consecutiveRejections >= 3) {
444
+ return makeEnforcementResult({
445
+ action: "terminate",
446
+ reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
447
+ `same issue without resolution. The agent has been unable to correct ` +
448
+ `the identified issue even after escalation. Terminating loop.`,
449
+ check: "max_rejections",
450
+ });
451
+ }
452
+ if (consecutiveRejections >= 2) {
453
+ return makeEnforcementResult({
454
+ action: "reject",
455
+ reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
456
+ `same issue without resolution.`,
457
+ fix_instructions: "You have been rejected multiple times for the same issue. " +
458
+ "You must address ALL items in the Evidence Gap section above. " +
459
+ "If you do not understand why you are being rejected, explain " +
460
+ "your confusion explicitly and ask for clarification." +
461
+ buildEscalationNotice(),
462
+ check: "max_rejections",
463
+ });
464
+ }
465
+ return null;
466
+ }
234
467
  if (consecutiveRejections < 2)
235
468
  return null;
236
469
  return makeEnforcementResult({
@@ -240,19 +473,114 @@ function enforceMaxRejections(consecutiveRejections) {
240
473
  check: "max_rejections",
241
474
  });
242
475
  }
476
+ /** v2.12: Check if a drift_clarification contains at least one semantic
477
+ * anchor — a constraint/criterion/sub-goal ID (v2.11) or a file-path-like
478
+ * reference. This distinguishes genuine explanations from filler text that
479
+ * just happens to be ≥ 20 characters. */
480
+ function hasSemanticAnchor(clarification) {
481
+ // v2.11: Constraint / criterion / sub-goal ID patterns
482
+ if (/[c]r?-[a-f0-9]{8}/.test(clarification))
483
+ return true;
484
+ if (/sg-[a-f0-9]{8}/.test(clarification))
485
+ return true;
486
+ // File path references (e.g. "src/auth/login.ts", "lib/utils.go")
487
+ if (/[\w./-]+\.[a-z]{2,6}\b/i.test(clarification))
488
+ return true;
489
+ return false;
490
+ }
491
+ /** Build a stronger rejection notice as the clarification streak grows. */
492
+ function buildClarificationRejectionNotice(streak) {
493
+ const base = "Your declared next_action from the previous round does not match " +
494
+ "what you actually did this round. You must either: " +
495
+ "(a) explain why you pivoted in the drift_clarification field, " +
496
+ "mentioning specific constraint/criterion/sub-goal IDs or file paths " +
497
+ "that prompted the change, or " +
498
+ "(b) redo this round and do what you said you would do.";
499
+ if (streak >= 2) {
500
+ return (base +
501
+ "\n\n### ⚠️ Repeated Weak Clarification\n\n" +
502
+ `You have submitted drift_clarification without concrete references ` +
503
+ `for ${streak} consecutive rounds. Your explanation MUST include at ` +
504
+ `least one of: a constraint ID (c-XXXXXXXX), criterion ID (cr-XXXXXXXX), ` +
505
+ `sub-goal ID (sg-XXXXXXXX), or a file path you changed.\n\n` +
506
+ `🚨 If the next clarification also lacks concrete anchors, the loop ` +
507
+ `will be terminated.`);
508
+ }
509
+ return base;
510
+ }
243
511
  /** R7: Agent declared a next_action but did something unrelated.
512
+ *
513
+ * v2.8: When the agent provides a substantive drift_clarification (≥ 20
514
+ * characters), the rejection is waived — the agent has acknowledged and
515
+ * explained the pivot. An empty or absent clarification still triggers
516
+ * the normal reject/terminate path.
517
+ *
518
+ * v2.12: Clarification now requires a semantic anchor (constraint/criterion/
519
+ * sub-goal ID or file path) in addition to the ≥ 20 char minimum. Weak
520
+ * clarifications (no anchors) increment a streak counter; 3 consecutive
521
+ * weak clarifications terminate the loop.
522
+ *
244
523
  * First occurrence → reject (agent must explain the gap or realign).
245
524
  * Second consecutive → terminate (agent cannot/will not follow its own plan). */
246
- function enforceIntentDrift(flags, consecutiveRejections) {
525
+ function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0) {
247
526
  const flag = flags.find((f) => f.check === "intent_drift");
248
527
  if (!flag)
249
528
  return null;
529
+ const clarification = selfEval.drift_clarification?.trim();
530
+ // v2.12: Semantic anchor detection — requires concrete references
531
+ const hasAnchor = clarification ? hasSemanticAnchor(clarification) : false;
532
+ // v2.12: Substantive clarification = length check + semantic anchor
533
+ const isSubstantive = clarification
534
+ && clarification.length >= 20
535
+ && hasAnchor;
536
+ if (isSubstantive) {
537
+ // Genuine explanation with concrete references → accept the pivot
538
+ return makeEnforcementResult({
539
+ action: "accept",
540
+ reason: "",
541
+ fix_instructions: "",
542
+ check: "intent_drift",
543
+ clarification_accepted: true,
544
+ });
545
+ }
546
+ // v2.12: Clarification present but weak (no semantic anchors)
547
+ if (clarification && clarification.length >= 20 && !hasAnchor) {
548
+ const maxStreak = getPolicy().engine.drift_clarification_max_streak;
549
+ // When maxStreak is 0, disable the check (pre-v2.12 behavior)
550
+ if (maxStreak <= 0) {
551
+ return makeEnforcementResult({
552
+ action: "accept",
553
+ reason: "",
554
+ fix_instructions: "",
555
+ check: "intent_drift",
556
+ clarification_accepted: true,
557
+ });
558
+ }
559
+ const newStreak = driftClarificationStreak + 1;
560
+ if (newStreak >= maxStreak) {
561
+ return makeEnforcementResult({
562
+ action: "terminate",
563
+ reason: `Agent has submitted weak drift_clarification (no concrete IDs or ` +
564
+ `file references) for ${newStreak} consecutive rounds. The agent ` +
565
+ `systematically avoids following its own plan without substantive ` +
566
+ `explanation.`,
567
+ check: "intent_drift",
568
+ });
569
+ }
570
+ return makeEnforcementResult({
571
+ action: "reject",
572
+ reason: flag.detail,
573
+ fix_instructions: buildClarificationRejectionNotice(newStreak),
574
+ check: "intent_drift",
575
+ });
576
+ }
577
+ // No clarification or too short — original behavior
250
578
  if (consecutiveRejections >= 1) {
251
579
  return makeEnforcementResult({
252
580
  action: "terminate",
253
581
  reason: `Agent was warned about intent-action drift but continues to do ` +
254
- `work unrelated to its declared next_action. The agent cannot or ` +
255
- `will not follow its own plan — terminating loop.`,
582
+ `work unrelated to its declared next_action without explanation. ` +
583
+ `The agent cannot or will not follow its own plan — terminating loop.`,
256
584
  check: "intent_drift",
257
585
  });
258
586
  }
@@ -261,7 +589,8 @@ function enforceIntentDrift(flags, consecutiveRejections) {
261
589
  reason: flag.detail,
262
590
  fix_instructions: "Your declared next_action from the previous round does not match " +
263
591
  "what you actually did this round. You must either: " +
264
- "(a) explain why you pivoted and what you learned, or " +
592
+ "(a) explain why you pivoted and what you learned in the " +
593
+ "drift_clarification field, or " +
265
594
  "(b) redo this round and do what you said you would do. " +
266
595
  "If you intentionally changed direction, update your objective_refinement " +
267
596
  "and set a new next_action that reflects the new plan.",
@@ -269,6 +598,58 @@ function enforceIntentDrift(flags, consecutiveRejections) {
269
598
  });
270
599
  }
271
600
  // ═══════════════════════════════════════════════════════════════════════════
601
+ // Escalation notice builder (v2.7)
602
+ // ═══════════════════════════════════════════════════════════════════════════
603
+ /** Build an escalation notice urging the agent to seek human guidance.
604
+ *
605
+ * Appended to fix_instructions when a rule would otherwise terminate.
606
+ * Gives the agent one final chance to correct course with human input
607
+ * before the loop is irreversibly stopped. */
608
+ function buildEscalationNotice() {
609
+ return [
610
+ "",
611
+ "### 🆘 Escalation — Seek Human Guidance",
612
+ "",
613
+ "You have been rejected multiple times for the same issue. " +
614
+ "Before retrying, pause and ask a human for help:",
615
+ "",
616
+ "1. Explain what you've tried so far and why each approach failed.",
617
+ "2. Ask the human for specific guidance on what to try next.",
618
+ "3. Incorporate their feedback before re-submitting your self-evaluation.",
619
+ "",
620
+ "⚠️ If this round is rejected again, the loop will be **terminated**. " +
621
+ "Do NOT resubmit without meaningful changes.",
622
+ ].join("\n");
623
+ }
624
+ // ═══════════════════════════════════════════════════════════════════════════
625
+ // Diagnostic gap builder (v2.7)
626
+ // ═══════════════════════════════════════════════════════════════════════════
627
+ /** Build a diagnostic gap section from verification flags.
628
+ *
629
+ * Translates each error/warn-level VerificationFlag into a concrete
630
+ * claim-vs-evidence mismatch statement. Info-level flags are excluded
631
+ * because they are purely informational and do not represent gaps.
632
+ *
633
+ * Returns an empty string when there are no diagnostic flags to report. */
634
+ function buildDiagnosticGap(flags) {
635
+ const diagnosticFlags = flags.filter((f) => f.severity === "error" || f.severity === "warn");
636
+ if (diagnosticFlags.length === 0)
637
+ return "";
638
+ const lines = [
639
+ "### Evidence Gap",
640
+ "",
641
+ "The enforcement gate detected mismatches between your claims and the evidence. " +
642
+ "Each item below shows a specific discrepancy — address every one before re-submitting.",
643
+ "",
644
+ ];
645
+ for (const flag of diagnosticFlags) {
646
+ const icon = flag.severity === "error" ? "🚫" : "⚠️";
647
+ lines.push(`- ${icon} **[${flag.check}]** \`${flag.field}\` — ${flag.detail}`);
648
+ }
649
+ lines.push("");
650
+ return lines.join("\n");
651
+ }
652
+ // ═══════════════════════════════════════════════════════════════════════════
272
653
  // Main entry point
273
654
  // ═══════════════════════════════════════════════════════════════════════════
274
655
  /** Enforce round-boundary rules based on the verification gate's findings
@@ -283,7 +664,9 @@ function enforceIntentDrift(flags, consecutiveRejections) {
283
664
  * @param consecutiveRejections How many consecutive rounds have already been rejected.
284
665
  * Starts at 0; increments on each reject; resets on accept.
285
666
  */
286
- export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0, skipEvidenceRules = false) {
667
+ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0, skipEvidenceRules = false,
668
+ /** v2.12: Current clarification streak for R7 escalation. */
669
+ driftClarificationStreak = 0) {
287
670
  const { flags } = verifyResult;
288
671
  // Run enforcement rules in priority order.
289
672
  // Earlier rules take higher precedence.
@@ -292,9 +675,9 @@ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries,
292
675
  () => enforceRecurringViolation(flags),
293
676
  () => enforceEmptySuccess(selfEval, flags, skipEvidenceRules),
294
677
  () => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
295
- () => enforceProgressStallTerminal(selfEval, flags, currentRound, vaultEntries, skipEvidenceRules),
678
+ () => enforceProgressStallTerminal(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
296
679
  () => enforceMaxRejections(consecutiveRejections),
297
- () => enforceIntentDrift(flags, consecutiveRejections),
680
+ () => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak),
298
681
  ];
299
682
  for (const rule of rules) {
300
683
  const result = rule();
@@ -313,11 +696,17 @@ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries,
313
696
  * The prompt clearly states the round was rejected, why, what the agent
314
697
  * must fix, and that the agent must redo the SAME round (not advance).
315
698
  *
316
- * @param currentRound The round number that was rejected (NOT incremented).
317
- * @param task The original loop task description.
318
- * @param enforceResult The enforcement decision with reason and fix instructions.
319
- */
320
- export function buildRejectionPrompt(currentRound, task, enforceResult) {
699
+ * v2.7: Accepts verificationFlags to render a diagnostic "Evidence Gap"
700
+ * section. Each error/warn flag becomes a concrete claim-vs-evidence
701
+ * mismatch statement so the agent knows exactly what to correct rather
702
+ * than retrying blindly.
703
+ *
704
+ * @param currentRound The round number that was rejected (NOT incremented).
705
+ * @param task The original loop task description.
706
+ * @param enforceResult The enforcement decision with reason and fix instructions.
707
+ * @param verificationFlags The verification gate's findings for this round.
708
+ * Used to build the Evidence Gap section. */
709
+ export function buildRejectionPrompt(currentRound, task, enforceResult, verificationFlags = []) {
321
710
  const lines = [
322
711
  "## ⛔ Round " + currentRound + " — REJECTED",
323
712
  "",
@@ -329,6 +718,11 @@ export function buildRejectionPrompt(currentRound, task, enforceResult) {
329
718
  enforceResult.reason,
330
719
  "",
331
720
  ];
721
+ // v2.7: Diagnostic gap — show the agent exactly what doesn't match.
722
+ const gap = buildDiagnosticGap(verificationFlags);
723
+ if (gap) {
724
+ lines.push(gap);
725
+ }
332
726
  if (enforceResult.fix_instructions) {
333
727
  lines.push("### Required Fix");
334
728
  lines.push("");