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