loopforge 3.0.0 → 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 +126 -143
- package/dist/audit.d.ts +50 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +131 -0
- package/dist/audit.js.map +1 -0
- package/dist/canonical-state.d.ts +98 -23
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +257 -181
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +40 -33
- 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/cognitive-governance.d.ts +25 -33
- package/dist/cognitive-governance.d.ts.map +1 -1
- package/dist/cognitive-governance.js +38 -138
- package/dist/cognitive-governance.js.map +1 -1
- 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 +78 -0
- package/dist/enforcement-gate.d.ts.map +1 -0
- package/dist/enforcement-gate.js +1109 -0
- package/dist/enforcement-gate.js.map +1 -0
- package/dist/engine.d.ts +111 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +887 -0
- package/dist/engine.js.map +1 -0
- package/dist/evidence-claims.d.ts +50 -0
- package/dist/evidence-claims.d.ts.map +1 -0
- package/dist/evidence-claims.js +116 -0
- package/dist/evidence-claims.js.map +1 -0
- package/dist/evidence-provider.d.ts +50 -23
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +167 -50
- package/dist/evidence-provider.js.map +1 -1
- package/dist/generate-schema.js +1 -84
- package/dist/generate-schema.js.map +1 -1
- package/dist/index.d.ts +19 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +16 -15
- package/dist/index.js.map +1 -1
- package/dist/loop-compiler.d.ts +58 -3
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +841 -304
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-extras-parser.d.ts +2 -2
- package/dist/loop-extras-parser.d.ts.map +1 -1
- package/dist/loop-extras-parser.js +2 -2
- package/dist/loop-extras-parser.js.map +1 -1
- package/dist/loop-projection.d.ts +6 -0
- package/dist/loop-projection.d.ts.map +1 -0
- package/dist/loop-projection.js +26 -0
- package/dist/loop-projection.js.map +1 -0
- package/dist/loop-store.d.ts +115 -24
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js +365 -218
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +214 -0
- package/dist/mcp/round-lifecycle.d.ts.map +1 -0
- package/dist/mcp/round-lifecycle.js +935 -0
- package/dist/mcp/round-lifecycle.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +29 -43
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +64 -101
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +433 -1110
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts +20 -11
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +587 -413
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +12 -24
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +78 -184
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +61 -50
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +48 -146
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts +37 -6
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +487 -278
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/prompt-policy.d.ts +1 -8
- package/dist/prompt-policy.d.ts.map +1 -1
- package/dist/prompt-policy.js +8 -26
- package/dist/prompt-policy.js.map +1 -1
- package/dist/protocol.d.ts +398 -236
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +147 -17
- package/dist/protocol.js.map +1 -1
- package/dist/replay.d.ts +5 -13
- package/dist/replay.d.ts.map +1 -1
- package/dist/replay.js +32 -69
- 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 +23 -25
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +103 -92
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +13 -13
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +25 -14
- package/dist/round-driver.js.map +1 -1
- package/dist/round-transaction.d.ts +14 -18
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +37 -47
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +48 -0
- package/dist/self-eval.d.ts.map +1 -0
- package/dist/self-eval.js +248 -0
- package/dist/self-eval.js.map +1 -0
- package/dist/storage.d.ts +9 -4
- package/dist/storage.d.ts.map +1 -1
- package/dist/storage.js +18 -6
- package/dist/storage.js.map +1 -1
- package/dist/token-utils.d.ts +15 -3
- package/dist/token-utils.d.ts.map +1 -1
- package/dist/token-utils.js +24 -5
- package/dist/token-utils.js.map +1 -1
- package/dist/verification-gate.d.ts +149 -0
- package/dist/verification-gate.d.ts.map +1 -0
- package/dist/verification-gate.js +1343 -0
- package/dist/verification-gate.js.map +1 -0
- 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 +37 -16
- package/package.json +5 -10
- package/skills/perception/SKILL.md +184 -202
- package/dist/canonical-event-writer.d.ts +0 -20
- package/dist/canonical-event-writer.d.ts.map +0 -1
- package/dist/canonical-event-writer.js +0 -131
- package/dist/canonical-event-writer.js.map +0 -1
- package/dist/compiler-subgoals.d.ts +0 -7
- package/dist/compiler-subgoals.d.ts.map +0 -1
- package/dist/compiler-subgoals.js +0 -93
- package/dist/compiler-subgoals.js.map +0 -1
- package/dist/derive-id.d.ts +0 -12
- package/dist/derive-id.d.ts.map +0 -1
- package/dist/derive-id.js +0 -31
- package/dist/derive-id.js.map +0 -1
- package/dist/mcp/session-projection.d.ts +0 -5
- package/dist/mcp/session-projection.d.ts.map +0 -1
- package/dist/mcp/session-projection.js +0 -18
- package/dist/mcp/session-projection.js.map +0 -1
- package/dist/mcp/session-queue.d.ts +0 -3
- package/dist/mcp/session-queue.d.ts.map +0 -1
- package/dist/mcp/session-queue.js +0 -18
- package/dist/mcp/session-queue.js.map +0 -1
- package/dist/mcp/session-types.d.ts +0 -72
- package/dist/mcp/session-types.d.ts.map +0 -1
- package/dist/mcp/session-types.js +0 -2
- package/dist/mcp/session-types.js.map +0 -1
- package/dist/round-commit-store.d.ts +0 -12
- package/dist/round-commit-store.d.ts.map +0 -1
- package/dist/round-commit-store.js +0 -13
- package/dist/round-commit-store.js.map +0 -1
- package/dist/round-gate.d.ts +0 -31
- package/dist/round-gate.d.ts.map +0 -1
- package/dist/round-gate.js +0 -259
- package/dist/round-gate.js.map +0 -1
- package/dist/round-recovery.d.ts +0 -17
- package/dist/round-recovery.d.ts.map +0 -1
- package/dist/round-recovery.js +0 -117
- package/dist/round-recovery.js.map +0 -1
- package/dist/round-report.d.ts +0 -16
- package/dist/round-report.d.ts.map +0 -1
- package/dist/round-report.js +0 -401
- package/dist/round-report.js.map +0 -1
- package/dist/runtime-services.d.ts +0 -59
- package/dist/runtime-services.d.ts.map +0 -1
- package/dist/runtime-services.js +0 -460
- package/dist/runtime-services.js.map +0 -1
- package/dist/vault-format.d.ts +0 -4
- package/dist/vault-format.d.ts.map +0 -1
- package/dist/vault-format.js +0 -4
- package/dist/vault-format.js.map +0 -1
- package/dist/vault-types.d.ts +0 -24
- package/dist/vault-types.d.ts.map +0 -1
- package/dist/vault-types.js +0 -2
- package/dist/vault-types.js.map +0 -1
- package/loopforge-policy.json +0 -340
|
@@ -0,0 +1,1109 @@
|
|
|
1
|
+
/** Enforcement Gate — Layer 2 round-boundary runtime enforcement (v1.13).
|
|
2
|
+
*
|
|
3
|
+
* Pure-function module. Receives the verification gate's findings plus
|
|
4
|
+
* round-level context and decides whether to accept the round, reject it
|
|
5
|
+
* (force the agent to redo), or terminate the loop.
|
|
6
|
+
*
|
|
7
|
+
* This is the "runtime" that prompt-only constraint systems lack.
|
|
8
|
+
* Verification gate detects WHAT is wrong; enforcement gate decides
|
|
9
|
+
* what to DO about it.
|
|
10
|
+
*
|
|
11
|
+
* Decision semantics:
|
|
12
|
+
* - accept: round passes all checks; advance to next round as normal.
|
|
13
|
+
* - reject: agent's self-evaluation or round output is invalid; the
|
|
14
|
+
* agent receives a rejection prompt and must redo the SAME
|
|
15
|
+
* round. Round counter does NOT increment.
|
|
16
|
+
* - terminate: loop has reached an unrecoverable state; stop immediately
|
|
17
|
+
* with stopReason "enforcement_terminated".
|
|
18
|
+
*/
|
|
19
|
+
import { makeEnforcementResult } from "./protocol.js";
|
|
20
|
+
import { entryRound, machineProgressSeries, CHECK_SUCCESS_WITH_REMAINING_CRITERIA, CHECK_RECURRING_VIOLATION, CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE, CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED, CHECK_REQUIRED_COMMAND_FAILED, CHECK_COMMAND_EVIDENCE_MISMATCH, CHECK_OUTCOME_SUCCESS_CONTRADICTION, CHECK_VERIFICATION_ENTRYPOINT_MODIFIED, CHECK_PREMATURE_BOUNDARY, CHECK_ROUND_SCOPE_DRIFT, CHECK_CONTRACT_COMPLETION_UNVERIFIED } from "./verification-gate.js";
|
|
21
|
+
import { effectiveSuccess } from "./self-eval.js";
|
|
22
|
+
import { deriveConstraintId, deriveCriterionId, deriveSubGoalId } from "./loop-compiler.js";
|
|
23
|
+
import { getPolicy } from "./policy.js";
|
|
24
|
+
import { STABLE_ID_RE, isRecord } from "./token-utils.js";
|
|
25
|
+
import { committedRoundsFromEntries, decodeCommittedRound, } from "./committed-round.js";
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
+
// Helpers
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
/** Collect progress estimates from vault entries into a round→estimate map.
|
|
30
|
+
* Shared by R4 (progress_stall) and R5 (progress_flatline). */
|
|
31
|
+
function collectProgressByRound(vaultEntries, currentRound) {
|
|
32
|
+
const progressByRound = new Map();
|
|
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);
|
|
37
|
+
}
|
|
38
|
+
return progressByRound;
|
|
39
|
+
}
|
|
40
|
+
/** v3.2.1: R4/R5 progress window — after a backtrack committed for this
|
|
41
|
+
* round, the agent's redo submission is the freshest data point. The
|
|
42
|
+
* pre-rollback history (which triggered the stall) stays in the vault and
|
|
43
|
+
* the redo does not commit while it is being evaluated, so without this
|
|
44
|
+
* the window never changes: a genuinely fixed redo is rejected forever and
|
|
45
|
+
* the loop dies in a spurious terminate. A redo that is STILL stalled
|
|
46
|
+
* keeps the window flat and hits the normal reject → terminate ladder. */
|
|
47
|
+
function progressWindow(vaultEntries, currentRound, selfEval) {
|
|
48
|
+
const progressByRound = collectProgressByRound(vaultEntries, currentRound);
|
|
49
|
+
if (hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
50
|
+
const pe = selfEval.execution_evidence?.progress_estimate;
|
|
51
|
+
if (typeof pe === "number")
|
|
52
|
+
progressByRound.set(currentRound, pe);
|
|
53
|
+
}
|
|
54
|
+
return progressByRound;
|
|
55
|
+
}
|
|
56
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
57
|
+
// v2.10: Backtrack helpers
|
|
58
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
59
|
+
/** Extract the committed round decision's result from a vault entry.
|
|
60
|
+
* The round commit entry (`loop:<id>:r<N>:feedback`) carries the full
|
|
61
|
+
* decision under loop_lineage.round_transaction; lineage entries carry
|
|
62
|
+
* nothing at the top level. */
|
|
63
|
+
function committedRoundResult(entry) {
|
|
64
|
+
return decodeCommittedRound(entry)?.result ?? null;
|
|
65
|
+
}
|
|
66
|
+
/** True when a backtrack already committed at or after the current round —
|
|
67
|
+
* i.e. the loop is re-walking territory the system already rolled back
|
|
68
|
+
* once. Guards against the reject↔backtrack deadlock on persistent stalls. */
|
|
69
|
+
function hasCommittedBacktrack(vaultEntries, currentRound) {
|
|
70
|
+
return vaultEntries.some((entry) => {
|
|
71
|
+
if (entryRound(entry) < currentRound)
|
|
72
|
+
return false;
|
|
73
|
+
return committedRoundResult(entry)?.action === "backtrack";
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Check whether a committed round represents a clean restore point.
|
|
77
|
+
* as a safe restore point. A clean round was accepted (not rejected)
|
|
78
|
+
* and had no error-level verification flags.
|
|
79
|
+
*
|
|
80
|
+
* v3.2.1: judged by the COMMITTED decision and gate flags, not by the
|
|
81
|
+
* agent's self-reported success. A progress-stall round commits with
|
|
82
|
+
* success=false but is accepted and carries no error flags — it is a
|
|
83
|
+
* valid backtrack target (the most common backtrack trigger is exactly a
|
|
84
|
+
* run of such rounds). Rejected/terminated rounds never commit, and
|
|
85
|
+
* rolled-back rounds are excluded below. */
|
|
86
|
+
function isCleanRound(round) {
|
|
87
|
+
return !round.verificationFlags.some((flag) => flag.severity === "error");
|
|
88
|
+
}
|
|
89
|
+
/** Scan backwards from currentRound to find the most recent clean round.
|
|
90
|
+
* Collects discovered_constraints from skipped rounds along the way.
|
|
91
|
+
* Returns null when no clean round is found within maxDepth. */
|
|
92
|
+
export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
|
|
93
|
+
const completed = committedRoundsFromEntries(vaultEntries, currentRound)
|
|
94
|
+
.sort((a, b) => b.round - a.round);
|
|
95
|
+
const skippedDiscoveries = [];
|
|
96
|
+
for (let depth = 1; depth <= maxDepth; depth++) {
|
|
97
|
+
const targetRound = currentRound - depth;
|
|
98
|
+
if (targetRound < 1)
|
|
99
|
+
break;
|
|
100
|
+
const round = completed.find((item) => item.round === targetRound);
|
|
101
|
+
if (!round)
|
|
102
|
+
continue;
|
|
103
|
+
// Collect discoveries from rounds we're about to skip (depth > 1)
|
|
104
|
+
if (depth > 1) {
|
|
105
|
+
for (let d = 1; d < depth; d++) {
|
|
106
|
+
const skippedRound = currentRound - d;
|
|
107
|
+
const skippedEntry = completed.find((item) => item.round === skippedRound);
|
|
108
|
+
if (skippedEntry?.evaluation) {
|
|
109
|
+
const discovered = skippedEntry.evaluation.discovered_constraints ?? [];
|
|
110
|
+
for (const item of discovered) {
|
|
111
|
+
if (!skippedDiscoveries.includes(item))
|
|
112
|
+
skippedDiscoveries.push(item);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (isCleanRound(round)) {
|
|
118
|
+
return { round: targetRound, skippedDiscoveries };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
/** v2.12: Git HEAD commit hash at the restore point. Read from the restore
|
|
124
|
+
* round's committed transaction snapshot (after evidence preferred, before
|
|
125
|
+
* evidence as fallback). Returns null when the round has no git snapshot
|
|
126
|
+
* or no committed feedback entry. */
|
|
127
|
+
export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
|
|
128
|
+
const committed = committedRoundsFromEntries(vaultEntries)
|
|
129
|
+
.find((view) => view.round === restoreRound);
|
|
130
|
+
if (!committed)
|
|
131
|
+
return null;
|
|
132
|
+
const evidence = committed.afterEvidence.length > 0
|
|
133
|
+
? committed.afterEvidence
|
|
134
|
+
: committed.beforeEvidence;
|
|
135
|
+
for (const item of evidence) {
|
|
136
|
+
if (!isRecord(item) || item.provider !== "git" || !isRecord(item.data))
|
|
137
|
+
continue;
|
|
138
|
+
const head = item.data.head;
|
|
139
|
+
if (typeof head === "string" && head.length > 0)
|
|
140
|
+
return head;
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
/** Build the backtrack prompt injected at the top of the restored round.
|
|
145
|
+
*
|
|
146
|
+
* v2.13: Includes concrete workspace restore instructions with affected
|
|
147
|
+
* file lists from skipped rounds. The agent must restore the working tree
|
|
148
|
+
* to the clean round's state before proceeding. */
|
|
149
|
+
export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
|
|
150
|
+
/** v2.13: Files changed in the skipped rounds (from evidence snapshots).
|
|
151
|
+
* Used to show the agent exactly what needs to be reverted. */
|
|
152
|
+
skippedFiles = [],
|
|
153
|
+
/** v2.12: Git HEAD commit hash at the backtrack point (current HEAD).
|
|
154
|
+
* The agent must discard work back to the clean round's state. */
|
|
155
|
+
gitHead) {
|
|
156
|
+
const lines = [
|
|
157
|
+
`## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
|
|
158
|
+
"",
|
|
159
|
+
];
|
|
160
|
+
// ── Why This Happened ────────────────────────────────────────────────
|
|
161
|
+
if (triggerRule === "progress_stall") {
|
|
162
|
+
lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}. ` +
|
|
163
|
+
`The work done in those rounds produced no verifiable progress — ` +
|
|
164
|
+
`no new files changed, no criteria met.`, "", "The approach used in those rounds **did not work**. It should not be repeated.", "");
|
|
165
|
+
}
|
|
166
|
+
else if (triggerRule === "progress_flatline") {
|
|
167
|
+
lines.push("### Why This Happened", "", `Progress was completely flat for multiple rounds leading up to Round ${fromRound}. ` +
|
|
168
|
+
`The agent made **zero forward motion** — the task is not advancing.`, "", "A **radically different** strategy is needed. The previous approach produced nothing.", "");
|
|
169
|
+
}
|
|
170
|
+
// ── v2.13: Workspace Restore ──────────────────────────────────────────
|
|
171
|
+
lines.push("### ⚠️ Workspace Restore Required", "", `Your working directory still contains changes from the **failed** ` +
|
|
172
|
+
`rounds (${toRound + 1}–${fromRound}). You MUST discard those changes ` +
|
|
173
|
+
`before starting Round ${toRound + 1}. Working on top of stale changes ` +
|
|
174
|
+
`will cause the verification gate to reject your next submission.`, "");
|
|
175
|
+
if (skippedFiles.length > 0) {
|
|
176
|
+
lines.push("**Files modified in skipped rounds (must be reverted):**", "");
|
|
177
|
+
for (const f of skippedFiles.slice(0, 15)) {
|
|
178
|
+
lines.push(`- \`${f}\``);
|
|
179
|
+
}
|
|
180
|
+
if (skippedFiles.length > 15) {
|
|
181
|
+
lines.push(`- … and ${skippedFiles.length - 15} more files`);
|
|
182
|
+
}
|
|
183
|
+
lines.push("");
|
|
184
|
+
}
|
|
185
|
+
lines.push("**To restore a clean workspace, run:**", "", "```bash", "# Option A: Discard all uncommitted changes (recommended)", "git checkout -- .", "git clean -fd", "", "# Option B: If you have unrelated work to keep", `git stash push -m "backtrack-safety-net-round-${fromRound}"`, "git checkout -- .", "git clean -fd", "```", "");
|
|
186
|
+
// v2.12: Restore to the exact commit observed at the clean round.
|
|
187
|
+
if (gitHead) {
|
|
188
|
+
lines.push(`**Restore to commit \`${gitHead.slice(0, 12)}\`** — the verification ` +
|
|
189
|
+
"gate checks that your working tree returns to this commit before " +
|
|
190
|
+
"accepting your next submission.", "", "```bash", "# Option C: If the failed rounds created commits, reset HEAD to the restore point", `git reset --hard ${gitHead.slice(0, 12)}`, "```", "", "> Option C discards the failed rounds' commits — use it only when those", "> commits belong to the failed work.", "");
|
|
191
|
+
}
|
|
192
|
+
lines.push(`**If git is unavailable**, manually revert the files listed above ` +
|
|
193
|
+
`to their state at Round ${toRound}. Re-read the state file below — ` +
|
|
194
|
+
`it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
|
|
195
|
+
"> your workspace no longer contains the failed changes. Submissions " +
|
|
196
|
+
"> with unrestored files will be **rejected**.", "");
|
|
197
|
+
// ── What Must Change ──────────────────────────────────────────────────
|
|
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 " +
|
|
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 " +
|
|
209
|
+
"to flag anything you don't understand about the task or the blockage.", "");
|
|
210
|
+
// ── Preserved Discoveries ─────────────────────────────────────────────
|
|
211
|
+
if (skippedDiscoveries.length > 0) {
|
|
212
|
+
lines.push("### Preserved Discoveries", "", "These were discovered in the skipped rounds and remain valid:", "");
|
|
213
|
+
for (const d of skippedDiscoveries) {
|
|
214
|
+
lines.push(`- ${d}`);
|
|
215
|
+
}
|
|
216
|
+
lines.push("");
|
|
217
|
+
}
|
|
218
|
+
// ── Instructions ──────────────────────────────────────────────────────
|
|
219
|
+
lines.push("### Instructions", "", `You have been rolled back to Round ${toRound}. ` +
|
|
220
|
+
`You are now starting **Round ${toRound + 1}** with the lessons above.`, "", "1. **First**: restore your workspace using the commands above.", "2. Read the restored state below — it reflects Round " + toRound + ".", "3. The discoveries from skipped rounds (if any) have been merged " +
|
|
221
|
+
"into the active constraints.", "4. Do NOT retrace the steps that led to the stall. Choose a new path.", "");
|
|
222
|
+
return lines.join("\n");
|
|
223
|
+
}
|
|
224
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
225
|
+
// Individual enforcement rules — each returns EnforcementResult | null
|
|
226
|
+
// Rules are ordered by priority. The first non-null result wins.
|
|
227
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
228
|
+
/** R1: Agent claims success but success_criteria_remaining has items.
|
|
229
|
+
* This is a lie — the agent must either finish the criteria or
|
|
230
|
+
* set success=false honestly. Triggered by the verification gate's
|
|
231
|
+
* "success_with_remaining_criteria" error flag. */
|
|
232
|
+
function enforceSuccessWithRemainingCriteria(flags) {
|
|
233
|
+
const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITH_REMAINING_CRITERIA && f.severity === "error");
|
|
234
|
+
if (!flag)
|
|
235
|
+
return null;
|
|
236
|
+
return makeEnforcementResult({
|
|
237
|
+
action: "reject",
|
|
238
|
+
reason: flag.detail,
|
|
239
|
+
fix_instructions: "You set success=true but success criteria remain unmet. " +
|
|
240
|
+
"Either: (a) complete the remaining criteria and re-submit your self-evaluation, " +
|
|
241
|
+
"or (b) set success=false and honestly report what remains to be done.",
|
|
242
|
+
check: "success_with_remaining_criteria",
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
/** R2: Same constraint violation appears in 3 consecutive rounds.
|
|
246
|
+
* The agent is repeating the same mistake. Triggered by the verification
|
|
247
|
+
* gate's "recurring_violation" error flag. */
|
|
248
|
+
function enforceRecurringViolation(flags) {
|
|
249
|
+
const flag = flags.find((f) => f.check === CHECK_RECURRING_VIOLATION && f.severity === "error");
|
|
250
|
+
if (!flag)
|
|
251
|
+
return null;
|
|
252
|
+
return makeEnforcementResult({
|
|
253
|
+
action: "reject",
|
|
254
|
+
reason: flag.detail,
|
|
255
|
+
fix_instructions: "The same constraint violation has appeared in 3 consecutive rounds. " +
|
|
256
|
+
"You must: (a) explain WHY this violation keeps occurring, " +
|
|
257
|
+
"and (b) propose a DIFFERENT approach than what you used in the last 3 rounds. " +
|
|
258
|
+
"Do NOT retry the same strategy — it has failed 3 times.",
|
|
259
|
+
check: "recurring_violation",
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
/** R-EVID (v2.14): A required command failure, a test-results mismatch that
|
|
263
|
+
* hides failures, or a self-contradictory outcome claim contradicts the
|
|
264
|
+
* success claim before commit. The verification gate detects the
|
|
265
|
+
* contradiction; this rule decides what to do about it — reject so the
|
|
266
|
+
* agent redos the SAME round with honest claims. Same-check rejections
|
|
267
|
+
* accumulate toward R6 via the per-check counter. */
|
|
268
|
+
function enforceEvidenceContradiction(flags) {
|
|
269
|
+
const contradiction = flags.find((f) => f.severity === "error" &&
|
|
270
|
+
(f.check === CHECK_REQUIRED_COMMAND_FAILED ||
|
|
271
|
+
f.check === CHECK_COMMAND_EVIDENCE_MISMATCH ||
|
|
272
|
+
f.check === CHECK_OUTCOME_SUCCESS_CONTRADICTION));
|
|
273
|
+
if (!contradiction)
|
|
274
|
+
return null;
|
|
275
|
+
return makeEnforcementResult({
|
|
276
|
+
action: "reject",
|
|
277
|
+
reason: contradiction.detail,
|
|
278
|
+
fix_instructions: "A required verification command failed or your claims contradict " +
|
|
279
|
+
"the evidence. You must: (a) run the required command and report its " +
|
|
280
|
+
"actual output, (b) reconcile your success claim with the evidence — " +
|
|
281
|
+
"do NOT report success when the evidence contradicts it, and " +
|
|
282
|
+
"(c) resubmit an honest SelfEvaluation for the SAME round.",
|
|
283
|
+
check: contradiction.check,
|
|
284
|
+
});
|
|
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
|
+
}
|
|
308
|
+
/** R3: Agent claims success but did nothing verifiable.
|
|
309
|
+
* v1.17: execution_evidence is now MANDATORY for structured self-evaluations.
|
|
310
|
+
* Missing evidence when success=true → reject (agent must provide evidence).
|
|
311
|
+
* Empty evidence (no files + no tests) when success=true → reject. */
|
|
312
|
+
function enforceEmptySuccess(selfEval, _flags) {
|
|
313
|
+
if (!effectiveSuccess(selfEval))
|
|
314
|
+
return null;
|
|
315
|
+
const ev = selfEval.execution_evidence;
|
|
316
|
+
// v1.17: Missing evidence when claiming success → reject.
|
|
317
|
+
// The agent MUST provide execution_evidence to back up a success claim.
|
|
318
|
+
if (!ev) {
|
|
319
|
+
return makeEnforcementResult({
|
|
320
|
+
action: "reject",
|
|
321
|
+
reason: "Agent claims success but provided no execution_evidence. " +
|
|
322
|
+
"Every successful round MUST include execution_evidence with " +
|
|
323
|
+
"files_changed, test_results, and progress_estimate.",
|
|
324
|
+
fix_instructions: "You must provide execution_evidence in your self-evaluation: " +
|
|
325
|
+
"(a) list the files you changed in execution_evidence.files_changed, " +
|
|
326
|
+
"(b) run tests and report results in execution_evidence.test_results, " +
|
|
327
|
+
"(c) estimate your progress in execution_evidence.progress_estimate. " +
|
|
328
|
+
"If you genuinely completed the task without file changes or tests, " +
|
|
329
|
+
"explain why in detail in your output_summary.",
|
|
330
|
+
check: "empty_success",
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
const filesEmpty = ev.files_changed.length === 0;
|
|
334
|
+
const testsNotRun = ev.test_results === null;
|
|
335
|
+
if (!filesEmpty || !testsNotRun)
|
|
336
|
+
return null;
|
|
337
|
+
return makeEnforcementResult({
|
|
338
|
+
action: "reject",
|
|
339
|
+
reason: "Agent claims success but execution_evidence shows no files changed " +
|
|
340
|
+
"and no tests were run. There is no verifiable evidence of work.",
|
|
341
|
+
fix_instructions: "You must provide verifiable evidence: " +
|
|
342
|
+
"(a) list the files you changed in execution_evidence.files_changed, " +
|
|
343
|
+
"and (b) run tests and report results in execution_evidence.test_results. " +
|
|
344
|
+
"If you genuinely completed the task without file changes or tests, " +
|
|
345
|
+
"explain why in detail in your output_summary.",
|
|
346
|
+
check: "empty_success",
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
/** R8 (v2.12): Success claimed without any machine-verifiable evidence.
|
|
350
|
+
* The verification gate produced success_without_verified_evidence (error):
|
|
351
|
+
* zero verified claims, no test results. First occurrence → reject with
|
|
352
|
+
* concrete evidence requirements; second consecutive → terminate. */
|
|
353
|
+
function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
|
|
354
|
+
const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE && f.severity === "error");
|
|
355
|
+
if (!flag)
|
|
356
|
+
return null;
|
|
357
|
+
if (consecutiveRejections >= 2) {
|
|
358
|
+
return makeEnforcementResult({
|
|
359
|
+
action: "terminate",
|
|
360
|
+
reason: "Repeated success claims with zero machine-verifiable evidence.",
|
|
361
|
+
check: "success_without_verified_evidence",
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled &&
|
|
365
|
+
consecutiveRejections >= 1
|
|
366
|
+
? buildEscalationNotice()
|
|
367
|
+
: "";
|
|
368
|
+
return makeEnforcementResult({
|
|
369
|
+
action: "reject",
|
|
370
|
+
reason: "Agent claims success but the runtime verified zero claims: no passing " +
|
|
371
|
+
"test evidence and no test results were observed. Success must be backed " +
|
|
372
|
+
"by machine-verifiable evidence.",
|
|
373
|
+
fix_instructions: "Provide verifiable evidence for your success claim: (a) run tests and " +
|
|
374
|
+
"report results in execution_evidence.test_results, or (b) configure a " +
|
|
375
|
+
"required verification command and run it, or (c) if no change was " +
|
|
376
|
+
"genuinely needed, declare no_change_reason explaining why." + escalation,
|
|
377
|
+
check: "success_without_verified_evidence",
|
|
378
|
+
});
|
|
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
|
+
}
|
|
505
|
+
/** R9 (v2.12): Post-backtrack workspace not restored. The verification gate
|
|
506
|
+
* produced backtrack_workspace_not_restored (error): either skipped-round
|
|
507
|
+
* files are still dirty, or the working tree is not at the restore point's
|
|
508
|
+
* git HEAD. The agent must restore the workspace, not redo the round. */
|
|
509
|
+
function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0) {
|
|
510
|
+
const flag = flags.find((f) => f.check === CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED && f.severity === "error");
|
|
511
|
+
if (!flag)
|
|
512
|
+
return null;
|
|
513
|
+
if (!getPolicy().engine.backtrack_enabled)
|
|
514
|
+
return null;
|
|
515
|
+
// v2.14: same deadlock guard as R4/R5 — a backtrack that already fired
|
|
516
|
+
// for this round means a non-restoring agent would otherwise cycle
|
|
517
|
+
// R9-backtrack forever (backtracks do not increment the rejection
|
|
518
|
+
// counter). One rollback chance, then terminate.
|
|
519
|
+
if (currentRound > 0 && hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
520
|
+
return makeEnforcementResult({
|
|
521
|
+
action: "terminate",
|
|
522
|
+
reason: "The workspace is still not restored after the previous backtrack: " +
|
|
523
|
+
flag.detail,
|
|
524
|
+
check: "backtrack_workspace_not_restored",
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
return makeEnforcementResult({
|
|
528
|
+
action: "backtrack",
|
|
529
|
+
reason: "The workspace was not restored after backtrack: " + flag.detail,
|
|
530
|
+
fix_instructions: "Restore the working directory to the clean round's state " +
|
|
531
|
+
"(git checkout -- . && git clean -fd, or git stash; if the failed " +
|
|
532
|
+
"rounds created commits, git reset --hard <restore-commit>) before " +
|
|
533
|
+
"submitting the next round.",
|
|
534
|
+
check: "backtrack_workspace_not_restored",
|
|
535
|
+
});
|
|
536
|
+
}
|
|
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)
|
|
544
|
+
return 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)
|
|
552
|
+
return null;
|
|
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
|
+
}
|
|
565
|
+
}
|
|
566
|
+
else {
|
|
567
|
+
const machine = machineProgressSeries(vaultEntries, currentRound, window);
|
|
568
|
+
if (machine === null)
|
|
569
|
+
return null;
|
|
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`;
|
|
574
|
+
}
|
|
575
|
+
if (!stalled)
|
|
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";
|
|
590
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
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) {
|
|
596
|
+
return makeEnforcementResult({
|
|
597
|
+
action: "terminate",
|
|
598
|
+
reason: `${stalledReason} The stall persisted after escalation. Terminating loop.`,
|
|
599
|
+
check: tier,
|
|
600
|
+
});
|
|
601
|
+
}
|
|
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)) {
|
|
612
|
+
return makeEnforcementResult({
|
|
613
|
+
action: "terminate",
|
|
614
|
+
reason: `${stalledReason} A backtrack already fired for this round; the stall persists after rollback.`,
|
|
615
|
+
check: tier,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
return makeEnforcementResult({
|
|
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,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
if (flatline && !escalation) {
|
|
628
|
+
return makeEnforcementResult({
|
|
629
|
+
action: "terminate",
|
|
630
|
+
reason: `${stalledReason} The agent is making zero forward motion and cannot recover.`,
|
|
631
|
+
check: tier,
|
|
632
|
+
});
|
|
633
|
+
}
|
|
634
|
+
return makeEnforcementResult({
|
|
635
|
+
action: "reject",
|
|
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,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
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;
|
|
655
|
+
}
|
|
656
|
+
/** R6: Two consecutive rejections → escalate (v2.7) or terminate.
|
|
657
|
+
*
|
|
658
|
+
* v2.7: When enforcement_escalation_enabled is true, the second consecutive
|
|
659
|
+
* rejection issues an escalated rejection with a "Seek Human Guidance" notice
|
|
660
|
+
* instead of terminating immediately. Termination occurs on the third
|
|
661
|
+
* consecutive rejection. When the flag is disabled, behavior is unchanged
|
|
662
|
+
* (terminate at ≥2 rejections). */
|
|
663
|
+
function enforceMaxRejections(consecutiveRejections) {
|
|
664
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
665
|
+
if (escalation) {
|
|
666
|
+
if (consecutiveRejections >= 3) {
|
|
667
|
+
return makeEnforcementResult({
|
|
668
|
+
action: "terminate",
|
|
669
|
+
reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
|
|
670
|
+
`same issue without resolution. The agent has been unable to correct ` +
|
|
671
|
+
`the identified issue even after escalation. Terminating loop.`,
|
|
672
|
+
check: "max_rejections",
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
if (consecutiveRejections >= 2) {
|
|
676
|
+
return makeEnforcementResult({
|
|
677
|
+
action: "reject",
|
|
678
|
+
reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
|
|
679
|
+
`same issue without resolution.`,
|
|
680
|
+
fix_instructions: "You have been rejected multiple times for the same issue. " +
|
|
681
|
+
"You must address ALL items in the Evidence Gap section above. " +
|
|
682
|
+
"If you do not understand why you are being rejected, explain " +
|
|
683
|
+
"your confusion explicitly and ask for clarification." +
|
|
684
|
+
buildEscalationNotice(),
|
|
685
|
+
check: "max_rejections",
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
if (consecutiveRejections < 2)
|
|
691
|
+
return null;
|
|
692
|
+
return makeEnforcementResult({
|
|
693
|
+
action: "terminate",
|
|
694
|
+
reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
|
|
695
|
+
`same issue without resolution. The agent has been unable to correct the identified issue.`,
|
|
696
|
+
check: "max_rejections",
|
|
697
|
+
});
|
|
698
|
+
}
|
|
699
|
+
/** v2.12: Check if a drift_clarification contains at least one semantic
|
|
700
|
+
* anchor — a constraint/criterion/sub-goal ID (v2.11) or a file-path-like
|
|
701
|
+
* reference. This distinguishes genuine explanations from filler text that
|
|
702
|
+
* just happens to be ≥ 20 characters. */
|
|
703
|
+
/** Extract anchor candidates from a clarification: c-/cr-/sg- IDs and
|
|
704
|
+
* file-path-like tokens. */
|
|
705
|
+
function extractAnchors(clarification) {
|
|
706
|
+
const anchors = new Set();
|
|
707
|
+
for (const match of clarification.matchAll(/[c]r?-[a-f0-9]{8}|sg-[a-f0-9]{8}/gi)) {
|
|
708
|
+
anchors.add(match[0].toLowerCase());
|
|
709
|
+
}
|
|
710
|
+
for (const match of clarification.matchAll(/[\w./-]+\.[a-z]{2,6}\b/gi)) {
|
|
711
|
+
anchors.add(match[0]);
|
|
712
|
+
}
|
|
713
|
+
return [...anchors];
|
|
714
|
+
}
|
|
715
|
+
/** v2.12: A drift clarification is substantive only when at least one anchor
|
|
716
|
+
* is REAL — a stable ID that derives from a known constraint/criterion/
|
|
717
|
+
* sub-goal text in the vault, or a file path present in reported/observed
|
|
718
|
+
* files. Regex patterns alone (e.g. a fabricated `c-00000000`) no longer
|
|
719
|
+
* count, closing the v2.12 filler-text loophole. */
|
|
720
|
+
function clarificationIsSubstantive(clarification, selfEval, vaultEntries) {
|
|
721
|
+
const anchors = extractAnchors(clarification);
|
|
722
|
+
if (anchors.length === 0)
|
|
723
|
+
return false;
|
|
724
|
+
const knownFiles = new Set();
|
|
725
|
+
const knownTexts = new Set();
|
|
726
|
+
const collect = (values) => {
|
|
727
|
+
if (!Array.isArray(values))
|
|
728
|
+
return;
|
|
729
|
+
for (const value of values) {
|
|
730
|
+
if (typeof value === "string" && value.length > 0)
|
|
731
|
+
knownTexts.add(value);
|
|
732
|
+
}
|
|
733
|
+
};
|
|
734
|
+
// The agent's own round report is a known set: its constraints, criteria,
|
|
735
|
+
// and files are legitimate anchor targets.
|
|
736
|
+
for (const file of selfEval.execution_evidence?.files_changed ?? [])
|
|
737
|
+
knownFiles.add(file);
|
|
738
|
+
collect(selfEval.discovered_constraints);
|
|
739
|
+
collect(selfEval.constraint_violations);
|
|
740
|
+
const selfEvidence = selfEval.execution_evidence;
|
|
741
|
+
if (selfEvidence) {
|
|
742
|
+
collect(selfEvidence.success_criteria_met);
|
|
743
|
+
collect(selfEvidence.success_criteria_remaining);
|
|
744
|
+
}
|
|
745
|
+
for (const entry of vaultEntries) {
|
|
746
|
+
collect(entry.constraint_violations);
|
|
747
|
+
collect(entry.discovered_constraints);
|
|
748
|
+
const ev = entry.execution_evidence;
|
|
749
|
+
if (ev) {
|
|
750
|
+
collect(ev.files_changed);
|
|
751
|
+
collect(ev.success_criteria_met);
|
|
752
|
+
collect(ev.success_criteria_remaining);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
for (const anchor of anchors) {
|
|
756
|
+
if (STABLE_ID_RE.test(anchor)) {
|
|
757
|
+
const matches = anchor.startsWith("c-") && !anchor.startsWith("cr-")
|
|
758
|
+
? [...knownTexts].some((text) => deriveConstraintId(text) === anchor)
|
|
759
|
+
: anchor.startsWith("cr-")
|
|
760
|
+
? [...knownTexts].some((text) => deriveCriterionId(text) === anchor)
|
|
761
|
+
: [...knownTexts].some((text) => deriveSubGoalId(text) === anchor);
|
|
762
|
+
if (matches)
|
|
763
|
+
return true;
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
// File path: exact or suffix match against known files.
|
|
767
|
+
if ([...knownFiles].some((file) => file === anchor || file.endsWith(`/${anchor}`) || file.endsWith(`\\${anchor}`))) {
|
|
768
|
+
return true;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
return false;
|
|
772
|
+
}
|
|
773
|
+
/** Build a stronger rejection notice as the clarification streak grows. */
|
|
774
|
+
function buildClarificationRejectionNotice(streak) {
|
|
775
|
+
const base = "Your declared next_action from the previous round does not match " +
|
|
776
|
+
"what you actually did this round. You must either: " +
|
|
777
|
+
"(a) explain why you pivoted in the drift_clarification field, " +
|
|
778
|
+
"mentioning specific constraint/criterion/sub-goal IDs or file paths " +
|
|
779
|
+
"that prompted the change, or " +
|
|
780
|
+
"(b) redo this round and do what you said you would do.";
|
|
781
|
+
if (streak >= 2) {
|
|
782
|
+
return (base +
|
|
783
|
+
"\n\n### ⚠️ Repeated Weak Clarification\n\n" +
|
|
784
|
+
`You have submitted drift_clarification without concrete references ` +
|
|
785
|
+
`for ${streak} consecutive rounds. Your explanation MUST include at ` +
|
|
786
|
+
`least one of: a constraint ID (c-XXXXXXXX), criterion ID (cr-XXXXXXXX), ` +
|
|
787
|
+
`sub-goal ID (sg-XXXXXXXX), or a file path you changed.\n\n` +
|
|
788
|
+
`🚨 If the next clarification also lacks concrete anchors, the loop ` +
|
|
789
|
+
`will be terminated.`);
|
|
790
|
+
}
|
|
791
|
+
return base;
|
|
792
|
+
}
|
|
793
|
+
/** R7: Agent declared a next_action but did something unrelated.
|
|
794
|
+
*
|
|
795
|
+
* v2.8: When the agent provides a substantive drift_clarification (≥ 20
|
|
796
|
+
* characters), the rejection is waived — the agent has acknowledged and
|
|
797
|
+
* explained the pivot. An empty or absent clarification still triggers
|
|
798
|
+
* the normal reject/terminate path.
|
|
799
|
+
*
|
|
800
|
+
* v2.12: Clarification now requires a semantic anchor (constraint/criterion/
|
|
801
|
+
* sub-goal ID or file path) in addition to the ≥ 20 char minimum. Weak
|
|
802
|
+
* clarifications (no anchors) increment a streak counter; 3 consecutive
|
|
803
|
+
* weak clarifications terminate the loop.
|
|
804
|
+
*
|
|
805
|
+
* First occurrence → reject (agent must explain the gap or realign).
|
|
806
|
+
* Second consecutive → terminate (agent cannot/will not follow its own plan). */
|
|
807
|
+
function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0, vaultEntries = []) {
|
|
808
|
+
const flag = flags.find((f) => f.check === "intent_drift");
|
|
809
|
+
if (!flag)
|
|
810
|
+
return null;
|
|
811
|
+
const clarification = selfEval.drift_clarification?.trim();
|
|
812
|
+
// v2.12: Substantive anchors — the referenced ID/path must actually exist
|
|
813
|
+
// in the vault or in reported files; a fabricated ID no longer counts.
|
|
814
|
+
const hasAnchor = clarification
|
|
815
|
+
? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
|
|
816
|
+
: false;
|
|
817
|
+
// v2.12: Substantive clarification = length check + semantic anchor
|
|
818
|
+
const isSubstantive = clarification
|
|
819
|
+
&& clarification.length >= 20
|
|
820
|
+
&& hasAnchor;
|
|
821
|
+
if (isSubstantive) {
|
|
822
|
+
// Genuine explanation with concrete references → accept the pivot
|
|
823
|
+
return makeEnforcementResult({
|
|
824
|
+
action: "accept",
|
|
825
|
+
reason: "",
|
|
826
|
+
fix_instructions: "",
|
|
827
|
+
check: "intent_drift",
|
|
828
|
+
clarification_accepted: true,
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
// v2.12: Clarification present but weak (no semantic anchors)
|
|
832
|
+
if (clarification && clarification.length >= 20 && !hasAnchor) {
|
|
833
|
+
const maxStreak = getPolicy().engine.drift_clarification_max_streak;
|
|
834
|
+
// When maxStreak is 0, disable the check (pre-v2.12 behavior)
|
|
835
|
+
if (maxStreak <= 0) {
|
|
836
|
+
return makeEnforcementResult({
|
|
837
|
+
action: "accept",
|
|
838
|
+
reason: "",
|
|
839
|
+
fix_instructions: "",
|
|
840
|
+
check: "intent_drift",
|
|
841
|
+
clarification_accepted: true,
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
const newStreak = driftClarificationStreak + 1;
|
|
845
|
+
if (newStreak >= maxStreak) {
|
|
846
|
+
return makeEnforcementResult({
|
|
847
|
+
action: "terminate",
|
|
848
|
+
reason: `Agent has submitted weak drift_clarification (no concrete IDs or ` +
|
|
849
|
+
`file references) for ${newStreak} consecutive rounds. The agent ` +
|
|
850
|
+
`systematically avoids following its own plan without substantive ` +
|
|
851
|
+
`explanation.`,
|
|
852
|
+
check: "intent_drift",
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
return makeEnforcementResult({
|
|
856
|
+
action: "reject",
|
|
857
|
+
reason: flag.detail,
|
|
858
|
+
fix_instructions: buildClarificationRejectionNotice(newStreak),
|
|
859
|
+
check: "intent_drift",
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
// No clarification or too short — the worst form of drift. v2.14: counts
|
|
863
|
+
// toward the SAME streak as weak clarifications (maxStreak consecutive
|
|
864
|
+
// un-explained drifts terminate, per the README's "three consecutive weak
|
|
865
|
+
// clarifications"). Previously this branch used the GLOBAL
|
|
866
|
+
// consecutiveRejections counter, so an unrelated earlier rejection
|
|
867
|
+
// (e.g. R1) terminated the loop on the very first drift.
|
|
868
|
+
const maxStreak = getPolicy().engine.drift_clarification_max_streak;
|
|
869
|
+
// When maxStreak is 0, disable the check (pre-v2.12 behavior)
|
|
870
|
+
if (maxStreak <= 0) {
|
|
871
|
+
return makeEnforcementResult({
|
|
872
|
+
action: "accept",
|
|
873
|
+
reason: "",
|
|
874
|
+
fix_instructions: "",
|
|
875
|
+
check: "intent_drift",
|
|
876
|
+
clarification_accepted: true,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
const newStreak = driftClarificationStreak + 1;
|
|
880
|
+
if (newStreak >= maxStreak) {
|
|
881
|
+
return makeEnforcementResult({
|
|
882
|
+
action: "terminate",
|
|
883
|
+
reason: `Agent has drifted from its declared next_action for ${newStreak} ` +
|
|
884
|
+
`consecutive rounds without any drift_clarification. The agent ` +
|
|
885
|
+
`cannot or will not follow its own plan — terminating loop.`,
|
|
886
|
+
check: "intent_drift",
|
|
887
|
+
});
|
|
888
|
+
}
|
|
889
|
+
return makeEnforcementResult({
|
|
890
|
+
action: "reject",
|
|
891
|
+
reason: flag.detail,
|
|
892
|
+
fix_instructions: "Your declared next_action from the previous round does not match " +
|
|
893
|
+
"what you actually did this round. You must either: " +
|
|
894
|
+
"(a) explain why you pivoted and what you learned in the " +
|
|
895
|
+
"drift_clarification field, or " +
|
|
896
|
+
"(b) redo this round and do what you said you would do. " +
|
|
897
|
+
"If you intentionally changed direction, update your objective_refinement " +
|
|
898
|
+
"and set a new next_action that reflects the new plan.",
|
|
899
|
+
check: "intent_drift",
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
903
|
+
// Escalation notice builder (v2.7)
|
|
904
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
905
|
+
/** Build an escalation notice urging the agent to seek human guidance.
|
|
906
|
+
*
|
|
907
|
+
* Appended to fix_instructions when a rule would otherwise terminate.
|
|
908
|
+
* Gives the agent one final chance to correct course with human input
|
|
909
|
+
* before the loop is irreversibly stopped. */
|
|
910
|
+
function buildEscalationNotice() {
|
|
911
|
+
return [
|
|
912
|
+
"",
|
|
913
|
+
"### 🆘 Escalation — Seek Human Guidance",
|
|
914
|
+
"",
|
|
915
|
+
"You have been rejected multiple times for the same issue. " +
|
|
916
|
+
"Before retrying, pause and ask a human for help:",
|
|
917
|
+
"",
|
|
918
|
+
"1. Explain what you've tried so far and why each approach failed.",
|
|
919
|
+
"2. Ask the human for specific guidance on what to try next.",
|
|
920
|
+
"3. Incorporate their feedback before re-submitting your self-evaluation.",
|
|
921
|
+
"",
|
|
922
|
+
"⚠️ If this round is rejected again, the loop will be **terminated**. " +
|
|
923
|
+
"Do NOT resubmit without meaningful changes.",
|
|
924
|
+
].join("\n");
|
|
925
|
+
}
|
|
926
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
927
|
+
// Diagnostic gap builder (v2.7)
|
|
928
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
929
|
+
/** Build a diagnostic gap section from verification flags.
|
|
930
|
+
*
|
|
931
|
+
* Translates each error/warn-level VerificationFlag into a concrete
|
|
932
|
+
* claim-vs-evidence mismatch statement. Info-level flags are excluded
|
|
933
|
+
* because they are purely informational and do not represent gaps.
|
|
934
|
+
*
|
|
935
|
+
* Returns an empty string when there are no diagnostic flags to report. */
|
|
936
|
+
function buildDiagnosticGap(flags) {
|
|
937
|
+
const diagnosticFlags = flags.filter((f) => f.severity === "error" || f.severity === "warn");
|
|
938
|
+
if (diagnosticFlags.length === 0)
|
|
939
|
+
return "";
|
|
940
|
+
const lines = [
|
|
941
|
+
"### Evidence Gap",
|
|
942
|
+
"",
|
|
943
|
+
"The enforcement gate detected mismatches between your claims and the evidence. " +
|
|
944
|
+
"Each item below shows a specific discrepancy — address every one before re-submitting.",
|
|
945
|
+
"",
|
|
946
|
+
];
|
|
947
|
+
for (const flag of diagnosticFlags) {
|
|
948
|
+
const icon = flag.severity === "error" ? "🚫" : "⚠️";
|
|
949
|
+
lines.push(`- ${icon} **[${flag.check}]** \`${flag.field}\` — ${flag.detail}`);
|
|
950
|
+
}
|
|
951
|
+
lines.push("");
|
|
952
|
+
return lines.join("\n");
|
|
953
|
+
}
|
|
954
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
955
|
+
// Main entry point
|
|
956
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
957
|
+
/** Enforce round-boundary rules based on the verification gate's findings
|
|
958
|
+
* and the agent's self-evaluation integrity.
|
|
959
|
+
*
|
|
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.
|
|
963
|
+
*
|
|
964
|
+
* @param selfEval The agent's self-evaluation for the current round.
|
|
965
|
+
* @param verifyResult The verification gate's output (from verifySelfEvaluation).
|
|
966
|
+
* @param currentRound Current round number (1-based, BEFORE increment).
|
|
967
|
+
* @param vaultEntries Vault entries for this loop (used for progress tracking).
|
|
968
|
+
* @param consecutiveRejections How many consecutive rounds have already been rejected.
|
|
969
|
+
* Starts at 0; increments on each reject; resets on accept.
|
|
970
|
+
*/
|
|
971
|
+
export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0,
|
|
972
|
+
/** v2.12: Current clarification streak for R7 escalation. */
|
|
973
|
+
driftClarificationStreak = 0) {
|
|
974
|
+
const { flags } = verifyResult;
|
|
975
|
+
// Run enforcement rules in priority order.
|
|
976
|
+
// Earlier rules take higher precedence.
|
|
977
|
+
const rules = [
|
|
978
|
+
() => enforceSuccessWithRemainingCriteria(flags),
|
|
979
|
+
() => enforceRecurringViolation(flags),
|
|
980
|
+
() => enforceEmptySuccess(selfEval, flags),
|
|
981
|
+
// v2.14: R-EVID — a required command failure or self-contradictory
|
|
982
|
+
// outcome claim contradicts the success claim before commit
|
|
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),
|
|
998
|
+
// v2.12: R8 — success with zero machine-verifiable evidence
|
|
999
|
+
() => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
|
|
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),
|
|
1009
|
+
() => enforceMaxRejections(consecutiveRejections),
|
|
1010
|
+
() => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
|
|
1011
|
+
// v2.12: R9 — post-backtrack workspace not restored
|
|
1012
|
+
() => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
|
|
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
|
+
]);
|
|
1034
|
+
for (const rule of rules) {
|
|
1035
|
+
const result = rule();
|
|
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;
|
|
1051
|
+
}
|
|
1052
|
+
// All rules passed — accept the round
|
|
1053
|
+
return makeEnforcementResult({
|
|
1054
|
+
action: "accept",
|
|
1055
|
+
reason: "",
|
|
1056
|
+
fix_instructions: "",
|
|
1057
|
+
});
|
|
1058
|
+
}
|
|
1059
|
+
/** Build a rejection prompt for the agent.
|
|
1060
|
+
*
|
|
1061
|
+
* The prompt clearly states the round was rejected, why, what the agent
|
|
1062
|
+
* must fix, and that the agent must redo the SAME round (not advance).
|
|
1063
|
+
*
|
|
1064
|
+
* v2.7: Accepts verificationFlags to render a diagnostic "Evidence Gap"
|
|
1065
|
+
* section. Each error/warn flag becomes a concrete claim-vs-evidence
|
|
1066
|
+
* mismatch statement so the agent knows exactly what to correct rather
|
|
1067
|
+
* than retrying blindly.
|
|
1068
|
+
*
|
|
1069
|
+
* @param currentRound The round number that was rejected (NOT incremented).
|
|
1070
|
+
* @param task The original loop task description.
|
|
1071
|
+
* @param enforceResult The enforcement decision with reason and fix instructions.
|
|
1072
|
+
* @param verificationFlags The verification gate's findings for this round.
|
|
1073
|
+
* Used to build the Evidence Gap section. */
|
|
1074
|
+
export function buildRejectionPrompt(currentRound, task, enforceResult, verificationFlags = []) {
|
|
1075
|
+
const lines = [
|
|
1076
|
+
"## ⛔ Round " + currentRound + " — REJECTED",
|
|
1077
|
+
"",
|
|
1078
|
+
"Your self-evaluation for Round " + currentRound +
|
|
1079
|
+
" was **rejected** by the enforcement gate.",
|
|
1080
|
+
"",
|
|
1081
|
+
"### Reason",
|
|
1082
|
+
"",
|
|
1083
|
+
enforceResult.reason,
|
|
1084
|
+
"",
|
|
1085
|
+
];
|
|
1086
|
+
// v2.7: Diagnostic gap — show the agent exactly what doesn't match.
|
|
1087
|
+
const gap = buildDiagnosticGap(verificationFlags);
|
|
1088
|
+
if (gap) {
|
|
1089
|
+
lines.push(gap);
|
|
1090
|
+
}
|
|
1091
|
+
if (enforceResult.fix_instructions) {
|
|
1092
|
+
lines.push("### Required Fix");
|
|
1093
|
+
lines.push("");
|
|
1094
|
+
lines.push(enforceResult.fix_instructions);
|
|
1095
|
+
lines.push("");
|
|
1096
|
+
}
|
|
1097
|
+
lines.push("### Your Task (Round " + currentRound + " — Retry)");
|
|
1098
|
+
lines.push("");
|
|
1099
|
+
lines.push(task);
|
|
1100
|
+
lines.push("");
|
|
1101
|
+
lines.push("### Instructions");
|
|
1102
|
+
lines.push("");
|
|
1103
|
+
lines.push("1. Read and address each issue in **Required Fix** above.", "2. Re-execute **Round " + currentRound +
|
|
1104
|
+
"** — do NOT advance to the next round.", "3. Submit a corrected self-evaluation via `loopforge_next`.", "4. Be honest in your self-evaluation — " +
|
|
1105
|
+
"claiming success when criteria are unmet will be rejected again.", "5. If you believe this rejection is incorrect, " +
|
|
1106
|
+
"explain why in your output_summary and the enforcement gate will re-evaluate.");
|
|
1107
|
+
return lines.join("\n");
|
|
1108
|
+
}
|
|
1109
|
+
//# sourceMappingURL=enforcement-gate.js.map
|