loopforge 3.7.0 → 3.8.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/LICENSE +21 -21
- package/README.md +171 -157
- package/dist/audit.d.ts +53 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +106 -22
- package/dist/audit.js.map +1 -1
- package/dist/canonical-state.d.ts +75 -69
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +276 -168
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +111 -12
- package/dist/cli.js.map +1 -1
- package/dist/cognitive-facts.d.ts +13 -0
- package/dist/cognitive-facts.d.ts.map +1 -1
- package/dist/cognitive-facts.js +30 -16
- package/dist/cognitive-facts.js.map +1 -1
- package/dist/cognitive-governance.d.ts +30 -6
- package/dist/cognitive-governance.d.ts.map +1 -1
- package/dist/cognitive-governance.js +74 -18
- package/dist/cognitive-governance.js.map +1 -1
- package/dist/committed-round.d.ts +109 -24
- package/dist/committed-round.d.ts.map +1 -1
- package/dist/committed-round.js +235 -52
- package/dist/committed-round.js.map +1 -1
- package/dist/contract-items.d.ts +55 -0
- package/dist/contract-items.d.ts.map +1 -0
- package/dist/contract-items.js +180 -0
- package/dist/contract-items.js.map +1 -0
- package/dist/enforcement-gate.d.ts +43 -20
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +269 -378
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts +11 -6
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +102 -67
- package/dist/engine.js.map +1 -1
- package/dist/evidence-claims.d.ts +4 -4
- package/dist/evidence-claims.d.ts.map +1 -1
- package/dist/evidence-claims.js +16 -14
- package/dist/evidence-claims.js.map +1 -1
- package/dist/evidence-provider.d.ts +91 -90
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +349 -160
- package/dist/evidence-provider.js.map +1 -1
- package/dist/explain.d.ts +76 -0
- package/dist/explain.d.ts.map +1 -0
- package/dist/explain.js +127 -0
- package/dist/explain.js.map +1 -0
- package/dist/generate-schema.js +250 -53
- package/dist/generate-schema.js.map +1 -1
- package/dist/index.d.ts +8 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +41 -40
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +275 -515
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-extras-parser.d.ts +0 -1
- package/dist/loop-extras-parser.d.ts.map +1 -1
- package/dist/loop-extras-parser.js +0 -4
- package/dist/loop-extras-parser.js.map +1 -1
- package/dist/loop-projection.d.ts.map +1 -1
- package/dist/loop-projection.js +3 -1
- package/dist/loop-projection.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +40 -21
- package/dist/mcp/round-lifecycle.d.ts.map +1 -1
- package/dist/mcp/round-lifecycle.js +173 -120
- package/dist/mcp/round-lifecycle.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +63 -10
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +53 -7
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +240 -139
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/submission-boundary.d.ts +89 -0
- package/dist/mcp/submission-boundary.d.ts.map +1 -0
- package/dist/mcp/submission-boundary.js +203 -0
- package/dist/mcp/submission-boundary.js.map +1 -0
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +377 -125
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.js +2 -2
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +65 -95
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +151 -39
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +7 -33
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +313 -473
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/prompt-policy.d.ts +1 -4
- package/dist/prompt-policy.d.ts.map +1 -1
- package/dist/prompt-policy.js +6 -7
- package/dist/prompt-policy.js.map +1 -1
- package/dist/protocol.d.ts +411 -168
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +23 -69
- package/dist/protocol.js.map +1 -1
- package/dist/replay.js +2 -2
- package/dist/replay.js.map +1 -1
- package/dist/round-contract.d.ts +69 -57
- package/dist/round-contract.d.ts.map +1 -1
- package/dist/round-contract.js +104 -64
- package/dist/round-contract.js.map +1 -1
- package/dist/round-coordinator.d.ts +55 -16
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +179 -57
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +7 -5
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +2 -1
- package/dist/round-driver.js.map +1 -1
- package/dist/round-facts.d.ts +96 -0
- package/dist/round-facts.d.ts.map +1 -0
- package/dist/round-facts.js +216 -0
- package/dist/round-facts.js.map +1 -0
- package/dist/round-transaction.d.ts +23 -9
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +99 -22
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +127 -11
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +517 -36
- package/dist/self-eval.js.map +1 -1
- package/dist/subgoal-state.d.ts +77 -0
- package/dist/subgoal-state.d.ts.map +1 -0
- package/dist/subgoal-state.js +198 -0
- package/dist/subgoal-state.js.map +1 -0
- package/dist/token-utils.d.ts +52 -19
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +83 -72
- package/dist/token-utils.js.map +1 -1
- package/dist/verification-gate.d.ts +27 -46
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +308 -557
- package/dist/verification-gate.js.map +1 -1
- package/dist/version.d.ts +5 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +5 -0
- package/dist/version.js.map +1 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.d.ts.map +1 -1
- package/dist/workspace.js +17 -0
- package/dist/workspace.js.map +1 -1
- package/loop_policy.json +17 -25
- package/package.json +7 -7
- package/skills/perception/SKILL.md +266 -64
package/dist/enforcement-gate.js
CHANGED
|
@@ -17,11 +17,10 @@
|
|
|
17
17
|
* with stopReason "enforcement_terminated".
|
|
18
18
|
*/
|
|
19
19
|
import { makeEnforcementResult } from "./protocol.js";
|
|
20
|
-
import { machineProgressSeries, CHECK_SUCCESS_WITH_REMAINING_CRITERIA, CHECK_RECURRING_VIOLATION, CHECK_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,
|
|
21
|
-
import { deriveConstraintId, deriveCriterionId, deriveSubGoalId } from "./loop-compiler.js";
|
|
20
|
+
import { 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_ROUND_SCOPE_DRIFT, CHECK_CONTRACT_ITEMS_UNVERIFIED, CHECK_USER_GATE_UNRESOLVED } from "./verification-gate.js";
|
|
22
21
|
import { getPolicy } from "./policy.js";
|
|
23
|
-
import {
|
|
24
|
-
import {
|
|
22
|
+
import { isRecord, entryRound } from "./token-utils.js";
|
|
23
|
+
import { derivationRounds, decodeCommittedRound, machineEvidenceForRound, } from "./committed-round.js";
|
|
25
24
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
26
25
|
// Helpers
|
|
27
26
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -29,8 +28,8 @@ import { committedRoundsFromEntries, decodeCommittedRound, machineEvidenceForRou
|
|
|
29
28
|
* Shared by the progress evaluator's stall and flatline tiers. */
|
|
30
29
|
function collectProgressByRound(vaultEntries, currentRound) {
|
|
31
30
|
const progressByRound = new Map();
|
|
32
|
-
for (const round of
|
|
33
|
-
const progress = round.
|
|
31
|
+
for (const round of derivationRounds(vaultEntries, currentRound)) {
|
|
32
|
+
const progress = round.executionReport?.progress_estimate;
|
|
34
33
|
if (typeof progress === "number")
|
|
35
34
|
progressByRound.set(round.round, progress);
|
|
36
35
|
}
|
|
@@ -46,7 +45,7 @@ function collectProgressByRound(vaultEntries, currentRound) {
|
|
|
46
45
|
function progressWindow(vaultEntries, currentRound, selfEval) {
|
|
47
46
|
const progressByRound = collectProgressByRound(vaultEntries, currentRound);
|
|
48
47
|
if (hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
49
|
-
const pe = selfEval.
|
|
48
|
+
const pe = selfEval.execution_report?.progress_estimate;
|
|
50
49
|
if (typeof pe === "number")
|
|
51
50
|
progressByRound.set(currentRound, pe);
|
|
52
51
|
}
|
|
@@ -89,7 +88,7 @@ function isCleanRound(round) {
|
|
|
89
88
|
* Collects discovered_constraints from skipped rounds along the way.
|
|
90
89
|
* Returns null when no clean round is found within maxDepth. */
|
|
91
90
|
export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
|
|
92
|
-
const completed =
|
|
91
|
+
const completed = derivationRounds(vaultEntries, currentRound)
|
|
93
92
|
.sort((a, b) => b.round - a.round);
|
|
94
93
|
const skippedDiscoveries = [];
|
|
95
94
|
for (let depth = 1; depth <= maxDepth; depth++) {
|
|
@@ -124,13 +123,13 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
|
|
|
124
123
|
* evidence as fallback). Returns null when the round has no git snapshot
|
|
125
124
|
* or no committed feedback entry. */
|
|
126
125
|
export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
|
|
127
|
-
const committed =
|
|
126
|
+
const committed = derivationRounds(vaultEntries)
|
|
128
127
|
.find((view) => view.round === restoreRound);
|
|
129
128
|
if (!committed)
|
|
130
129
|
return null;
|
|
131
130
|
const evidence = machineEvidenceForRound(committed);
|
|
132
131
|
for (const item of evidence) {
|
|
133
|
-
if (!isRecord(item) || item.
|
|
132
|
+
if (!isRecord(item) || item.providerId !== "git" || !isRecord(item.data))
|
|
134
133
|
continue;
|
|
135
134
|
const head = item.data.head;
|
|
136
135
|
if (typeof head === "string" && head.length > 0)
|
|
@@ -138,22 +137,77 @@ export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
|
|
|
138
137
|
}
|
|
139
138
|
return null;
|
|
140
139
|
}
|
|
141
|
-
/**
|
|
140
|
+
/** v3.8.1: the ONE Recovery Brief renderer.
|
|
142
141
|
*
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
146
|
-
|
|
142
|
+
* The rollback prompt and the state file's Recent tier used to carry two
|
|
143
|
+
* hand-written copies of these lines — differing in punctuation
|
|
144
|
+
* ("the redo is round N" vs "redo round N") and in whether an approach
|
|
145
|
+
* overflow count was shown at all. Two renderers over one rollback meant the
|
|
146
|
+
* two surfaces could describe the same event differently while both claiming
|
|
147
|
+
* to be derived facts. The facts are supplied by the caller: the coordinator
|
|
148
|
+
* from its live walk, the compile path from `decodeBacktrackRecord` on the
|
|
149
|
+
* committed decision. */
|
|
150
|
+
export function recoveryBriefLines(input) {
|
|
151
|
+
const maxApproaches = input.maxApproaches ?? 6;
|
|
152
|
+
const maxAssumptions = input.maxAssumptions ?? 5;
|
|
153
|
+
const lines = [
|
|
154
|
+
`- **Trigger**: ${input.triggerRule || "unknown"}`,
|
|
155
|
+
`- **Restored to round**: ${input.target} (the redo is round ${input.target + 1})`,
|
|
156
|
+
];
|
|
157
|
+
if (input.recoveryRoundId) {
|
|
158
|
+
lines.push(`- **Recovery Round ID**: \`${input.recoveryRoundId}\``);
|
|
159
|
+
}
|
|
160
|
+
if (input.failedRounds.length > 0) {
|
|
161
|
+
lines.push(`- **Failed rounds**: ${input.failedRounds.join(", ")}`);
|
|
162
|
+
}
|
|
163
|
+
const shown = input.approaches.slice(0, maxApproaches);
|
|
164
|
+
for (const approach of shown) {
|
|
165
|
+
lines.push(` - Failed approach: ${approach}`);
|
|
166
|
+
}
|
|
167
|
+
if (input.approaches.length > shown.length) {
|
|
168
|
+
lines.push(` - … and ${input.approaches.length - shown.length} more failed approaches`);
|
|
169
|
+
}
|
|
170
|
+
if (input.wrongAssumptions.length > 0) {
|
|
171
|
+
lines.push("- **Falsified assumptions** (do not rebuild on these):");
|
|
172
|
+
for (const assumption of input.wrongAssumptions.slice(0, maxAssumptions)) {
|
|
173
|
+
lines.push(` - ${assumption}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return lines;
|
|
177
|
+
}
|
|
178
|
+
export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
|
|
147
179
|
/** v2.13: Files changed in the skipped rounds (from evidence snapshots).
|
|
148
180
|
* Used to show the agent exactly what needs to be reverted. */
|
|
149
|
-
skippedFiles = [],
|
|
181
|
+
skippedFiles = [],
|
|
150
182
|
/** v2.12: Git HEAD commit hash at the backtrack point (current HEAD).
|
|
151
183
|
* The agent must discard work back to the clean round's state. */
|
|
152
|
-
gitHead
|
|
184
|
+
gitHead,
|
|
185
|
+
/** v3.7.1: The redo submission's roundId (loop:<id>:round:<toRound+1>). */
|
|
186
|
+
recoveryRoundId,
|
|
187
|
+
/** v3.7.1: Derived Recovery Brief facts — committed facts + the
|
|
188
|
+
* in-flight attempt only; never rejected payloads. */
|
|
189
|
+
recovery) {
|
|
153
190
|
const lines = [
|
|
154
191
|
`## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
|
|
155
192
|
"",
|
|
156
193
|
];
|
|
194
|
+
// ── Recovery Brief (v3.7.1) ──────────────────────────────────────────
|
|
195
|
+
// A compact structured summary at the top of the rollback directive. The
|
|
196
|
+
// detailed sections below (workspace restore, discoveries, instructions)
|
|
197
|
+
// keep their existing wording; the brief exists so the agent cannot miss
|
|
198
|
+
// why it was rolled back, what failed, and what the redo round is.
|
|
199
|
+
if (recovery) {
|
|
200
|
+
lines.push("### Recovery Brief", "");
|
|
201
|
+
lines.push(...recoveryBriefLines({
|
|
202
|
+
target: toRound,
|
|
203
|
+
triggerRule,
|
|
204
|
+
failedRounds: recovery.failedRounds,
|
|
205
|
+
approaches: recovery.approaches,
|
|
206
|
+
wrongAssumptions: recovery.wrongAssumptions,
|
|
207
|
+
recoveryRoundId,
|
|
208
|
+
}));
|
|
209
|
+
lines.push("");
|
|
210
|
+
}
|
|
157
211
|
// ── Why This Happened ────────────────────────────────────────────────
|
|
158
212
|
// v3.7: stall and flatline are one evaluator emitting progress_stall; the
|
|
159
213
|
// former flatline-specific wording ("zero forward motion → radically
|
|
@@ -180,24 +234,33 @@ gitHead) {
|
|
|
180
234
|
}
|
|
181
235
|
lines.push("");
|
|
182
236
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
//
|
|
237
|
+
// v3.8: FACTS, not commands. LoopForge never mutates the working tree, and it
|
|
238
|
+
// does not prescribe how the agent restores it either — the restore is the
|
|
239
|
+
// AGENT's action, so the prompt states what must be true (the target HEAD and
|
|
240
|
+
// the files that must be reverted) and leaves the means entirely to the
|
|
241
|
+
// agent. No `git stash` / `git reset` / `git checkout` / `git clean` line
|
|
242
|
+
// belongs here.
|
|
188
243
|
if (gitHead) {
|
|
189
|
-
lines.push(
|
|
190
|
-
"
|
|
191
|
-
"
|
|
244
|
+
lines.push("**The workspace must be returned to the restore point before you " +
|
|
245
|
+
"continue:**", "", `- HEAD must be at \`${gitHead.slice(0, 12)}\``, `- Every file listed above must be back in its state at Round ${toRound}`, `- No leftover change from rounds ${toRound + 1}–${fromRound} may remain`, "", "Your next submission's git observation is compared against that HEAD, " +
|
|
246
|
+
"and a file whose fingerprint still matches the failed round proves the " +
|
|
247
|
+
"restore did not happen.", "");
|
|
192
248
|
}
|
|
193
|
-
|
|
194
|
-
|
|
249
|
+
else {
|
|
250
|
+
lines.push(`**The workspace must be returned to its state at Round ${toRound}** — ` +
|
|
251
|
+
"every file listed above reverted, with nothing left over from the " +
|
|
252
|
+
"failed rounds.", "");
|
|
253
|
+
}
|
|
254
|
+
lines.push("Restoring the workspace is **your** responsibility. LoopForge never " +
|
|
255
|
+
"modifies the working tree; it only records the restore requirement and " +
|
|
256
|
+
"checks the result.", "", `**If git is unavailable**, revert the files listed above by hand to ` +
|
|
257
|
+
`their state at Round ${toRound}. Re-read the state file below — ` +
|
|
195
258
|
`it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
|
|
196
259
|
"> your workspace no longer contains the failed changes. Submissions " +
|
|
197
260
|
"> with unrestored files will be **rejected**.", "");
|
|
198
261
|
// ── What Must Change ──────────────────────────────────────────────────
|
|
199
262
|
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 " +
|
|
200
|
-
"(`
|
|
263
|
+
"(`subgoal_updates` to canceled) and working on a different one.",
|
|
201
264
|
// v3.5: the restored Current Task may be the very Round Contract that
|
|
202
265
|
// stalled — the agent must be told the sanctioned way to revise it
|
|
203
266
|
// (outcome=blocked closes the active contract; the revised proposal in
|
|
@@ -226,7 +289,7 @@ gitHead) {
|
|
|
226
289
|
// Individual enforcement rules — each returns EnforcementResult | null
|
|
227
290
|
// Rules are ordered by priority. The first non-null result wins.
|
|
228
291
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
229
|
-
/** R1: Agent claims success but
|
|
292
|
+
/** R1: Agent claims success but declared criteria remain outstanding.
|
|
230
293
|
* This is a lie — the agent must either finish the criteria or
|
|
231
294
|
* set success=false honestly. Triggered by the verification gate's
|
|
232
295
|
* "success_with_remaining_criteria" error flag. */
|
|
@@ -333,118 +396,91 @@ function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
|
|
|
333
396
|
"test evidence and no test results were observed. Success must be backed " +
|
|
334
397
|
"by machine-verifiable evidence.",
|
|
335
398
|
fix_instructions: "Provide verifiable evidence for your success claim: (a) run tests and " +
|
|
336
|
-
"report results in
|
|
399
|
+
"report results in execution_report.tests_reported, or (b) configure a " +
|
|
337
400
|
"required verification command and run it, or (c) if no change was " +
|
|
338
401
|
"genuinely needed, declare no_change_reason explaining why." + escalation,
|
|
339
402
|
check: "success_without_verified_evidence",
|
|
340
403
|
});
|
|
341
404
|
}
|
|
342
|
-
/**
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
*
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
405
|
+
/** v3.8: Verification debt — contract items claimed met without machine
|
|
406
|
+
* verification. The round COMMITS (insufficient is recorded, not rejected);
|
|
407
|
+
* this row only fires once the debt has persisted for
|
|
408
|
+
* `engine.unverified_claim_streak_limit` consecutive rounds, then runs its
|
|
409
|
+
* OWN ladder: reject → terminate as `incomplete`.
|
|
410
|
+
*
|
|
411
|
+
* v3.8.1: the ladder lives here (RULE_TABLE marks the row "internal").
|
|
412
|
+
* Declaring it "uniform" as well ran the escalation twice — the notice was
|
|
413
|
+
* appended twice to one fix_instructions — and made the uniform terminate
|
|
414
|
+
* branch unreachable, while its bare `consecutiveRejections` read let a
|
|
415
|
+
* streak earned by a DIFFERENT check terminate this row on its FIRST
|
|
416
|
+
* occurrence, which is exactly what the L4 rule forbids.
|
|
417
|
+
*
|
|
418
|
+
* The streak is derived from COMMITTED round flags, so it survives restart
|
|
419
|
+
* and cannot be moved by an agent self-report. Git motion does not excuse
|
|
420
|
+
* it: churning code without verifying is exactly what this row catches. */
|
|
421
|
+
function enforceContractItemsUnverified(flags, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck) {
|
|
422
|
+
const limit = getPolicy().engine.unverified_claim_streak_limit;
|
|
423
|
+
if (!Number.isFinite(limit) || limit <= 0)
|
|
350
424
|
return null;
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
425
|
+
const current = flags.find((flag) => flag.check === CHECK_CONTRACT_ITEMS_UNVERIFIED);
|
|
426
|
+
if (!current)
|
|
427
|
+
return null;
|
|
428
|
+
let streak = 1;
|
|
429
|
+
for (let round = currentRound - 1; round >= 1; round--) {
|
|
430
|
+
const entry = vaultEntries.find((item) => entryRound(item) === round && String(item.task_id ?? "").endsWith(":feedback"));
|
|
431
|
+
const committed = entry ? decodeCommittedRound(entry) : null;
|
|
432
|
+
const carried = committed?.verificationFlags.some((flag) => flag.check === CHECK_CONTRACT_ITEMS_UNVERIFIED);
|
|
433
|
+
if (!carried)
|
|
434
|
+
break;
|
|
435
|
+
streak++;
|
|
358
436
|
}
|
|
359
|
-
|
|
360
|
-
consecutiveRejections >= 1
|
|
361
|
-
? buildEscalationNotice()
|
|
362
|
-
: "";
|
|
363
|
-
return makeEnforcementResult({
|
|
364
|
-
action: "reject",
|
|
365
|
-
reason: flag.detail,
|
|
366
|
-
fix_instructions: "You claimed success under a Round Contract but done_when items are " +
|
|
367
|
-
"unfinished or unverified. You must: (a) run the contract's " +
|
|
368
|
-
"verification_plan commands (configured in loop_policy.json " +
|
|
369
|
-
"evidence.commands) and report their actual output, listing satisfied " +
|
|
370
|
-
"done_when items in success_criteria_met; or (b) set success=false and " +
|
|
371
|
-
"list what remains in success_criteria_remaining. " +
|
|
372
|
-
"Do NOT claim contract completion without machine evidence " +
|
|
373
|
-
"(no_change_reason does not apply to contract claims)." + escalation,
|
|
374
|
-
check: "premature_boundary",
|
|
375
|
-
});
|
|
376
|
-
}
|
|
377
|
-
/** v3.5: Round Contract completion claimed without its verification_plan
|
|
378
|
-
* commands passing this round. The gate produced
|
|
379
|
-
* contract_completion_unverified (error): the eval's met claims satisfy
|
|
380
|
-
* every done_when of the ACTIVE contract, but the machine never observed
|
|
381
|
-
* the plan's commands pass. Closing a contract is a success-class claim —
|
|
382
|
-
* first occurrence → reject with fix instructions; second consecutive →
|
|
383
|
-
* terminate (R-C1's ladder). Registered BEFORE R-C1: the completion-truth
|
|
384
|
-
* question outranks boundary nuance. */
|
|
385
|
-
function enforceContractCompletionUnverified(flags, consecutiveRejections) {
|
|
386
|
-
const flag = flags.find((f) => f.check === CHECK_CONTRACT_COMPLETION_UNVERIFIED && f.severity === "error");
|
|
387
|
-
if (!flag)
|
|
437
|
+
if (streak < limit)
|
|
388
438
|
return null;
|
|
389
|
-
|
|
439
|
+
// The counter measures the PREVIOUS round's check (the coordinator resets it
|
|
440
|
+
// whenever the check changes), so it only speaks for this row when it names
|
|
441
|
+
// this row — same rule the uniform rows apply.
|
|
442
|
+
const ownRejections = lastRejectionCheck === "contract_items_unverified"
|
|
443
|
+
? consecutiveRejections
|
|
444
|
+
: 0;
|
|
445
|
+
if (ownRejections >= 2) {
|
|
390
446
|
return makeEnforcementResult({
|
|
391
447
|
action: "terminate",
|
|
392
|
-
reason:
|
|
393
|
-
|
|
394
|
-
check: "
|
|
448
|
+
reason: `Verification debt persisted for ${streak} rounds — contract items ` +
|
|
449
|
+
`claimed met while no bound command was observed passing.`,
|
|
450
|
+
check: "contract_items_unverified",
|
|
451
|
+
stopReason: "incomplete",
|
|
395
452
|
});
|
|
396
453
|
}
|
|
397
454
|
const escalation = getPolicy().engine.enforcement_escalation_enabled &&
|
|
398
|
-
|
|
455
|
+
ownRejections >= 1
|
|
399
456
|
? buildEscalationNotice()
|
|
400
457
|
: "";
|
|
401
458
|
return makeEnforcementResult({
|
|
402
459
|
action: "reject",
|
|
403
|
-
reason:
|
|
404
|
-
fix_instructions:
|
|
405
|
-
"
|
|
406
|
-
"
|
|
407
|
-
"
|
|
408
|
-
"
|
|
409
|
-
|
|
410
|
-
"Completion claims are never accepted without machine verification — " +
|
|
411
|
-
"do NOT use no_change_reason for completion claims." + escalation,
|
|
412
|
-
check: "contract_completion_unverified",
|
|
460
|
+
reason: current.detail,
|
|
461
|
+
fix_instructions: `Contract items have been claimed met for ${streak} consecutive rounds ` +
|
|
462
|
+
"without machine verification. You must either: (a) run the item's bound " +
|
|
463
|
+
"command(s) so they are observed passing, or (b) report the item as " +
|
|
464
|
+
"remaining / declare outcome: \"blocked\" with a blocker. " +
|
|
465
|
+
"An unverified claim is never accepted as completion." + escalation,
|
|
466
|
+
check: "contract_items_unverified",
|
|
413
467
|
});
|
|
414
468
|
}
|
|
415
469
|
/** R-C2 (v3.3): Round Contract scope drift. The gate produced
|
|
416
470
|
* round_scope_drift (warn): files changed outside the contract's declared
|
|
417
|
-
* scope.
|
|
418
|
-
* the
|
|
419
|
-
* scope
|
|
420
|
-
*
|
|
421
|
-
|
|
422
|
-
function enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries = []) {
|
|
471
|
+
* scope. v3.8: scope drift is a MACHINE fact and is no longer waivable by an
|
|
472
|
+
* agent explanation (the drift_clarification channel was deleted) — the
|
|
473
|
+
* agent must revert the out-of-scope changes or close the active contract
|
|
474
|
+
* and declare an extended scope. Repeated drift terminates. */
|
|
475
|
+
function enforceScopeDrift(flags, consecutiveRejections) {
|
|
423
476
|
const flag = flags.find((f) => f.check === CHECK_ROUND_SCOPE_DRIFT);
|
|
424
477
|
if (!flag)
|
|
425
478
|
return null;
|
|
426
|
-
const clarification = selfEval.drift_clarification?.trim();
|
|
427
|
-
const hasAnchor = clarification
|
|
428
|
-
? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
|
|
429
|
-
: false;
|
|
430
|
-
const isSubstantive = clarification !== undefined
|
|
431
|
-
&& clarification.length >= 20
|
|
432
|
-
&& hasAnchor;
|
|
433
|
-
if (isSubstantive) {
|
|
434
|
-
// Genuine scope expansion with concrete file references → accept.
|
|
435
|
-
return makeEnforcementResult({
|
|
436
|
-
action: "accept",
|
|
437
|
-
reason: "",
|
|
438
|
-
fix_instructions: "",
|
|
439
|
-
check: "round_scope_drift",
|
|
440
|
-
clarification_accepted: true,
|
|
441
|
-
});
|
|
442
|
-
}
|
|
443
479
|
if (consecutiveRejections >= 2) {
|
|
444
480
|
return makeEnforcementResult({
|
|
445
481
|
action: "terminate",
|
|
446
|
-
reason: `Repeated scope drift
|
|
447
|
-
`
|
|
482
|
+
reason: `Repeated scope drift — the agent keeps changing files outside its ` +
|
|
483
|
+
`declared contract scope.`,
|
|
448
484
|
check: "round_scope_drift",
|
|
449
485
|
});
|
|
450
486
|
}
|
|
@@ -455,12 +491,21 @@ function enforceScopeDrift(flags, selfEval, consecutiveRejections, vaultEntries
|
|
|
455
491
|
return makeEnforcementResult({
|
|
456
492
|
action: "reject",
|
|
457
493
|
reason: flag.detail,
|
|
494
|
+
// L3 (v3.7.x): option (b) previously told the agent to "extend the scope
|
|
495
|
+
// in your re-declared round_contract" — structurally unreachable: while a
|
|
496
|
+
// contract is ACTIVE, a replacement proposal is ignored as premature, so
|
|
497
|
+
// the guidance dead-ended into repeated rejects/termination. The only
|
|
498
|
+
// legal scope change is to CLOSE the active contract first: resubmit with
|
|
499
|
+
// outcome="blocked" (blocker naming the too-narrow scope) and the
|
|
500
|
+
// extended scope as the NEXT round's contract in the same submission.
|
|
458
501
|
fix_instructions: "Files changed outside the contract's declared scope. You must either: " +
|
|
459
|
-
"(a) revert the out-of-scope changes, or (b) extend the scope
|
|
460
|
-
"
|
|
461
|
-
"
|
|
462
|
-
"
|
|
463
|
-
"
|
|
502
|
+
"(a) revert the out-of-scope changes, or (b) legally extend the scope: " +
|
|
503
|
+
"a replacement contract is IGNORED while the current one is active, so " +
|
|
504
|
+
"resubmit this work with outcome: \"blocked\" (blocker naming the " +
|
|
505
|
+
"too-narrow scope) plus the extended scope as the next round's " +
|
|
506
|
+
"round_contract. Scope drift is a machine fact — no explanation waives " +
|
|
507
|
+
"it. Repeated scope drift terminates the loop." +
|
|
508
|
+
escalation,
|
|
464
509
|
check: "round_scope_drift",
|
|
465
510
|
});
|
|
466
511
|
}
|
|
@@ -489,27 +534,57 @@ function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0)
|
|
|
489
534
|
return makeEnforcementResult({
|
|
490
535
|
action: "backtrack",
|
|
491
536
|
reason: "The workspace was not restored after backtrack: " + flag.detail,
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
"submitting the next round
|
|
537
|
+
// v3.8: the requirement, not the recipe — LoopForge never mutates the
|
|
538
|
+
// working tree and does not prescribe how the agent restores it.
|
|
539
|
+
fix_instructions: "Return the working directory to the clean round's state before " +
|
|
540
|
+
"submitting the next round: the skipped-round files must be reverted " +
|
|
541
|
+
"and HEAD must be back at the restore point the backtrack prompt named.",
|
|
496
542
|
check: "backtrack_workspace_not_restored",
|
|
497
543
|
});
|
|
498
544
|
}
|
|
499
545
|
/** Shared observation pipeline for the progress evaluator (stall window,
|
|
500
546
|
* flatline tier). Machine git motion is exculpatory only: it may veto a
|
|
501
547
|
* self-reported stall but can never create one. */
|
|
502
|
-
function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries
|
|
548
|
+
function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries,
|
|
549
|
+
/** v3.8.1: the round's success is machine-verified (`deriveEvidenceStatus`).
|
|
550
|
+
* A verified round is finishing work; a motion window cannot outvote it. */
|
|
551
|
+
machineBackedSuccess) {
|
|
552
|
+
// A round the machine verified is not churn, whatever the window says. This
|
|
553
|
+
// is the machine-backed replacement for the old self-reported `>= 0.95`
|
|
554
|
+
// near-completion guard: it lets a loop FINISH (a closing round that only
|
|
555
|
+
// runs the verification and declares success changes no files).
|
|
556
|
+
if (machineBackedSuccess)
|
|
557
|
+
return null;
|
|
503
558
|
const window = tier === "progress_stall"
|
|
504
559
|
? 3
|
|
505
560
|
: Math.max(1, getPolicy().engine.stall_lookback_rounds);
|
|
506
561
|
if (currentRound < window)
|
|
507
562
|
return null;
|
|
508
563
|
const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
|
|
564
|
+
const threshold = getPolicy().evolution.progress_stall_threshold;
|
|
509
565
|
let stalled = false;
|
|
510
566
|
let detail = "";
|
|
511
|
-
|
|
512
|
-
|
|
567
|
+
// v3.8.1: when the MACHINE series is observable it decides the verdict, and
|
|
568
|
+
// the agent's own estimates are not consulted at all. Reading the estimates
|
|
569
|
+
// first (the pre-3.8.1 order) let an agent cancel a stall by writing a
|
|
570
|
+
// rising `progress_estimate` every round — a verdict about machine motion
|
|
571
|
+
// that the agent's own report could move, which is the one thing an external
|
|
572
|
+
// verifier must not allow. Machine motion still only ever EXCUSES: it can
|
|
573
|
+
// veto a stall it did not create; it can never create one.
|
|
574
|
+
//
|
|
575
|
+
// The self-reported window is the FALLBACK for a loop with no machine
|
|
576
|
+
// history to read (no git provider, or fewer rounds than the window). There
|
|
577
|
+
// the evaluator can only catch a stall the agent's own reports state, and
|
|
578
|
+
// the near-completion guard keeps it from rejecting a loop that reports
|
|
579
|
+
// itself nearly done.
|
|
580
|
+
const machine = machineProgressSeries(vaultEntries, currentRound, window);
|
|
581
|
+
if (machine !== null) {
|
|
582
|
+
stalled = machine.every((value) => !value);
|
|
583
|
+
detail = tier === "progress_stall"
|
|
584
|
+
? `no machine-observed changes in the last ${window} rounds`
|
|
585
|
+
: `no machine-observed changes for ${window} consecutive rounds`;
|
|
586
|
+
}
|
|
587
|
+
else if (progressByRound.size >= window) {
|
|
513
588
|
const recent = [...progressByRound.keys()].sort((a, b) => a - b).slice(-window);
|
|
514
589
|
if (recent[0] < currentRound - window)
|
|
515
590
|
return null;
|
|
@@ -518,7 +593,8 @@ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
|
|
|
518
593
|
return null;
|
|
519
594
|
if (tier === "progress_stall") {
|
|
520
595
|
stalled = values.slice(1).every((value, index) => value - values[index] < threshold);
|
|
521
|
-
detail = values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ")
|
|
596
|
+
detail = `${values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ")}` +
|
|
597
|
+
" (agent-reported, no machine history to read)";
|
|
522
598
|
}
|
|
523
599
|
else {
|
|
524
600
|
const first = values[0];
|
|
@@ -527,22 +603,10 @@ function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries) {
|
|
|
527
603
|
}
|
|
528
604
|
}
|
|
529
605
|
else {
|
|
530
|
-
|
|
531
|
-
if (machine === null)
|
|
532
|
-
return null;
|
|
533
|
-
stalled = machine.every((value) => !value);
|
|
534
|
-
detail = tier === "progress_stall"
|
|
535
|
-
? `no machine-observed changes in the last ${window} rounds`
|
|
536
|
-
: `no machine-observed changes for ${window} consecutive rounds`;
|
|
606
|
+
return null;
|
|
537
607
|
}
|
|
538
608
|
if (!stalled)
|
|
539
609
|
return null;
|
|
540
|
-
const machine = machineProgressSeries(vaultEntries, currentRound, window);
|
|
541
|
-
if (machine?.some(Boolean))
|
|
542
|
-
return null;
|
|
543
|
-
if (machine !== null) {
|
|
544
|
-
detail += ` — no machine-observed git motion in rounds ${currentRound - window}–${currentRound - 1}`;
|
|
545
|
-
}
|
|
546
610
|
return { tier, detail, window, threshold };
|
|
547
611
|
}
|
|
548
612
|
/** Shared progress escalation and backtrack deadlock guard (v3.7: one
|
|
@@ -616,9 +680,9 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
|
|
|
616
680
|
* shorter lookback configs), where it applies its steeper disposition
|
|
617
681
|
* ladder. Machine git motion is exculpatory only — it may veto a
|
|
618
682
|
* self-reported stall but can never create one. */
|
|
619
|
-
function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections) {
|
|
620
|
-
const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries)
|
|
621
|
-
?? evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries);
|
|
683
|
+
function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, machineBackedSuccess) {
|
|
684
|
+
const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries, machineBackedSuccess)
|
|
685
|
+
?? evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries, machineBackedSuccess);
|
|
622
686
|
return verdict
|
|
623
687
|
? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
|
|
624
688
|
: null;
|
|
@@ -666,212 +730,6 @@ function enforceMaxRejections(consecutiveRejections) {
|
|
|
666
730
|
check: "max_rejections",
|
|
667
731
|
});
|
|
668
732
|
}
|
|
669
|
-
/** v2.12: Check if a drift_clarification contains at least one semantic
|
|
670
|
-
* anchor — a constraint/criterion/sub-goal ID (v2.11) or a file-path-like
|
|
671
|
-
* reference. This distinguishes genuine explanations from filler text that
|
|
672
|
-
* just happens to be ≥ 20 characters. */
|
|
673
|
-
/** Extract anchor candidates from a clarification: c-/cr-/sg- IDs and
|
|
674
|
-
* file-path-like tokens. */
|
|
675
|
-
function extractAnchors(clarification) {
|
|
676
|
-
const anchors = new Set();
|
|
677
|
-
for (const match of clarification.matchAll(/[c]r?-[a-f0-9]{8}|sg-[a-f0-9]{8}/gi)) {
|
|
678
|
-
anchors.add(match[0].toLowerCase());
|
|
679
|
-
}
|
|
680
|
-
for (const path of extractFilePathTokens(clarification)) {
|
|
681
|
-
anchors.add(path);
|
|
682
|
-
}
|
|
683
|
-
return [...anchors];
|
|
684
|
-
}
|
|
685
|
-
/** v2.12: A drift clarification is substantive only when at least one anchor
|
|
686
|
-
* is REAL — a stable ID that derives from a known constraint/criterion/
|
|
687
|
-
* sub-goal text in the vault, or a file path present in reported/observed
|
|
688
|
-
* files. Regex patterns alone (e.g. a fabricated `c-00000000`) no longer
|
|
689
|
-
* count, closing the v2.12 filler-text loophole. */
|
|
690
|
-
function clarificationIsSubstantive(clarification, selfEval, vaultEntries) {
|
|
691
|
-
const anchors = extractAnchors(clarification);
|
|
692
|
-
if (anchors.length === 0)
|
|
693
|
-
return false;
|
|
694
|
-
const knownFiles = new Set();
|
|
695
|
-
const knownTexts = new Set();
|
|
696
|
-
const collect = (values) => {
|
|
697
|
-
if (!Array.isArray(values))
|
|
698
|
-
return;
|
|
699
|
-
for (const value of values) {
|
|
700
|
-
if (typeof value === "string" && value.length > 0)
|
|
701
|
-
knownTexts.add(value);
|
|
702
|
-
}
|
|
703
|
-
};
|
|
704
|
-
// The agent's own round report is a known set: its constraints, criteria,
|
|
705
|
-
// and files are legitimate anchor targets.
|
|
706
|
-
for (const file of selfEval.execution_evidence?.files_changed ?? [])
|
|
707
|
-
knownFiles.add(file);
|
|
708
|
-
collect(selfEval.discovered_constraints);
|
|
709
|
-
collect(selfEval.constraint_violations);
|
|
710
|
-
const selfEvidence = selfEval.execution_evidence;
|
|
711
|
-
if (selfEvidence) {
|
|
712
|
-
collect(selfEvidence.success_criteria_met);
|
|
713
|
-
collect(selfEvidence.success_criteria_remaining);
|
|
714
|
-
}
|
|
715
|
-
for (const entry of vaultEntries) {
|
|
716
|
-
collect(entry.constraint_violations);
|
|
717
|
-
collect(entry.discovered_constraints);
|
|
718
|
-
const ev = entry.execution_evidence;
|
|
719
|
-
if (ev) {
|
|
720
|
-
collect(ev.files_changed);
|
|
721
|
-
collect(ev.success_criteria_met);
|
|
722
|
-
collect(ev.success_criteria_remaining);
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
for (const anchor of anchors) {
|
|
726
|
-
if (STABLE_ID_RE.test(anchor)) {
|
|
727
|
-
const matches = anchor.startsWith("c-") && !anchor.startsWith("cr-")
|
|
728
|
-
? [...knownTexts].some((text) => deriveConstraintId(text) === anchor)
|
|
729
|
-
: anchor.startsWith("cr-")
|
|
730
|
-
? [...knownTexts].some((text) => deriveCriterionId(text) === anchor)
|
|
731
|
-
: [...knownTexts].some((text) => deriveSubGoalId(text) === anchor);
|
|
732
|
-
if (matches)
|
|
733
|
-
return true;
|
|
734
|
-
continue;
|
|
735
|
-
}
|
|
736
|
-
// File path: exact or suffix match against known files.
|
|
737
|
-
if ([...knownFiles].some((file) => file === anchor || file.endsWith(`/${anchor}`) || file.endsWith(`\\${anchor}`))) {
|
|
738
|
-
return true;
|
|
739
|
-
}
|
|
740
|
-
}
|
|
741
|
-
return false;
|
|
742
|
-
}
|
|
743
|
-
/** Build a stronger rejection notice as the clarification streak grows. */
|
|
744
|
-
function buildClarificationRejectionNotice(streak) {
|
|
745
|
-
const base = "Your declared next_action from the previous round does not match " +
|
|
746
|
-
"what you actually did this round. You must either: " +
|
|
747
|
-
"(a) explain why you pivoted in the drift_clarification field, " +
|
|
748
|
-
"mentioning specific constraint/criterion/sub-goal IDs or file paths " +
|
|
749
|
-
"that prompted the change, or " +
|
|
750
|
-
"(b) redo this round and do what you said you would do.";
|
|
751
|
-
if (streak >= 2) {
|
|
752
|
-
return (base +
|
|
753
|
-
"\n\n### ⚠️ Repeated Weak Clarification\n\n" +
|
|
754
|
-
`You have submitted drift_clarification without concrete references ` +
|
|
755
|
-
`for ${streak} consecutive rounds. Your explanation MUST include at ` +
|
|
756
|
-
`least one of: a constraint ID (c-XXXXXXXX), criterion ID (cr-XXXXXXXX), ` +
|
|
757
|
-
`sub-goal ID (sg-XXXXXXXX), or a file path you changed.\n\n` +
|
|
758
|
-
`🚨 If the next clarification also lacks concrete anchors, the loop ` +
|
|
759
|
-
`will be terminated.`);
|
|
760
|
-
}
|
|
761
|
-
return base;
|
|
762
|
-
}
|
|
763
|
-
/** R7: Agent declared a next_action but did something unrelated.
|
|
764
|
-
*
|
|
765
|
-
* v2.8: When the agent provides a substantive drift_clarification (≥ 20
|
|
766
|
-
* characters), the rejection is waived — the agent has acknowledged and
|
|
767
|
-
* explained the pivot. An empty or absent clarification still triggers
|
|
768
|
-
* the normal reject/terminate path.
|
|
769
|
-
*
|
|
770
|
-
* v2.12: Clarification now requires a semantic anchor (constraint/criterion/
|
|
771
|
-
* sub-goal ID or file path) in addition to the ≥ 20 char minimum. Weak
|
|
772
|
-
* clarifications (no anchors) increment a streak counter; 3 consecutive
|
|
773
|
-
* weak clarifications terminate the loop.
|
|
774
|
-
*
|
|
775
|
-
* First occurrence → reject (agent must explain the gap or realign).
|
|
776
|
-
* Second consecutive → terminate (agent cannot/will not follow its own plan). */
|
|
777
|
-
function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0, vaultEntries = []) {
|
|
778
|
-
const flag = flags.find((f) => f.check === "intent_drift");
|
|
779
|
-
if (!flag)
|
|
780
|
-
return null;
|
|
781
|
-
const clarification = selfEval.drift_clarification?.trim();
|
|
782
|
-
// v2.12: Substantive anchors — the referenced ID/path must actually exist
|
|
783
|
-
// in the vault or in reported files; a fabricated ID no longer counts.
|
|
784
|
-
const hasAnchor = clarification
|
|
785
|
-
? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
|
|
786
|
-
: false;
|
|
787
|
-
// v2.12: Substantive clarification = length check + semantic anchor
|
|
788
|
-
const isSubstantive = clarification
|
|
789
|
-
&& clarification.length >= 20
|
|
790
|
-
&& hasAnchor;
|
|
791
|
-
if (isSubstantive) {
|
|
792
|
-
// Genuine explanation with concrete references → accept the pivot
|
|
793
|
-
return makeEnforcementResult({
|
|
794
|
-
action: "accept",
|
|
795
|
-
reason: "",
|
|
796
|
-
fix_instructions: "",
|
|
797
|
-
check: "intent_drift",
|
|
798
|
-
clarification_accepted: true,
|
|
799
|
-
});
|
|
800
|
-
}
|
|
801
|
-
// v2.12: Clarification present but weak (no semantic anchors)
|
|
802
|
-
if (clarification && clarification.length >= 20 && !hasAnchor) {
|
|
803
|
-
const maxStreak = getPolicy().engine.drift_clarification_max_streak;
|
|
804
|
-
// When maxStreak is 0, disable the check (pre-v2.12 behavior)
|
|
805
|
-
if (maxStreak <= 0) {
|
|
806
|
-
return makeEnforcementResult({
|
|
807
|
-
action: "accept",
|
|
808
|
-
reason: "",
|
|
809
|
-
fix_instructions: "",
|
|
810
|
-
check: "intent_drift",
|
|
811
|
-
clarification_accepted: true,
|
|
812
|
-
});
|
|
813
|
-
}
|
|
814
|
-
const newStreak = driftClarificationStreak + 1;
|
|
815
|
-
if (newStreak >= maxStreak) {
|
|
816
|
-
return makeEnforcementResult({
|
|
817
|
-
action: "terminate",
|
|
818
|
-
reason: `Agent has submitted weak drift_clarification (no concrete IDs or ` +
|
|
819
|
-
`file references) for ${newStreak} consecutive rounds. The agent ` +
|
|
820
|
-
`systematically avoids following its own plan without substantive ` +
|
|
821
|
-
`explanation.`,
|
|
822
|
-
check: "intent_drift",
|
|
823
|
-
});
|
|
824
|
-
}
|
|
825
|
-
return makeEnforcementResult({
|
|
826
|
-
action: "reject",
|
|
827
|
-
reason: flag.detail,
|
|
828
|
-
fix_instructions: buildClarificationRejectionNotice(newStreak),
|
|
829
|
-
check: "intent_drift",
|
|
830
|
-
});
|
|
831
|
-
}
|
|
832
|
-
// No clarification or too short — the worst form of drift. v2.14: counts
|
|
833
|
-
// toward the SAME streak as weak clarifications (maxStreak consecutive
|
|
834
|
-
// un-explained drifts terminate, per the README's "three consecutive weak
|
|
835
|
-
// clarifications"). Previously this branch used the GLOBAL
|
|
836
|
-
// consecutiveRejections counter, so an unrelated earlier rejection
|
|
837
|
-
// (e.g. R1) terminated the loop on the very first drift.
|
|
838
|
-
const maxStreak = getPolicy().engine.drift_clarification_max_streak;
|
|
839
|
-
// When maxStreak is 0, disable the check (pre-v2.12 behavior)
|
|
840
|
-
if (maxStreak <= 0) {
|
|
841
|
-
return makeEnforcementResult({
|
|
842
|
-
action: "accept",
|
|
843
|
-
reason: "",
|
|
844
|
-
fix_instructions: "",
|
|
845
|
-
check: "intent_drift",
|
|
846
|
-
clarification_accepted: true,
|
|
847
|
-
});
|
|
848
|
-
}
|
|
849
|
-
const newStreak = driftClarificationStreak + 1;
|
|
850
|
-
if (newStreak >= maxStreak) {
|
|
851
|
-
return makeEnforcementResult({
|
|
852
|
-
action: "terminate",
|
|
853
|
-
reason: `Agent has drifted from its declared next_action for ${newStreak} ` +
|
|
854
|
-
`consecutive rounds without any drift_clarification. The agent ` +
|
|
855
|
-
`cannot or will not follow its own plan — terminating loop.`,
|
|
856
|
-
check: "intent_drift",
|
|
857
|
-
});
|
|
858
|
-
}
|
|
859
|
-
return makeEnforcementResult({
|
|
860
|
-
action: "reject",
|
|
861
|
-
reason: flag.detail,
|
|
862
|
-
fix_instructions: "Your declared next_action from the previous round does not match " +
|
|
863
|
-
"what you actually did this round. You must either: " +
|
|
864
|
-
"(a) explain why you pivoted and what you learned in the " +
|
|
865
|
-
"drift_clarification field, or " +
|
|
866
|
-
"(b) redo this round and do what you said you would do. " +
|
|
867
|
-
"If you intentionally changed direction, update your objective_refinement " +
|
|
868
|
-
"and set a new next_action that reflects the new plan.",
|
|
869
|
-
check: "intent_drift",
|
|
870
|
-
});
|
|
871
|
-
}
|
|
872
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
873
|
-
// Escalation notice builder (v2.7)
|
|
874
|
-
// ═══════════════════════════════════════════════════════════════════════════
|
|
875
733
|
/** Build an escalation notice urging the agent to seek human guidance.
|
|
876
734
|
*
|
|
877
735
|
* Appended to fix_instructions when a rule would otherwise terminate.
|
|
@@ -922,22 +780,21 @@ function buildDiagnosticGap(flags) {
|
|
|
922
780
|
return lines.join("\n");
|
|
923
781
|
}
|
|
924
782
|
const RULE_TABLE = [
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
{
|
|
930
|
-
{
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
{
|
|
935
|
-
{
|
|
936
|
-
{
|
|
937
|
-
{
|
|
938
|
-
|
|
939
|
-
{
|
|
940
|
-
{ category: "progress_recovery", checks: ["backtrack_workspace_not_restored"], ladder: "internal", backtrackAllowed: true },
|
|
783
|
+
{ checks: ["success_with_remaining_criteria"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
|
|
784
|
+
{ checks: ["recurring_violation"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
|
|
785
|
+
{ checks: ["required_command_failed", "command_evidence_mismatch", "outcome_success_contradiction"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
|
|
786
|
+
{ checks: ["verification_entrypoint_modified"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
|
|
787
|
+
{ checks: ["success_without_verified_evidence"], ladder: "internal" },
|
|
788
|
+
{ checks: ["round_scope_drift"], ladder: "internal" },
|
|
789
|
+
// v3.8.1: "internal" — the handler owns this row's ladder (it must terminate
|
|
790
|
+
// as `incomplete`, with its own reason). Declaring it uniform as well ran the
|
|
791
|
+
// escalation notice twice and left the uniform terminate branch dead.
|
|
792
|
+
{ checks: ["contract_items_unverified"], ladder: "internal" },
|
|
793
|
+
{ checks: ["progress_stall"], ladder: "internal" },
|
|
794
|
+
{ checks: ["max_rejections"], ladder: "counter" },
|
|
795
|
+
{ checks: ["backtrack_workspace_not_restored"], ladder: "internal" },
|
|
796
|
+
// v3.7.1: cited gates without an approved human decision
|
|
797
|
+
{ checks: ["user_gate_unresolved"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
|
|
941
798
|
];
|
|
942
799
|
const RULE_TABLE_BY_ID = new Map();
|
|
943
800
|
for (const row of RULE_TABLE) {
|
|
@@ -961,9 +818,33 @@ for (const row of RULE_TABLE) {
|
|
|
961
818
|
* @param consecutiveRejections How many consecutive rounds have already been rejected.
|
|
962
819
|
* Starts at 0; increments on each reject; resets on accept.
|
|
963
820
|
*/
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
821
|
+
/** v3.7.1: user_gate_unresolved — the round cites gates without an
|
|
822
|
+
* approved human decision. Reject; the uniform ladder escalates repeats
|
|
823
|
+
* (the agent should pause and obtain approval — never resubmit a claim of
|
|
824
|
+
* completed high-risk work). */
|
|
825
|
+
function enforceUserGateUnresolved(flags) {
|
|
826
|
+
const flag = flags.find((f) => f.check === CHECK_USER_GATE_UNRESOLVED && f.severity === "error");
|
|
827
|
+
if (!flag)
|
|
828
|
+
return null;
|
|
829
|
+
return makeEnforcementResult({
|
|
830
|
+
action: "reject",
|
|
831
|
+
reason: flag.detail,
|
|
832
|
+
fix_instructions: "The round depends on a high-risk action with no approved human " +
|
|
833
|
+
"decision. Run loopforge_gate_check with the action, present the " +
|
|
834
|
+
"approval question, and call loopforge_gate_resolve only after the " +
|
|
835
|
+
"human decides. Then resubmit with the approved gate id in " +
|
|
836
|
+
"evaluation.gate_ids. Do NOT claim the action is done while it is " +
|
|
837
|
+
"unapproved — safe preparation work never needs a gate.",
|
|
838
|
+
check: CHECK_USER_GATE_UNRESOLVED,
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
|
|
842
|
+
/** L4 (v3.7.x): which check rejected the PREVIOUS round. The coordinator
|
|
843
|
+
* resets consecutiveRejections to 1 whenever the check changes, so the
|
|
844
|
+
* counter only ever measures one check's streak — this field names that
|
|
845
|
+
* check and lets uniform rows escalate on THEIR OWN streak instead of
|
|
846
|
+
* inheriting an unrelated history. */
|
|
847
|
+
lastRejectionCheck = "") {
|
|
967
848
|
const { flags } = verifyResult;
|
|
968
849
|
// Run enforcement rules in priority order — the array order IS the
|
|
969
850
|
// priority; the R-labels in the comments are historical (RULE_TABLE is
|
|
@@ -981,25 +862,25 @@ driftClarificationStreak = 0) {
|
|
|
981
862
|
// v3.5: contract completion claimed without its verification_plan
|
|
982
863
|
// commands passing — runs before R-C1: the completion-truth question
|
|
983
864
|
// outranks boundary nuance.
|
|
984
|
-
() => enforceContractCompletionUnverified(flags, consecutiveRejections),
|
|
985
865
|
// v3.3: R-C1 — contract boundary claimed prematurely (contract-specific
|
|
986
866
|
// wording and check counting; runs before R8 so contract rounds get the
|
|
987
867
|
// contract's own reason.)
|
|
988
|
-
() => enforcePrematureBoundary(flags, consecutiveRejections),
|
|
989
868
|
// v2.12/v3.7: R8 — the single success-evidence row (empty/missing-evidence
|
|
990
869
|
// arm merged from the former R3 empty_success posture + claims arm)
|
|
991
870
|
() => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
|
|
992
871
|
// v3.3: R-C2 — files changed outside the contract's declared scope
|
|
993
|
-
() => enforceScopeDrift(flags,
|
|
872
|
+
() => enforceScopeDrift(flags, consecutiveRejections),
|
|
873
|
+
// v3.8: verification debt — contract items claimed met without machine
|
|
874
|
+
// verification (fires only after the configured streak)
|
|
875
|
+
() => enforceContractItemsUnverified(flags, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck),
|
|
994
876
|
// v3.7: single progress evaluator (former R4/R5 slots merged) —
|
|
995
877
|
// reject → backtrack → terminate with the deadlock guard
|
|
996
|
-
() => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections),
|
|
878
|
+
() => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, verifyResult.machineBackedSuccess),
|
|
997
879
|
// R6: rejection-counter catch-all (evaluated only when no row above
|
|
998
880
|
// fired — a clean-looking round carrying a high persisted counter)
|
|
881
|
+
// v3.7.1: cited gate without an approved human decision
|
|
882
|
+
() => enforceUserGateUnresolved(flags),
|
|
999
883
|
() => enforceMaxRejections(consecutiveRejections),
|
|
1000
|
-
// v2.12: R7 — intent drift; its weak-clarification streak is a separate
|
|
1001
|
-
// counter and never mixes with the global rejection count
|
|
1002
|
-
() => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
|
|
1003
884
|
// v2.12: R9 — post-backtrack workspace not restored
|
|
1004
885
|
() => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
|
|
1005
886
|
];
|
|
@@ -1020,16 +901,26 @@ driftClarificationStreak = 0) {
|
|
|
1020
901
|
? RULE_TABLE_BY_ID.get(result.check)
|
|
1021
902
|
: undefined;
|
|
1022
903
|
if (result.action === "reject" && row && row.ladder === "uniform") {
|
|
1023
|
-
|
|
904
|
+
// L4: uniform rows escalate on their OWN consecutive streak only.
|
|
905
|
+
// consecutiveRejections is the streak of the PREVIOUS round's check
|
|
906
|
+
// (the coordinator resets it to 1 whenever the check changes) — a
|
|
907
|
+
// streak earned by a DIFFERENT check must not convert this first
|
|
908
|
+
// occurrence into termination. The user_gate row is the sharp case:
|
|
909
|
+
// its recovery is human approval, so a gate cited after unrelated
|
|
910
|
+
// rejections must reject with instructions, not kill the loop.
|
|
911
|
+
const ownStreak = lastRejectionCheck === result.check
|
|
912
|
+
? consecutiveRejections
|
|
913
|
+
: 0;
|
|
914
|
+
if (ownStreak >= (row.terminalAfter ?? 2)) {
|
|
1024
915
|
return makeEnforcementResult({
|
|
1025
916
|
action: "terminate",
|
|
1026
|
-
reason: `${
|
|
917
|
+
reason: `${ownStreak + 1} consecutive rejections for the same ` +
|
|
1027
918
|
`issue (${result.check}) without resolution — the agent cannot ` +
|
|
1028
919
|
`correct it even after escalation.`,
|
|
1029
920
|
check: result.check,
|
|
1030
921
|
});
|
|
1031
922
|
}
|
|
1032
|
-
if (
|
|
923
|
+
if (ownStreak >= 1 && row.noticeOnRepeat &&
|
|
1033
924
|
getPolicy().engine.enforcement_escalation_enabled) {
|
|
1034
925
|
result = makeEnforcementResult({
|
|
1035
926
|
...result,
|