loopforge 3.2.1 → 3.6.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.
- package/README.md +67 -67
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +7 -34
- package/dist/audit.js.map +1 -1
- package/dist/canonical-state.d.ts +71 -4
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +176 -9
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +16 -18
- package/dist/cli.js.map +1 -1
- package/dist/cognitive-facts.d.ts +17 -0
- package/dist/cognitive-facts.d.ts.map +1 -0
- package/dist/cognitive-facts.js +99 -0
- package/dist/cognitive-facts.js.map +1 -0
- package/dist/committed-round.d.ts +59 -0
- package/dist/committed-round.d.ts.map +1 -0
- package/dist/committed-round.js +222 -0
- package/dist/committed-round.js.map +1 -0
- package/dist/enforcement-gate.d.ts +4 -2
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +339 -301
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts +16 -4
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +63 -15
- package/dist/engine.js.map +1 -1
- package/dist/evidence-claims.d.ts.map +1 -1
- package/dist/evidence-claims.js +21 -39
- package/dist/evidence-claims.js.map +1 -1
- package/dist/evidence-provider.d.ts +29 -0
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +93 -16
- package/dist/evidence-provider.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +21 -4
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +244 -66
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-projection.d.ts +5 -25
- package/dist/loop-projection.d.ts.map +1 -1
- package/dist/loop-projection.js +18 -134
- package/dist/loop-projection.js.map +1 -1
- package/dist/loop-store.d.ts +5 -2
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +6 -8
- package/dist/mcp/round-lifecycle.d.ts.map +1 -1
- package/dist/mcp/round-lifecycle.js +61 -52
- package/dist/mcp/round-lifecycle.js.map +1 -1
- package/dist/mcp/server.js +3 -3
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +14 -3
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +174 -52
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts +12 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +160 -52
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +0 -7
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +15 -55
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +22 -20
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +24 -38
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +120 -140
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/protocol.d.ts +57 -23
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +10 -4
- package/dist/protocol.js.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +21 -22
- package/dist/replay.js.map +1 -1
- package/dist/round-contract.d.ts +64 -0
- package/dist/round-contract.d.ts.map +1 -0
- package/dist/round-contract.js +80 -0
- package/dist/round-contract.js.map +1 -0
- package/dist/round-coordinator.d.ts +5 -3
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +33 -29
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +0 -1
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +0 -1
- package/dist/round-driver.js.map +1 -1
- package/dist/round-transaction.d.ts +0 -1
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +0 -1
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +29 -34
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +112 -148
- package/dist/self-eval.js.map +1 -1
- package/dist/verification-gate.d.ts +53 -12
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +462 -90
- package/dist/verification-gate.js.map +1 -1
- package/dist/workspace.d.ts +29 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +61 -0
- package/dist/workspace.js.map +1 -0
- package/loop_policy.json +5 -13
- package/package.json +1 -1
- package/skills/perception/SKILL.md +135 -136
package/dist/enforcement-gate.js
CHANGED
|
@@ -17,35 +17,23 @@
|
|
|
17
17
|
* with stopReason "enforcement_terminated".
|
|
18
18
|
*/
|
|
19
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";
|
|
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, CHECK_VERIFICATION_ENTRYPOINT_MODIFIED, CHECK_PREMATURE_BOUNDARY, CHECK_ROUND_SCOPE_DRIFT, CHECK_CONTRACT_COMPLETION_UNVERIFIED } from "./verification-gate.js";
|
|
21
21
|
import { effectiveSuccess } from "./self-eval.js";
|
|
22
22
|
import { deriveConstraintId, deriveCriterionId, deriveSubGoalId } from "./loop-compiler.js";
|
|
23
23
|
import { getPolicy } from "./policy.js";
|
|
24
24
|
import { STABLE_ID_RE, isRecord } from "./token-utils.js";
|
|
25
|
-
import {
|
|
25
|
+
import { committedRoundsFromEntries, decodeCommittedRound, } from "./committed-round.js";
|
|
26
26
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
27
|
// Helpers
|
|
28
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
29
|
/** Collect progress estimates from vault entries into a round→estimate map.
|
|
39
|
-
* Shared by R4 (progress_stall) and R5 (
|
|
30
|
+
* Shared by R4 (progress_stall) and R5 (progress_flatline). */
|
|
40
31
|
function collectProgressByRound(vaultEntries, currentRound) {
|
|
41
32
|
const progressByRound = new Map();
|
|
42
|
-
for (const
|
|
43
|
-
const
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
const pe = entryProgress(entry);
|
|
47
|
-
if (pe !== null)
|
|
48
|
-
progressByRound.set(rnd, pe);
|
|
33
|
+
for (const round of committedRoundsFromEntries(vaultEntries, currentRound)) {
|
|
34
|
+
const progress = round.executionEvidence?.progress_estimate;
|
|
35
|
+
if (typeof progress === "number")
|
|
36
|
+
progressByRound.set(round.round, progress);
|
|
49
37
|
}
|
|
50
38
|
return progressByRound;
|
|
51
39
|
}
|
|
@@ -73,13 +61,7 @@ function progressWindow(vaultEntries, currentRound, selfEval) {
|
|
|
73
61
|
* decision under loop_lineage.round_transaction; lineage entries carry
|
|
74
62
|
* nothing at the top level. */
|
|
75
63
|
function committedRoundResult(entry) {
|
|
76
|
-
|
|
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;
|
|
64
|
+
return decodeCommittedRound(entry)?.result ?? null;
|
|
83
65
|
}
|
|
84
66
|
/** True when a backtrack already committed at or after the current round —
|
|
85
67
|
* i.e. the loop is re-walking territory the system already rolled back
|
|
@@ -91,7 +73,7 @@ function hasCommittedBacktrack(vaultEntries, currentRound) {
|
|
|
91
73
|
return committedRoundResult(entry)?.action === "backtrack";
|
|
92
74
|
});
|
|
93
75
|
}
|
|
94
|
-
/** Check whether a
|
|
76
|
+
/** Check whether a committed round represents a clean restore point.
|
|
95
77
|
* as a safe restore point. A clean round was accepted (not rejected)
|
|
96
78
|
* and had no error-level verification flags.
|
|
97
79
|
*
|
|
@@ -101,61 +83,30 @@ function hasCommittedBacktrack(vaultEntries, currentRound) {
|
|
|
101
83
|
* valid backtrack target (the most common backtrack trigger is exactly a
|
|
102
84
|
* run of such rounds). Rejected/terminated rounds never commit, and
|
|
103
85
|
* rolled-back rounds are excluded below. */
|
|
104
|
-
function isCleanRound(
|
|
105
|
-
|
|
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;
|
|
86
|
+
function isCleanRound(round) {
|
|
87
|
+
return !round.verificationFlags.some((flag) => flag.severity === "error");
|
|
124
88
|
}
|
|
125
89
|
/** Scan backwards from currentRound to find the most recent clean round.
|
|
126
90
|
* Collects discovered_constraints from skipped rounds along the way.
|
|
127
91
|
* Returns null when no clean round is found within maxDepth. */
|
|
128
92
|
export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
93
|
+
const completed = committedRoundsFromEntries(vaultEntries, currentRound)
|
|
94
|
+
.sort((a, b) => b.round - a.round);
|
|
142
95
|
const skippedDiscoveries = [];
|
|
143
96
|
for (let depth = 1; depth <= maxDepth; depth++) {
|
|
144
97
|
const targetRound = currentRound - depth;
|
|
145
98
|
if (targetRound < 1)
|
|
146
99
|
break;
|
|
147
|
-
const
|
|
148
|
-
if (!
|
|
100
|
+
const round = completed.find((item) => item.round === targetRound);
|
|
101
|
+
if (!round)
|
|
149
102
|
continue;
|
|
150
103
|
// Collect discoveries from rounds we're about to skip (depth > 1)
|
|
151
104
|
if (depth > 1) {
|
|
152
105
|
for (let d = 1; d < depth; d++) {
|
|
153
106
|
const skippedRound = currentRound - d;
|
|
154
|
-
const skippedEntry = completed.find((
|
|
155
|
-
if (skippedEntry) {
|
|
156
|
-
const discovered =
|
|
157
|
-
? skippedEntry.discovered_constraints.filter((v) => typeof v === "string")
|
|
158
|
-
: [];
|
|
107
|
+
const skippedEntry = completed.find((item) => item.round === skippedRound);
|
|
108
|
+
if (skippedEntry?.evaluation) {
|
|
109
|
+
const discovered = skippedEntry.evaluation.discovered_constraints ?? [];
|
|
159
110
|
for (const item of discovered) {
|
|
160
111
|
if (!skippedDiscoveries.includes(item))
|
|
161
112
|
skippedDiscoveries.push(item);
|
|
@@ -163,7 +114,7 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
|
|
|
163
114
|
}
|
|
164
115
|
}
|
|
165
116
|
}
|
|
166
|
-
if (isCleanRound(
|
|
117
|
+
if (isCleanRound(round)) {
|
|
167
118
|
return { round: targetRound, skippedDiscoveries };
|
|
168
119
|
}
|
|
169
120
|
}
|
|
@@ -174,23 +125,13 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
|
|
|
174
125
|
* evidence as fallback). Returns null when the round has no git snapshot
|
|
175
126
|
* or no committed feedback entry. */
|
|
176
127
|
export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
|
|
177
|
-
const
|
|
178
|
-
|
|
179
|
-
|
|
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)
|
|
128
|
+
const committed = committedRoundsFromEntries(vaultEntries)
|
|
129
|
+
.find((view) => view.round === restoreRound);
|
|
130
|
+
if (!committed)
|
|
188
131
|
return null;
|
|
189
|
-
const evidence =
|
|
190
|
-
?
|
|
191
|
-
:
|
|
192
|
-
? snapshot.beforeEvidence
|
|
193
|
-
: [];
|
|
132
|
+
const evidence = committed.afterEvidence.length > 0
|
|
133
|
+
? committed.afterEvidence
|
|
134
|
+
: committed.beforeEvidence;
|
|
194
135
|
for (const item of evidence) {
|
|
195
136
|
if (!isRecord(item) || item.provider !== "git" || !isRecord(item.data))
|
|
196
137
|
continue;
|
|
@@ -222,7 +163,7 @@ gitHead) {
|
|
|
222
163
|
`The work done in those rounds produced no verifiable progress — ` +
|
|
223
164
|
`no new files changed, no criteria met.`, "", "The approach used in those rounds **did not work**. It should not be repeated.", "");
|
|
224
165
|
}
|
|
225
|
-
else if (triggerRule === "
|
|
166
|
+
else if (triggerRule === "progress_flatline") {
|
|
226
167
|
lines.push("### Why This Happened", "", `Progress was completely flat for multiple rounds leading up to Round ${fromRound}. ` +
|
|
227
168
|
`The agent made **zero forward motion** — the task is not advancing.`, "", "A **radically different** strategy is needed. The previous approach produced nothing.", "");
|
|
228
169
|
}
|
|
@@ -255,7 +196,16 @@ gitHead) {
|
|
|
255
196
|
"> with unrestored files will be **rejected**.", "");
|
|
256
197
|
// ── What Must Change ──────────────────────────────────────────────────
|
|
257
198
|
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.",
|
|
199
|
+
"(`canceled_subtasks`) and working on a different one.",
|
|
200
|
+
// v3.5: the restored Current Task may be the very Round Contract that
|
|
201
|
+
// stalled — the agent must be told the sanctioned way to revise it
|
|
202
|
+
// (outcome=blocked closes the active contract; the revised proposal in
|
|
203
|
+
// the same submission becomes active next round). Without this the
|
|
204
|
+
// agent's revision would be silently ignored as premature.
|
|
205
|
+
"- If the restored Current Task is a Round Contract that caused the stall: " +
|
|
206
|
+
"close it in this submission with `outcome=\"blocked\"` (+ a blocker " +
|
|
207
|
+
"explaining why it stalled) and declare the REVISED contract in the " +
|
|
208
|
+
"same submission. Do **NOT** silently restate the stalled contract.", "- Use the `prompt_requests.confusion_points` field in your self-evaluation " +
|
|
259
209
|
"to flag anything you don't understand about the task or the blockage.", "");
|
|
260
210
|
// ── Preserved Discoveries ─────────────────────────────────────────────
|
|
261
211
|
if (skippedDiscoveries.length > 0) {
|
|
@@ -333,13 +283,33 @@ function enforceEvidenceContradiction(flags) {
|
|
|
333
283
|
check: contradiction.check,
|
|
334
284
|
});
|
|
335
285
|
}
|
|
286
|
+
/** v3.3: R-EVID-VERIFY — the verification command's entrypoint changed in
|
|
287
|
+
* the same round the command ran. The runtime executed a script the agent
|
|
288
|
+
* just rewrote, so the "machine observation" has no stable baseline and
|
|
289
|
+
* cannot back the success claim. Reject so the agent resubmits the SAME
|
|
290
|
+
* round with the entrypoint unchanged (a freshly created entrypoint passes
|
|
291
|
+
* on the redo — the diff no longer contains it). */
|
|
292
|
+
function enforceVerificationEntrypointTampered(flags) {
|
|
293
|
+
const flag = flags.find((f) => f.check === CHECK_VERIFICATION_ENTRYPOINT_MODIFIED && f.severity === "error");
|
|
294
|
+
if (!flag)
|
|
295
|
+
return null;
|
|
296
|
+
return makeEnforcementResult({
|
|
297
|
+
action: "reject",
|
|
298
|
+
reason: flag.detail,
|
|
299
|
+
fix_instructions: "The verification command entrypoint changed in this round, so its " +
|
|
300
|
+
"result cannot be trusted as machine evidence. You must: (a) keep the " +
|
|
301
|
+
"verification command (and its entrypoint files) stable, (b) resubmit " +
|
|
302
|
+
"your SelfEvaluation for the SAME round with the entrypoint unchanged " +
|
|
303
|
+
"so the runtime can re-run it against a stable baseline, and (c) do " +
|
|
304
|
+
"NOT rewrite the verification script to make it pass.",
|
|
305
|
+
check: "verification_entrypoint_modified",
|
|
306
|
+
});
|
|
307
|
+
}
|
|
336
308
|
/** R3: Agent claims success but did nothing verifiable.
|
|
337
309
|
* v1.17: execution_evidence is now MANDATORY for structured self-evaluations.
|
|
338
310
|
* Missing evidence when success=true → reject (agent must provide evidence).
|
|
339
311
|
* Empty evidence (no files + no tests) when success=true → reject. */
|
|
340
|
-
function enforceEmptySuccess(selfEval, _flags
|
|
341
|
-
if (skipEvidenceRules)
|
|
342
|
-
return null; // Requires execution_evidence
|
|
312
|
+
function enforceEmptySuccess(selfEval, _flags) {
|
|
343
313
|
if (!effectiveSuccess(selfEval))
|
|
344
314
|
return null;
|
|
345
315
|
const ev = selfEval.execution_evidence;
|
|
@@ -407,6 +377,131 @@ function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
|
|
|
407
377
|
check: "success_without_verified_evidence",
|
|
408
378
|
});
|
|
409
379
|
}
|
|
380
|
+
/** R-C1 (v3.3): Round Contract boundary claimed prematurely. The gate
|
|
381
|
+
* produced premature_boundary (error): success claimed while done_when
|
|
382
|
+
* items were claimed met without machine-verified evidence or silently
|
|
383
|
+
* dropped. First occurrence → reject with contract-specific fix
|
|
384
|
+
* instructions; second consecutive → terminate (R8 ladder). */
|
|
385
|
+
function enforcePrematureBoundary(flags, consecutiveRejections) {
|
|
386
|
+
const flag = flags.find((f) => f.check === CHECK_PREMATURE_BOUNDARY && f.severity === "error");
|
|
387
|
+
if (!flag)
|
|
388
|
+
return null;
|
|
389
|
+
if (consecutiveRejections >= 2) {
|
|
390
|
+
return makeEnforcementResult({
|
|
391
|
+
action: "terminate",
|
|
392
|
+
reason: "Repeated premature Round Contract boundary claims — done_when items " +
|
|
393
|
+
"claimed without machine evidence or dropped while success is claimed.",
|
|
394
|
+
check: "premature_boundary",
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled &&
|
|
398
|
+
consecutiveRejections >= 1
|
|
399
|
+
? buildEscalationNotice()
|
|
400
|
+
: "";
|
|
401
|
+
return makeEnforcementResult({
|
|
402
|
+
action: "reject",
|
|
403
|
+
reason: flag.detail,
|
|
404
|
+
fix_instructions: "You claimed success under a Round Contract but done_when items are " +
|
|
405
|
+
"unfinished or unverified. You must: (a) run the contract's " +
|
|
406
|
+
"verification_plan commands (configured in loop_policy.json " +
|
|
407
|
+
"evidence.commands) and report their actual output, listing satisfied " +
|
|
408
|
+
"done_when items in success_criteria_met; or (b) set success=false and " +
|
|
409
|
+
"list what remains in success_criteria_remaining. " +
|
|
410
|
+
"Do NOT claim contract completion without machine evidence " +
|
|
411
|
+
"(no_change_reason does not apply to contract claims)." + escalation,
|
|
412
|
+
check: "premature_boundary",
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
/** v3.5: Round Contract completion claimed without its verification_plan
|
|
416
|
+
* commands passing this round. The gate produced
|
|
417
|
+
* contract_completion_unverified (error): the eval's met claims satisfy
|
|
418
|
+
* every done_when of the ACTIVE contract, but the machine never observed
|
|
419
|
+
* the plan's commands pass. Closing a contract is a success-class claim —
|
|
420
|
+
* first occurrence → reject with fix instructions; second consecutive →
|
|
421
|
+
* terminate (R-C1's ladder). Registered BEFORE R-C1: the completion-truth
|
|
422
|
+
* question outranks boundary nuance. */
|
|
423
|
+
function enforceContractCompletionUnverified(flags, consecutiveRejections) {
|
|
424
|
+
const flag = flags.find((f) => f.check === CHECK_CONTRACT_COMPLETION_UNVERIFIED && f.severity === "error");
|
|
425
|
+
if (!flag)
|
|
426
|
+
return null;
|
|
427
|
+
if (consecutiveRejections >= 2) {
|
|
428
|
+
return makeEnforcementResult({
|
|
429
|
+
action: "terminate",
|
|
430
|
+
reason: "Repeated unverified Round Contract completion claims — done_when items " +
|
|
431
|
+
"claimed complete while the verification_plan commands never passed.",
|
|
432
|
+
check: "contract_completion_unverified",
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled &&
|
|
436
|
+
consecutiveRejections >= 1
|
|
437
|
+
? buildEscalationNotice()
|
|
438
|
+
: "";
|
|
439
|
+
return makeEnforcementResult({
|
|
440
|
+
action: "reject",
|
|
441
|
+
reason: flag.detail,
|
|
442
|
+
fix_instructions: "You claimed completion under the ACTIVE Round Contract, but its " +
|
|
443
|
+
"verification_plan commands did not pass this round. You must: (a) fix " +
|
|
444
|
+
"the underlying failure, re-run the contract's verification_plan " +
|
|
445
|
+
"commands (configured in loop_policy.json evidence.commands), and list " +
|
|
446
|
+
"satisfied done_when items in success_criteria_met; or (b) set " +
|
|
447
|
+
"success=false and list what remains in success_criteria_remaining. " +
|
|
448
|
+
"Completion claims are never accepted without machine verification — " +
|
|
449
|
+
"do NOT use no_change_reason for completion claims." + escalation,
|
|
450
|
+
check: "contract_completion_unverified",
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
/** R-C2 (v3.3): Round Contract scope drift. The gate produced
|
|
454
|
+
* round_scope_drift (warn): files changed outside the contract's declared
|
|
455
|
+
* scope. A substantive drift_clarification (≥ 20 chars with real anchors —
|
|
456
|
+
* the out-of-scope file paths count) accepts the legitimately expanded
|
|
457
|
+
* scope; no/weak clarification rejects; repeated unclarified drift
|
|
458
|
+
* terminates. Mirrors R8's ladder, not R7's streak counter — no new
|
|
459
|
+
* session state. */
|
|
460
|
+
function enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries = []) {
|
|
461
|
+
const flag = flags.find((f) => f.check === CHECK_ROUND_SCOPE_DRIFT);
|
|
462
|
+
if (!flag)
|
|
463
|
+
return null;
|
|
464
|
+
const clarification = selfEval.drift_clarification?.trim();
|
|
465
|
+
const hasAnchor = clarification
|
|
466
|
+
? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
|
|
467
|
+
: false;
|
|
468
|
+
const isSubstantive = clarification !== undefined
|
|
469
|
+
&& clarification.length >= 20
|
|
470
|
+
&& hasAnchor;
|
|
471
|
+
if (isSubstantive) {
|
|
472
|
+
// Genuine scope expansion with concrete file references → accept.
|
|
473
|
+
return makeEnforcementResult({
|
|
474
|
+
action: "accept",
|
|
475
|
+
reason: "",
|
|
476
|
+
fix_instructions: "",
|
|
477
|
+
check: "round_scope_drift",
|
|
478
|
+
clarification_accepted: true,
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
if (consecutiveRejections >= 2) {
|
|
482
|
+
return makeEnforcementResult({
|
|
483
|
+
action: "terminate",
|
|
484
|
+
reason: `Repeated scope drift without substantive drift_clarification — ` +
|
|
485
|
+
`the agent keeps changing files outside its declared contract scope.`,
|
|
486
|
+
check: "round_scope_drift",
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled &&
|
|
490
|
+
consecutiveRejections >= 1
|
|
491
|
+
? buildEscalationNotice()
|
|
492
|
+
: "";
|
|
493
|
+
return makeEnforcementResult({
|
|
494
|
+
action: "reject",
|
|
495
|
+
reason: flag.detail,
|
|
496
|
+
fix_instructions: "Files changed outside the contract's declared scope. You must either: " +
|
|
497
|
+
"(a) revert the out-of-scope changes, or (b) extend the scope in your " +
|
|
498
|
+
"re-declared round_contract AND explain the pivot in drift_clarification " +
|
|
499
|
+
"with concrete file paths (≥ 20 characters, real anchors). Weak or " +
|
|
500
|
+
"missing clarifications are rejected; repeated scope drift terminates " +
|
|
501
|
+
"the loop." + escalation,
|
|
502
|
+
check: "round_scope_drift",
|
|
503
|
+
});
|
|
504
|
+
}
|
|
410
505
|
/** R9 (v2.12): Post-backtrack workspace not restored. The verification gate
|
|
411
506
|
* produced backtrack_workspace_not_restored (error): either skipped-round
|
|
412
507
|
* files are still dirty, or the working tree is not at the restore point's
|
|
@@ -439,237 +534,124 @@ function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0)
|
|
|
439
534
|
check: "backtrack_workspace_not_restored",
|
|
440
535
|
});
|
|
441
536
|
}
|
|
442
|
-
/** R4
|
|
443
|
-
*
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
if (currentRound <
|
|
537
|
+
/** Shared R4/R5 observation pipeline. Machine git motion is exculpatory
|
|
538
|
+
* only: it may veto a self-reported stall but can never create one. */
|
|
539
|
+
function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
|
|
540
|
+
const window = tier === "progress_stall"
|
|
541
|
+
? 3
|
|
542
|
+
: Math.max(1, getPolicy().engine.stall_lookback_rounds);
|
|
543
|
+
if (currentRound < window)
|
|
449
544
|
return null;
|
|
450
|
-
|
|
451
|
-
let
|
|
452
|
-
let
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const machine = machineProgressSeries(vaultEntries, currentRound, 3);
|
|
458
|
-
if (machine === null)
|
|
545
|
+
const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
|
|
546
|
+
let stalled = false;
|
|
547
|
+
let detail = "";
|
|
548
|
+
const threshold = getPolicy().evolution.progress_stall_threshold;
|
|
549
|
+
if (progressByRound.size >= window) {
|
|
550
|
+
const recent = [...progressByRound.keys()].sort((a, b) => a - b).slice(-window);
|
|
551
|
+
if (recent[0] < currentRound - window)
|
|
459
552
|
return null;
|
|
460
|
-
|
|
461
|
-
|
|
553
|
+
const values = recent.map((round) => progressByRound.get(round));
|
|
554
|
+
if (values[values.length - 1] >= 0.95)
|
|
555
|
+
return null;
|
|
556
|
+
if (tier === "progress_stall") {
|
|
557
|
+
stalled = values.slice(1).every((value, index) => value - values[index] < threshold);
|
|
558
|
+
detail = values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ");
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
const first = values[0];
|
|
562
|
+
stalled = values.every((value) => Math.abs(value - first) < 1e-10);
|
|
563
|
+
detail = `completely flat at ${(first * 100).toFixed(0)}%`;
|
|
564
|
+
}
|
|
462
565
|
}
|
|
463
566
|
else {
|
|
464
|
-
const
|
|
465
|
-
if (
|
|
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)
|
|
567
|
+
const machine = machineProgressSeries(vaultEntries, currentRound, window);
|
|
568
|
+
if (machine === null)
|
|
474
569
|
return null;
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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)}%`;
|
|
570
|
+
stalled = machine.every((value) => !value);
|
|
571
|
+
detail = tier === "progress_stall"
|
|
572
|
+
? `no machine-observed changes in the last ${window} rounds`
|
|
573
|
+
: `no machine-observed changes for ${window} consecutive rounds`;
|
|
484
574
|
}
|
|
485
|
-
if (!
|
|
575
|
+
if (!stalled)
|
|
486
576
|
return null;
|
|
577
|
+
const machine = machineProgressSeries(vaultEntries, currentRound, window);
|
|
578
|
+
if (machine?.some(Boolean))
|
|
579
|
+
return null;
|
|
580
|
+
if (machine !== null) {
|
|
581
|
+
detail += ` — no machine-observed git motion in rounds ${currentRound - window}–${currentRound - 1}`;
|
|
582
|
+
}
|
|
583
|
+
return { tier, detail, window, threshold };
|
|
584
|
+
}
|
|
585
|
+
/** Shared R4/R5 escalation and backtrack deadlock guard. The flatline tier
|
|
586
|
+
* keeps its steeper threshold-0 ladder while using the same mechanics. */
|
|
587
|
+
function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections) {
|
|
588
|
+
const { tier, detail, window, threshold } = verdict;
|
|
589
|
+
const flatline = tier === "progress_flatline";
|
|
487
590
|
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
591
|
+
const stalledReason = flatline
|
|
592
|
+
? `Progress has been ${detail} for ${window} consecutive rounds.`
|
|
593
|
+
: `Progress has stalled: ${detail} over the last ${window} rounds` +
|
|
594
|
+
`${threshold > 0 ? ` (delta < ${(threshold * 100).toFixed(0)}% each round)` : ""}.`;
|
|
595
|
+
if ((flatline && consecutiveRejections >= 1) || consecutiveRejections >= 2) {
|
|
492
596
|
return makeEnforcementResult({
|
|
493
597
|
action: "terminate",
|
|
494
|
-
reason:
|
|
495
|
-
|
|
496
|
-
`and agent did not resolve after escalation. Terminating loop.`,
|
|
497
|
-
check: "progress_stall",
|
|
598
|
+
reason: `${stalledReason} The stall persisted after escalation. Terminating loop.`,
|
|
599
|
+
check: tier,
|
|
498
600
|
});
|
|
499
601
|
}
|
|
500
|
-
if (consecutiveRejections >= 1 && escalation) {
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
}
|
|
602
|
+
if (consecutiveRejections >= 1 && !escalation) {
|
|
603
|
+
return makeEnforcementResult({
|
|
604
|
+
action: "terminate",
|
|
605
|
+
reason: `${stalledReason} The stall persisted after the previous rejection. Terminating loop.`,
|
|
606
|
+
check: tier,
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
const escalated = flatline ? escalation : consecutiveRejections >= 1 && escalation;
|
|
610
|
+
if (escalated && getPolicy().engine.backtrack_enabled) {
|
|
611
|
+
if (hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
518
612
|
return makeEnforcementResult({
|
|
519
|
-
action: "
|
|
520
|
-
reason:
|
|
521
|
-
|
|
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",
|
|
613
|
+
action: "terminate",
|
|
614
|
+
reason: `${stalledReason} A backtrack already fired for this round; the stall persists after rollback.`,
|
|
615
|
+
check: tier,
|
|
526
616
|
});
|
|
527
617
|
}
|
|
528
618
|
return makeEnforcementResult({
|
|
529
|
-
action: "
|
|
530
|
-
reason:
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
"
|
|
534
|
-
|
|
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",
|
|
619
|
+
action: "backtrack",
|
|
620
|
+
reason: stalledReason,
|
|
621
|
+
fix_instructions: flatline
|
|
622
|
+
? "Progress is exactly flat. After rollback, choose a radically different approach."
|
|
623
|
+
: "After rollback, do not repeat the stalled approach; choose a different technique or task decomposition.",
|
|
624
|
+
check: tier,
|
|
539
625
|
});
|
|
540
626
|
}
|
|
541
|
-
if (
|
|
627
|
+
if (flatline && !escalation) {
|
|
542
628
|
return makeEnforcementResult({
|
|
543
629
|
action: "terminate",
|
|
544
|
-
reason:
|
|
545
|
-
|
|
546
|
-
`and agent did not resolve after previous rejection. Terminating loop.`,
|
|
547
|
-
check: "progress_stall",
|
|
630
|
+
reason: `${stalledReason} The agent is making zero forward motion and cannot recover.`,
|
|
631
|
+
check: tier,
|
|
548
632
|
});
|
|
549
633
|
}
|
|
550
634
|
return makeEnforcementResult({
|
|
551
635
|
action: "reject",
|
|
552
|
-
reason:
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
"
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
"Do NOT repeat the same approach — it has not moved progress forward.",
|
|
559
|
-
check: "progress_stall",
|
|
636
|
+
reason: stalledReason,
|
|
637
|
+
fix_instructions: (flatline
|
|
638
|
+
? "Explain the fundamental blocker, choose a radically different approach, and set a concrete verifiable goal."
|
|
639
|
+
: "Explain the blocker, choose a different technique or task decomposition, and set a concrete verifiable goal for this retry.") +
|
|
640
|
+
(escalated ? buildEscalationNotice() : ""),
|
|
641
|
+
check: tier,
|
|
560
642
|
});
|
|
561
643
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
});
|
|
644
|
+
function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections) {
|
|
645
|
+
const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries);
|
|
646
|
+
return verdict
|
|
647
|
+
? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
|
|
648
|
+
: null;
|
|
649
|
+
}
|
|
650
|
+
function enforceProgressFlatline(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections) {
|
|
651
|
+
const verdict = evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries);
|
|
652
|
+
return verdict
|
|
653
|
+
? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
|
|
654
|
+
: null;
|
|
673
655
|
}
|
|
674
656
|
/** R6: Two consecutive rejections → escalate (v2.7) or terminate.
|
|
675
657
|
*
|
|
@@ -975,7 +957,9 @@ function buildDiagnosticGap(flags) {
|
|
|
975
957
|
/** Enforce round-boundary rules based on the verification gate's findings
|
|
976
958
|
* and the agent's self-evaluation integrity.
|
|
977
959
|
*
|
|
978
|
-
* Rules run in priority order
|
|
960
|
+
* Rules run in priority order — the rules array below IS the priority
|
|
961
|
+
* order; numeric IDs (R1–R9, R-EVID) reflect insertion history, not
|
|
962
|
+
* priority. The first rule that fires wins.
|
|
979
963
|
*
|
|
980
964
|
* @param selfEval The agent's self-evaluation for the current round.
|
|
981
965
|
* @param verifyResult The verification gate's output (from verifySelfEvaluation).
|
|
@@ -984,7 +968,7 @@ function buildDiagnosticGap(flags) {
|
|
|
984
968
|
* @param consecutiveRejections How many consecutive rounds have already been rejected.
|
|
985
969
|
* Starts at 0; increments on each reject; resets on accept.
|
|
986
970
|
*/
|
|
987
|
-
export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
|
|
971
|
+
export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
|
|
988
972
|
/** v2.12: Current clarification streak for R7 escalation. */
|
|
989
973
|
driftClarificationStreak = 0) {
|
|
990
974
|
const { flags } = verifyResult;
|
|
@@ -993,23 +977,77 @@ driftClarificationStreak = 0) {
|
|
|
993
977
|
const rules = [
|
|
994
978
|
() => enforceSuccessWithRemainingCriteria(flags),
|
|
995
979
|
() => enforceRecurringViolation(flags),
|
|
996
|
-
() => enforceEmptySuccess(selfEval, flags
|
|
980
|
+
() => enforceEmptySuccess(selfEval, flags),
|
|
997
981
|
// v2.14: R-EVID — a required command failure or self-contradictory
|
|
998
982
|
// outcome claim contradicts the success claim before commit
|
|
999
983
|
() => enforceEvidenceContradiction(flags),
|
|
984
|
+
// v3.3: R-EVID-VERIFY — the verification command entrypoint changed in
|
|
985
|
+
// the same round it ran; its result cannot back the success claim.
|
|
986
|
+
// Runs before R8: when the entrypoint is tainted, R8 also fires (the
|
|
987
|
+
// status degrades to unavailable), but this reason is more specific.
|
|
988
|
+
() => enforceVerificationEntrypointTampered(flags),
|
|
989
|
+
// v3.5: contract completion claimed without its verification_plan
|
|
990
|
+
// commands passing — runs before R-C1: the completion-truth question
|
|
991
|
+
// outranks boundary nuance (a completing eval that also lies about its
|
|
992
|
+
// evidence gets the completion reason first).
|
|
993
|
+
() => enforceContractCompletionUnverified(flags, consecutiveRejections),
|
|
994
|
+
// v3.3: R-C1 — contract boundary claimed prematurely (contract-specific
|
|
995
|
+
// wording and check counting; runs before R8 so contract rounds get the
|
|
996
|
+
// contract's own reason. R8 itself is untouched.)
|
|
997
|
+
() => enforcePrematureBoundary(flags, consecutiveRejections),
|
|
1000
998
|
// v2.12: R8 — success with zero machine-verifiable evidence
|
|
1001
999
|
() => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
|
|
1002
|
-
|
|
1003
|
-
() =>
|
|
1000
|
+
// v3.3: R-C2 — files changed outside the contract's declared scope
|
|
1001
|
+
() => enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries),
|
|
1002
|
+
// v3.3.1: R4 is evaluated first and its stall predicate contains R5's
|
|
1003
|
+
// flatness predicate — under the default ladder R4 proxies for flat runs
|
|
1004
|
+
// too (reject → backtrack/terminate). R5 stays as the reserved severity
|
|
1005
|
+
// tier (see its doc comment: active when progress_stall_threshold <= 0
|
|
1006
|
+
// or after a future window split).
|
|
1007
|
+
() => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
|
|
1008
|
+
() => enforceProgressFlatline(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
|
|
1004
1009
|
() => enforceMaxRejections(consecutiveRejections),
|
|
1005
1010
|
() => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
|
|
1006
1011
|
// v2.12: R9 — post-backtrack workspace not restored
|
|
1007
1012
|
() => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
|
|
1008
1013
|
];
|
|
1014
|
+
// v3.3.1: rules without an internal escalation ladder (R1/R2/R3/R-EVID/
|
|
1015
|
+
// R-EVID-VERIFY) reject on EVERY occurrence of their check, and the
|
|
1016
|
+
// generic R6 ladder sits AFTER them in this priority array — R6 is only
|
|
1017
|
+
// evaluated when no earlier rule fires, which a persistent offender's
|
|
1018
|
+
// round never is. The documented "same-check rejections accumulate toward
|
|
1019
|
+
// R6" (per-check counter the session maintains) was therefore unreachable
|
|
1020
|
+
// and a repeat offender was rejected forever without a terminating step.
|
|
1021
|
+
// Escalate these rules' rejects on the session's consecutive-rejection
|
|
1022
|
+
// count, mirroring the R8/R7 three-strike rhythm: reject, reject
|
|
1023
|
+
// (escalation notice), terminate on the third consecutive occurrence.
|
|
1024
|
+
// Rules with their own ladder (R4/R5/R6/R7/R8/R9/R-C1/R-C2) are untouched.
|
|
1025
|
+
const UNLADDERED_REJECT_CHECKS = new Set([
|
|
1026
|
+
"success_with_remaining_criteria", // R1
|
|
1027
|
+
"recurring_violation", // R2
|
|
1028
|
+
"empty_success", // R3
|
|
1029
|
+
"required_command_failed", // R-EVID
|
|
1030
|
+
"command_evidence_mismatch", // R-EVID
|
|
1031
|
+
"outcome_success_contradiction", // R-EVID
|
|
1032
|
+
"verification_entrypoint_modified", // R-EVID-VERIFY
|
|
1033
|
+
]);
|
|
1009
1034
|
for (const rule of rules) {
|
|
1010
1035
|
const result = rule();
|
|
1011
|
-
if (result)
|
|
1012
|
-
|
|
1036
|
+
if (!result)
|
|
1037
|
+
continue;
|
|
1038
|
+
if (result.action === "reject" &&
|
|
1039
|
+
consecutiveRejections >= 2 &&
|
|
1040
|
+
result.check !== undefined &&
|
|
1041
|
+
UNLADDERED_REJECT_CHECKS.has(result.check)) {
|
|
1042
|
+
return makeEnforcementResult({
|
|
1043
|
+
action: "terminate",
|
|
1044
|
+
reason: `${consecutiveRejections + 1} consecutive rejections for the same ` +
|
|
1045
|
+
`issue (${result.check}) without resolution — the agent cannot ` +
|
|
1046
|
+
`correct it even after escalation.`,
|
|
1047
|
+
check: result.check,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
return result;
|
|
1013
1051
|
}
|
|
1014
1052
|
// All rules passed — accept the round
|
|
1015
1053
|
return makeEnforcementResult({
|