loopforge 3.0.0 → 3.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +128 -145
- package/dist/audit.d.ts +50 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +158 -0
- package/dist/audit.js.map +1 -0
- package/dist/canonical-state.d.ts +28 -20
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +82 -173
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +25 -16
- package/dist/cli.js.map +1 -1
- 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/enforcement-gate.d.ts +76 -0
- package/dist/enforcement-gate.d.ts.map +1 -0
- package/dist/enforcement-gate.js +1071 -0
- package/dist/enforcement-gate.js.map +1 -0
- package/dist/engine.d.ts +99 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +839 -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 +134 -0
- package/dist/evidence-claims.js.map +1 -0
- package/dist/evidence-provider.d.ts +21 -23
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +74 -34
- 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 +41 -3
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +632 -273
- 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 +26 -0
- package/dist/loop-projection.d.ts.map +1 -0
- package/dist/loop-projection.js +142 -0
- package/dist/loop-projection.js.map +1 -0
- package/dist/loop-store.d.ts +112 -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 +216 -0
- package/dist/mcp/round-lifecycle.d.ts.map +1 -0
- package/dist/mcp/round-lifecycle.js +926 -0
- package/dist/mcp/round-lifecycle.js.map +1 -0
- package/dist/mcp/server.d.ts +2 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +28 -42
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/session.d.ts +53 -101
- package/dist/mcp/session.d.ts.map +1 -1
- package/dist/mcp/session.js +306 -1105
- package/dist/mcp/session.js.map +1 -1
- package/dist/mcp/tools.d.ts +13 -16
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +479 -413
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy-metrics.d.ts +12 -17
- package/dist/policy-metrics.d.ts.map +1 -1
- package/dist/policy-metrics.js +95 -161
- package/dist/policy-metrics.js.map +1 -1
- package/dist/policy.d.ts +44 -35
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +31 -115
- 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 +389 -160
- 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 +357 -229
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +139 -15
- 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 +19 -55
- package/dist/replay.js.map +1 -1
- package/dist/round-coordinator.d.ts +20 -24
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +99 -92
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +14 -13
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +26 -14
- package/dist/round-driver.js.map +1 -1
- package/dist/round-transaction.d.ts +15 -18
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +38 -47
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +53 -0
- package/dist/self-eval.d.ts.map +1 -0
- package/dist/self-eval.js +284 -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 +108 -0
- package/dist/verification-gate.d.ts.map +1 -0
- package/dist/verification-gate.js +971 -0
- package/dist/verification-gate.js.map +1 -0
- package/loop_policy.json +44 -15
- package/package.json +5 -10
- package/skills/perception/SKILL.md +182 -199
- 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,1071 @@
|
|
|
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 } 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 { isProcessResult } from "./round-transaction.js";
|
|
26
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
+
// Helpers
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
/** Extract progress_estimate from a vault entry's execution_evidence.
|
|
30
|
+
* Returns null if no execution evidence is available. */
|
|
31
|
+
function entryProgress(entry) {
|
|
32
|
+
const evidence = entry.execution_evidence;
|
|
33
|
+
if (!evidence)
|
|
34
|
+
return null;
|
|
35
|
+
const pe = evidence.progress_estimate;
|
|
36
|
+
return typeof pe === "number" ? pe : null;
|
|
37
|
+
}
|
|
38
|
+
/** Collect progress estimates from vault entries into a round→estimate map.
|
|
39
|
+
* Shared by R4 (progress_stall) and R5 (progress_stall_terminal). */
|
|
40
|
+
function collectProgressByRound(vaultEntries, currentRound) {
|
|
41
|
+
const progressByRound = new Map();
|
|
42
|
+
for (const entry of vaultEntries) {
|
|
43
|
+
const rnd = entryRound(entry);
|
|
44
|
+
if (rnd < 1 || rnd >= currentRound)
|
|
45
|
+
continue;
|
|
46
|
+
const pe = entryProgress(entry);
|
|
47
|
+
if (pe !== null)
|
|
48
|
+
progressByRound.set(rnd, pe);
|
|
49
|
+
}
|
|
50
|
+
return progressByRound;
|
|
51
|
+
}
|
|
52
|
+
/** v3.2.1: R4/R5 progress window — after a backtrack committed for this
|
|
53
|
+
* round, the agent's redo submission is the freshest data point. The
|
|
54
|
+
* pre-rollback history (which triggered the stall) stays in the vault and
|
|
55
|
+
* the redo does not commit while it is being evaluated, so without this
|
|
56
|
+
* the window never changes: a genuinely fixed redo is rejected forever and
|
|
57
|
+
* the loop dies in a spurious terminate. A redo that is STILL stalled
|
|
58
|
+
* keeps the window flat and hits the normal reject → terminate ladder. */
|
|
59
|
+
function progressWindow(vaultEntries, currentRound, selfEval) {
|
|
60
|
+
const progressByRound = collectProgressByRound(vaultEntries, currentRound);
|
|
61
|
+
if (hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
62
|
+
const pe = selfEval.execution_evidence?.progress_estimate;
|
|
63
|
+
if (typeof pe === "number")
|
|
64
|
+
progressByRound.set(currentRound, pe);
|
|
65
|
+
}
|
|
66
|
+
return progressByRound;
|
|
67
|
+
}
|
|
68
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
69
|
+
// v2.10: Backtrack helpers
|
|
70
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
71
|
+
/** Extract the committed round decision's result from a vault entry.
|
|
72
|
+
* The round commit entry (`loop:<id>:r<N>:feedback`) carries the full
|
|
73
|
+
* decision under loop_lineage.round_transaction; lineage entries carry
|
|
74
|
+
* nothing at the top level. */
|
|
75
|
+
function committedRoundResult(entry) {
|
|
76
|
+
const lineage = isRecord(entry.loop_lineage) ? entry.loop_lineage : null;
|
|
77
|
+
const rt = lineage && isRecord(lineage.round_transaction)
|
|
78
|
+
? lineage.round_transaction
|
|
79
|
+
: null;
|
|
80
|
+
if (!rt)
|
|
81
|
+
return null;
|
|
82
|
+
return isProcessResult(rt.result) ? rt.result : null;
|
|
83
|
+
}
|
|
84
|
+
/** True when a backtrack already committed at or after the current round —
|
|
85
|
+
* i.e. the loop is re-walking territory the system already rolled back
|
|
86
|
+
* once. Guards against the reject↔backtrack deadlock on persistent stalls. */
|
|
87
|
+
function hasCommittedBacktrack(vaultEntries, currentRound) {
|
|
88
|
+
return vaultEntries.some((entry) => {
|
|
89
|
+
if (entryRound(entry) < currentRound)
|
|
90
|
+
return false;
|
|
91
|
+
return committedRoundResult(entry)?.action === "backtrack";
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
/** Check whether a vault entry represents a "clean" round that can serve
|
|
95
|
+
* as a safe restore point. A clean round was accepted (not rejected)
|
|
96
|
+
* and had no error-level verification flags.
|
|
97
|
+
*
|
|
98
|
+
* v3.2.1: judged by the COMMITTED decision and gate flags, not by the
|
|
99
|
+
* agent's self-reported success. A progress-stall round commits with
|
|
100
|
+
* success=false but is accepted and carries no error flags — it is a
|
|
101
|
+
* valid backtrack target (the most common backtrack trigger is exactly a
|
|
102
|
+
* run of such rounds). Rejected/terminated rounds never commit, and
|
|
103
|
+
* rolled-back rounds are excluded below. */
|
|
104
|
+
function isCleanRound(entry) {
|
|
105
|
+
const committed = committedRoundResult(entry);
|
|
106
|
+
// A rolled-back round (already backtracked) is not a valid restore point.
|
|
107
|
+
if (committed?.action === "backtrack")
|
|
108
|
+
return false;
|
|
109
|
+
// Rejected/terminated rounds are not clean. (They never commit in the
|
|
110
|
+
// real vault — this guards legacy or mixed-shape data.)
|
|
111
|
+
if (committed && (committed.action === "reject" || committed.action === "terminate")) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
// Rounds with error-level verification flags are not clean
|
|
115
|
+
const flags = Array.isArray(entry.verification_flags)
|
|
116
|
+
? entry.verification_flags
|
|
117
|
+
: committed?.verificationFlags ?? [];
|
|
118
|
+
for (const f of flags) {
|
|
119
|
+
if (f !== null && typeof f === "object" &&
|
|
120
|
+
f.severity === "error")
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
/** Scan backwards from currentRound to find the most recent clean round.
|
|
126
|
+
* Collects discovered_constraints from skipped rounds along the way.
|
|
127
|
+
* Returns null when no clean round is found within maxDepth. */
|
|
128
|
+
export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
|
|
129
|
+
// v3.2.1: exclude compile-time lineage entries — every committed round
|
|
130
|
+
// writes one (engine.ts hardcodes `success: true` on it, and it carries no
|
|
131
|
+
// round_transaction), and it precedes the feedback entry in the flat view.
|
|
132
|
+
// Treating it as a decision made every round look clean, so the restore
|
|
133
|
+
// point was always currentRound-1: dirty/rejected rounds were never
|
|
134
|
+
// skipped, depth > 1 never collected skippedDiscoveries, and the restore
|
|
135
|
+
// prompt's file list (round-coordinator) was always empty.
|
|
136
|
+
const completed = vaultEntries
|
|
137
|
+
.filter((e) => {
|
|
138
|
+
const rnd = entryRound(e);
|
|
139
|
+
return rnd >= 1 && rnd < currentRound && e.task_type !== "loop_lineage";
|
|
140
|
+
})
|
|
141
|
+
.sort((a, b) => entryRound(b) - entryRound(a)); // newest first
|
|
142
|
+
const skippedDiscoveries = [];
|
|
143
|
+
for (let depth = 1; depth <= maxDepth; depth++) {
|
|
144
|
+
const targetRound = currentRound - depth;
|
|
145
|
+
if (targetRound < 1)
|
|
146
|
+
break;
|
|
147
|
+
const entry = completed.find((e) => entryRound(e) === targetRound);
|
|
148
|
+
if (!entry)
|
|
149
|
+
continue;
|
|
150
|
+
// Collect discoveries from rounds we're about to skip (depth > 1)
|
|
151
|
+
if (depth > 1) {
|
|
152
|
+
for (let d = 1; d < depth; d++) {
|
|
153
|
+
const skippedRound = currentRound - d;
|
|
154
|
+
const skippedEntry = completed.find((e) => entryRound(e) === skippedRound);
|
|
155
|
+
if (skippedEntry) {
|
|
156
|
+
const discovered = Array.isArray(skippedEntry.discovered_constraints)
|
|
157
|
+
? skippedEntry.discovered_constraints.filter((v) => typeof v === "string")
|
|
158
|
+
: [];
|
|
159
|
+
for (const item of discovered) {
|
|
160
|
+
if (!skippedDiscoveries.includes(item))
|
|
161
|
+
skippedDiscoveries.push(item);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (isCleanRound(entry)) {
|
|
167
|
+
return { round: targetRound, skippedDiscoveries };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
/** v2.12: Git HEAD commit hash at the restore point. Read from the restore
|
|
173
|
+
* round's committed transaction snapshot (after evidence preferred, before
|
|
174
|
+
* evidence as fallback). Returns null when the round has no git snapshot
|
|
175
|
+
* or no committed feedback entry. */
|
|
176
|
+
export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
|
|
177
|
+
const feedback = vaultEntries.find((entry) => String(entry.task_id ?? "") === `loop:${entry.loop_id}:r${restoreRound}:feedback`);
|
|
178
|
+
const lineage = feedback && isRecord(feedback.loop_lineage)
|
|
179
|
+
? feedback.loop_lineage
|
|
180
|
+
: null;
|
|
181
|
+
const transaction = lineage && isRecord(lineage.round_transaction)
|
|
182
|
+
? lineage.round_transaction
|
|
183
|
+
: null;
|
|
184
|
+
const snapshot = transaction && isRecord(transaction.snapshot)
|
|
185
|
+
? transaction.snapshot
|
|
186
|
+
: null;
|
|
187
|
+
if (!snapshot)
|
|
188
|
+
return null;
|
|
189
|
+
const evidence = Array.isArray(snapshot.afterEvidence) && snapshot.afterEvidence.length > 0
|
|
190
|
+
? snapshot.afterEvidence
|
|
191
|
+
: Array.isArray(snapshot.beforeEvidence)
|
|
192
|
+
? snapshot.beforeEvidence
|
|
193
|
+
: [];
|
|
194
|
+
for (const item of evidence) {
|
|
195
|
+
if (!isRecord(item) || item.provider !== "git" || !isRecord(item.data))
|
|
196
|
+
continue;
|
|
197
|
+
const head = item.data.head;
|
|
198
|
+
if (typeof head === "string" && head.length > 0)
|
|
199
|
+
return head;
|
|
200
|
+
}
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
/** Build the backtrack prompt injected at the top of the restored round.
|
|
204
|
+
*
|
|
205
|
+
* v2.13: Includes concrete workspace restore instructions with affected
|
|
206
|
+
* file lists from skipped rounds. The agent must restore the working tree
|
|
207
|
+
* to the clean round's state before proceeding. */
|
|
208
|
+
export function buildBacktrackPrompt(fromRound, toRound, triggerRule, skippedDiscoveries,
|
|
209
|
+
/** v2.13: Files changed in the skipped rounds (from evidence snapshots).
|
|
210
|
+
* Used to show the agent exactly what needs to be reverted. */
|
|
211
|
+
skippedFiles = [],
|
|
212
|
+
/** v2.12: Git HEAD commit hash at the backtrack point (current HEAD).
|
|
213
|
+
* The agent must discard work back to the clean round's state. */
|
|
214
|
+
gitHead) {
|
|
215
|
+
const lines = [
|
|
216
|
+
`## ⛔ Backtrack — Round ${fromRound} → Restored to Round ${toRound}`,
|
|
217
|
+
"",
|
|
218
|
+
];
|
|
219
|
+
// ── Why This Happened ────────────────────────────────────────────────
|
|
220
|
+
if (triggerRule === "progress_stall") {
|
|
221
|
+
lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}. ` +
|
|
222
|
+
`The work done in those rounds produced no verifiable progress — ` +
|
|
223
|
+
`no new files changed, no criteria met.`, "", "The approach used in those rounds **did not work**. It should not be repeated.", "");
|
|
224
|
+
}
|
|
225
|
+
else if (triggerRule === "progress_stall_terminal") {
|
|
226
|
+
lines.push("### Why This Happened", "", `Progress was completely flat for multiple rounds leading up to Round ${fromRound}. ` +
|
|
227
|
+
`The agent made **zero forward motion** — the task is not advancing.`, "", "A **radically different** strategy is needed. The previous approach produced nothing.", "");
|
|
228
|
+
}
|
|
229
|
+
// ── v2.13: Workspace Restore ──────────────────────────────────────────
|
|
230
|
+
lines.push("### ⚠️ Workspace Restore Required", "", `Your working directory still contains changes from the **failed** ` +
|
|
231
|
+
`rounds (${toRound + 1}–${fromRound}). You MUST discard those changes ` +
|
|
232
|
+
`before starting Round ${toRound + 1}. Working on top of stale changes ` +
|
|
233
|
+
`will cause the verification gate to reject your next submission.`, "");
|
|
234
|
+
if (skippedFiles.length > 0) {
|
|
235
|
+
lines.push("**Files modified in skipped rounds (must be reverted):**", "");
|
|
236
|
+
for (const f of skippedFiles.slice(0, 15)) {
|
|
237
|
+
lines.push(`- \`${f}\``);
|
|
238
|
+
}
|
|
239
|
+
if (skippedFiles.length > 15) {
|
|
240
|
+
lines.push(`- … and ${skippedFiles.length - 15} more files`);
|
|
241
|
+
}
|
|
242
|
+
lines.push("");
|
|
243
|
+
}
|
|
244
|
+
lines.push("**To restore a clean workspace, run:**", "", "```bash", "# Option A: Discard all uncommitted changes (recommended)", "git checkout -- .", "git clean -fd", "", "# Option B: If you have unrelated work to keep", `git stash push -m "backtrack-safety-net-round-${fromRound}"`, "git checkout -- .", "git clean -fd", "```", "");
|
|
245
|
+
// v2.12: Restore to the exact commit observed at the clean round.
|
|
246
|
+
if (gitHead) {
|
|
247
|
+
lines.push(`**Restore to commit \`${gitHead.slice(0, 12)}\`** — the verification ` +
|
|
248
|
+
"gate checks that your working tree returns to this commit before " +
|
|
249
|
+
"accepting your next submission.", "", "```bash", "# Option C: If the failed rounds created commits, reset HEAD to the restore point", `git reset --hard ${gitHead.slice(0, 12)}`, "```", "", "> Option C discards the failed rounds' commits — use it only when those", "> commits belong to the failed work.", "");
|
|
250
|
+
}
|
|
251
|
+
lines.push(`**If git is unavailable**, manually revert the files listed above ` +
|
|
252
|
+
`to their state at Round ${toRound}. Re-read the state file below — ` +
|
|
253
|
+
`it reflects the correct state at Round ${toRound}.`, "", "> 🛡️ **Verification:** The next round's evidence check will verify that " +
|
|
254
|
+
"> your workspace no longer contains the failed changes. Submissions " +
|
|
255
|
+
"> with unrestored files will be **rejected**.", "");
|
|
256
|
+
// ── What Must Change ──────────────────────────────────────────────────
|
|
257
|
+
lines.push("### What Must Change", "", "- Do **NOT** repeat the approach used in the skipped rounds.", "- Try a **different** task decomposition or technique.", "- If the current sub-goal is stuck, consider canceling it " +
|
|
258
|
+
"(`canceled_subtasks`) and working on a different one.", "- Use the `prompt_requests.confusion_points` field in your self-evaluation " +
|
|
259
|
+
"to flag anything you don't understand about the task or the blockage.", "");
|
|
260
|
+
// ── Preserved Discoveries ─────────────────────────────────────────────
|
|
261
|
+
if (skippedDiscoveries.length > 0) {
|
|
262
|
+
lines.push("### Preserved Discoveries", "", "These were discovered in the skipped rounds and remain valid:", "");
|
|
263
|
+
for (const d of skippedDiscoveries) {
|
|
264
|
+
lines.push(`- ${d}`);
|
|
265
|
+
}
|
|
266
|
+
lines.push("");
|
|
267
|
+
}
|
|
268
|
+
// ── Instructions ──────────────────────────────────────────────────────
|
|
269
|
+
lines.push("### Instructions", "", `You have been rolled back to Round ${toRound}. ` +
|
|
270
|
+
`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 " +
|
|
271
|
+
"into the active constraints.", "4. Do NOT retrace the steps that led to the stall. Choose a new path.", "");
|
|
272
|
+
return lines.join("\n");
|
|
273
|
+
}
|
|
274
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
275
|
+
// Individual enforcement rules — each returns EnforcementResult | null
|
|
276
|
+
// Rules are ordered by priority. The first non-null result wins.
|
|
277
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
278
|
+
/** R1: Agent claims success but success_criteria_remaining has items.
|
|
279
|
+
* This is a lie — the agent must either finish the criteria or
|
|
280
|
+
* set success=false honestly. Triggered by the verification gate's
|
|
281
|
+
* "success_with_remaining_criteria" error flag. */
|
|
282
|
+
function enforceSuccessWithRemainingCriteria(flags) {
|
|
283
|
+
const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITH_REMAINING_CRITERIA && f.severity === "error");
|
|
284
|
+
if (!flag)
|
|
285
|
+
return null;
|
|
286
|
+
return makeEnforcementResult({
|
|
287
|
+
action: "reject",
|
|
288
|
+
reason: flag.detail,
|
|
289
|
+
fix_instructions: "You set success=true but success criteria remain unmet. " +
|
|
290
|
+
"Either: (a) complete the remaining criteria and re-submit your self-evaluation, " +
|
|
291
|
+
"or (b) set success=false and honestly report what remains to be done.",
|
|
292
|
+
check: "success_with_remaining_criteria",
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
/** R2: Same constraint violation appears in 3 consecutive rounds.
|
|
296
|
+
* The agent is repeating the same mistake. Triggered by the verification
|
|
297
|
+
* gate's "recurring_violation" error flag. */
|
|
298
|
+
function enforceRecurringViolation(flags) {
|
|
299
|
+
const flag = flags.find((f) => f.check === CHECK_RECURRING_VIOLATION && f.severity === "error");
|
|
300
|
+
if (!flag)
|
|
301
|
+
return null;
|
|
302
|
+
return makeEnforcementResult({
|
|
303
|
+
action: "reject",
|
|
304
|
+
reason: flag.detail,
|
|
305
|
+
fix_instructions: "The same constraint violation has appeared in 3 consecutive rounds. " +
|
|
306
|
+
"You must: (a) explain WHY this violation keeps occurring, " +
|
|
307
|
+
"and (b) propose a DIFFERENT approach than what you used in the last 3 rounds. " +
|
|
308
|
+
"Do NOT retry the same strategy — it has failed 3 times.",
|
|
309
|
+
check: "recurring_violation",
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** R-EVID (v2.14): A required command failure, a test-results mismatch that
|
|
313
|
+
* hides failures, or a self-contradictory outcome claim contradicts the
|
|
314
|
+
* success claim before commit. The verification gate detects the
|
|
315
|
+
* contradiction; this rule decides what to do about it — reject so the
|
|
316
|
+
* agent redos the SAME round with honest claims. Same-check rejections
|
|
317
|
+
* accumulate toward R6 via the per-check counter. */
|
|
318
|
+
function enforceEvidenceContradiction(flags) {
|
|
319
|
+
const contradiction = flags.find((f) => f.severity === "error" &&
|
|
320
|
+
(f.check === CHECK_REQUIRED_COMMAND_FAILED ||
|
|
321
|
+
f.check === CHECK_COMMAND_EVIDENCE_MISMATCH ||
|
|
322
|
+
f.check === CHECK_OUTCOME_SUCCESS_CONTRADICTION));
|
|
323
|
+
if (!contradiction)
|
|
324
|
+
return null;
|
|
325
|
+
return makeEnforcementResult({
|
|
326
|
+
action: "reject",
|
|
327
|
+
reason: contradiction.detail,
|
|
328
|
+
fix_instructions: "A required verification command failed or your claims contradict " +
|
|
329
|
+
"the evidence. You must: (a) run the required command and report its " +
|
|
330
|
+
"actual output, (b) reconcile your success claim with the evidence — " +
|
|
331
|
+
"do NOT report success when the evidence contradicts it, and " +
|
|
332
|
+
"(c) resubmit an honest SelfEvaluation for the SAME round.",
|
|
333
|
+
check: contradiction.check,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
/** R3: Agent claims success but did nothing verifiable.
|
|
337
|
+
* v1.17: execution_evidence is now MANDATORY for structured self-evaluations.
|
|
338
|
+
* Missing evidence when success=true → reject (agent must provide evidence).
|
|
339
|
+
* Empty evidence (no files + no tests) when success=true → reject. */
|
|
340
|
+
function enforceEmptySuccess(selfEval, _flags, skipEvidenceRules = false) {
|
|
341
|
+
if (skipEvidenceRules)
|
|
342
|
+
return null; // Requires execution_evidence
|
|
343
|
+
if (!effectiveSuccess(selfEval))
|
|
344
|
+
return null;
|
|
345
|
+
const ev = selfEval.execution_evidence;
|
|
346
|
+
// v1.17: Missing evidence when claiming success → reject.
|
|
347
|
+
// The agent MUST provide execution_evidence to back up a success claim.
|
|
348
|
+
if (!ev) {
|
|
349
|
+
return makeEnforcementResult({
|
|
350
|
+
action: "reject",
|
|
351
|
+
reason: "Agent claims success but provided no execution_evidence. " +
|
|
352
|
+
"Every successful round MUST include execution_evidence with " +
|
|
353
|
+
"files_changed, test_results, and progress_estimate.",
|
|
354
|
+
fix_instructions: "You must provide execution_evidence in your self-evaluation: " +
|
|
355
|
+
"(a) list the files you changed in execution_evidence.files_changed, " +
|
|
356
|
+
"(b) run tests and report results in execution_evidence.test_results, " +
|
|
357
|
+
"(c) estimate your progress in execution_evidence.progress_estimate. " +
|
|
358
|
+
"If you genuinely completed the task without file changes or tests, " +
|
|
359
|
+
"explain why in detail in your output_summary.",
|
|
360
|
+
check: "empty_success",
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
const filesEmpty = ev.files_changed.length === 0;
|
|
364
|
+
const testsNotRun = ev.test_results === null;
|
|
365
|
+
if (!filesEmpty || !testsNotRun)
|
|
366
|
+
return null;
|
|
367
|
+
return makeEnforcementResult({
|
|
368
|
+
action: "reject",
|
|
369
|
+
reason: "Agent claims success but execution_evidence shows no files changed " +
|
|
370
|
+
"and no tests were run. There is no verifiable evidence of work.",
|
|
371
|
+
fix_instructions: "You must provide verifiable evidence: " +
|
|
372
|
+
"(a) list the files you changed in execution_evidence.files_changed, " +
|
|
373
|
+
"and (b) run tests and report results in execution_evidence.test_results. " +
|
|
374
|
+
"If you genuinely completed the task without file changes or tests, " +
|
|
375
|
+
"explain why in detail in your output_summary.",
|
|
376
|
+
check: "empty_success",
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
/** R8 (v2.12): Success claimed without any machine-verifiable evidence.
|
|
380
|
+
* The verification gate produced success_without_verified_evidence (error):
|
|
381
|
+
* zero verified claims, no test results. First occurrence → reject with
|
|
382
|
+
* concrete evidence requirements; second consecutive → terminate. */
|
|
383
|
+
function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
|
|
384
|
+
const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE && f.severity === "error");
|
|
385
|
+
if (!flag)
|
|
386
|
+
return null;
|
|
387
|
+
if (consecutiveRejections >= 2) {
|
|
388
|
+
return makeEnforcementResult({
|
|
389
|
+
action: "terminate",
|
|
390
|
+
reason: "Repeated success claims with zero machine-verifiable evidence.",
|
|
391
|
+
check: "success_without_verified_evidence",
|
|
392
|
+
});
|
|
393
|
+
}
|
|
394
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled &&
|
|
395
|
+
consecutiveRejections >= 1
|
|
396
|
+
? buildEscalationNotice()
|
|
397
|
+
: "";
|
|
398
|
+
return makeEnforcementResult({
|
|
399
|
+
action: "reject",
|
|
400
|
+
reason: "Agent claims success but the runtime verified zero claims: no passing " +
|
|
401
|
+
"test evidence and no test results were observed. Success must be backed " +
|
|
402
|
+
"by machine-verifiable evidence.",
|
|
403
|
+
fix_instructions: "Provide verifiable evidence for your success claim: (a) run tests and " +
|
|
404
|
+
"report results in execution_evidence.test_results, or (b) configure a " +
|
|
405
|
+
"required verification command and run it, or (c) if no change was " +
|
|
406
|
+
"genuinely needed, declare no_change_reason explaining why." + escalation,
|
|
407
|
+
check: "success_without_verified_evidence",
|
|
408
|
+
});
|
|
409
|
+
}
|
|
410
|
+
/** R9 (v2.12): Post-backtrack workspace not restored. The verification gate
|
|
411
|
+
* produced backtrack_workspace_not_restored (error): either skipped-round
|
|
412
|
+
* files are still dirty, or the working tree is not at the restore point's
|
|
413
|
+
* git HEAD. The agent must restore the workspace, not redo the round. */
|
|
414
|
+
function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0) {
|
|
415
|
+
const flag = flags.find((f) => f.check === CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED && f.severity === "error");
|
|
416
|
+
if (!flag)
|
|
417
|
+
return null;
|
|
418
|
+
if (!getPolicy().engine.backtrack_enabled)
|
|
419
|
+
return null;
|
|
420
|
+
// v2.14: same deadlock guard as R4/R5 — a backtrack that already fired
|
|
421
|
+
// for this round means a non-restoring agent would otherwise cycle
|
|
422
|
+
// R9-backtrack forever (backtracks do not increment the rejection
|
|
423
|
+
// counter). One rollback chance, then terminate.
|
|
424
|
+
if (currentRound > 0 && hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
425
|
+
return makeEnforcementResult({
|
|
426
|
+
action: "terminate",
|
|
427
|
+
reason: "The workspace is still not restored after the previous backtrack: " +
|
|
428
|
+
flag.detail,
|
|
429
|
+
check: "backtrack_workspace_not_restored",
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
return makeEnforcementResult({
|
|
433
|
+
action: "backtrack",
|
|
434
|
+
reason: "The workspace was not restored after backtrack: " + flag.detail,
|
|
435
|
+
fix_instructions: "Restore the working directory to the clean round's state " +
|
|
436
|
+
"(git checkout -- . && git clean -fd, or git stash; if the failed " +
|
|
437
|
+
"rounds created commits, git reset --hard <restore-commit>) before " +
|
|
438
|
+
"submitting the next round.",
|
|
439
|
+
check: "backtrack_workspace_not_restored",
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
/** R4: Progress has stalled for 3+ consecutive rounds.
|
|
443
|
+
* Detected by checking progress_estimate deltas across vault entries.
|
|
444
|
+
* First occurrence → REJECT (agent must change approach).
|
|
445
|
+
* Second consecutive occurrence → TERMINATE (agent cannot recover). */
|
|
446
|
+
function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
|
|
447
|
+
// Need at least 3 rounds of history
|
|
448
|
+
if (currentRound < 3)
|
|
449
|
+
return null;
|
|
450
|
+
let isStalling = false;
|
|
451
|
+
let stallDetail = "";
|
|
452
|
+
let stallThreshold = 0;
|
|
453
|
+
if (skipEvidenceRules) {
|
|
454
|
+
// v3.2: heuristic path has no execution_evidence — fall back to machine
|
|
455
|
+
// observations: three consecutive rounds with no git change = stall.
|
|
456
|
+
// No git signal → keep the legacy skip (the machine cannot observe).
|
|
457
|
+
const machine = machineProgressSeries(vaultEntries, currentRound, 3);
|
|
458
|
+
if (machine === null)
|
|
459
|
+
return null;
|
|
460
|
+
isStalling = machine.every((value) => !value);
|
|
461
|
+
stallDetail = "no machine-observed changes in the last 3 rounds";
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
|
|
465
|
+
if (progressByRound.size < 3)
|
|
466
|
+
return null;
|
|
467
|
+
const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
|
|
468
|
+
const last3 = sortedRounds.slice(-3);
|
|
469
|
+
// Require all three data points to be within [currentRound-3, currentRound-1].
|
|
470
|
+
// (collectProgressByRound already filters rnd < currentRound, so the upper
|
|
471
|
+
// bound is always satisfied — the lower bound is the real continuity guard.)
|
|
472
|
+
const expectedMin = currentRound - 3;
|
|
473
|
+
if (last3[0] < expectedMin)
|
|
474
|
+
return null;
|
|
475
|
+
const p1 = progressByRound.get(last3[0]);
|
|
476
|
+
const p2 = progressByRound.get(last3[1]);
|
|
477
|
+
const p3 = progressByRound.get(last3[2]);
|
|
478
|
+
stallThreshold = getPolicy().evolution.progress_stall_threshold;
|
|
479
|
+
const delta12 = p2 - p1;
|
|
480
|
+
const delta23 = p3 - p2;
|
|
481
|
+
// Both deltas must be below threshold AND not near completion
|
|
482
|
+
isStalling = delta12 < stallThreshold && delta23 < stallThreshold && p3 < 0.95;
|
|
483
|
+
stallDetail = `${(p1 * 100).toFixed(0)}% → ${(p2 * 100).toFixed(0)}% → ${(p3 * 100).toFixed(0)}%`;
|
|
484
|
+
}
|
|
485
|
+
if (!isStalling)
|
|
486
|
+
return null;
|
|
487
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
488
|
+
// v2.7: When escalation is enabled, give one extra rejection round with a
|
|
489
|
+
// "Seek Human Guidance" notice before terminating. This lets the agent
|
|
490
|
+
// course-correct with human input rather than being killed immediately.
|
|
491
|
+
if (consecutiveRejections >= 2) {
|
|
492
|
+
return makeEnforcementResult({
|
|
493
|
+
action: "terminate",
|
|
494
|
+
reason: `Progress stalled for 3+ rounds ` +
|
|
495
|
+
`(${stallDetail}) ` +
|
|
496
|
+
`and agent did not resolve after escalation. Terminating loop.`,
|
|
497
|
+
check: "progress_stall",
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
if (consecutiveRejections >= 1 && escalation) {
|
|
501
|
+
// v2.10: When backtrack is enabled, escalate to backtrack instead of
|
|
502
|
+
// reject. The agent is rolled back to the last clean round with lessons
|
|
503
|
+
// injected, rather than redoing the same round with an escalation notice.
|
|
504
|
+
if (getPolicy().engine.backtrack_enabled) {
|
|
505
|
+
// v2.14: A backtrack that already committed for this round (the loop
|
|
506
|
+
// is re-walking rolled-back territory with the same stall) must not
|
|
507
|
+
// loop forever — terminate instead. Without this, consecutive
|
|
508
|
+
// rejections reset on every backtrack and the R4 cycle has no exit.
|
|
509
|
+
if (hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
510
|
+
return makeEnforcementResult({
|
|
511
|
+
action: "terminate",
|
|
512
|
+
reason: `Progress has stalled over the last 3 rounds ` +
|
|
513
|
+
`(${stallDetail}) and a backtrack already fired for ` +
|
|
514
|
+
`this round. The stall persists after rollback — terminating.`,
|
|
515
|
+
check: "progress_stall",
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
return makeEnforcementResult({
|
|
519
|
+
action: "backtrack",
|
|
520
|
+
reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
|
|
521
|
+
`${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
|
|
522
|
+
fix_instructions: "Your progress has been flat for 3 rounds. You are being rolled back " +
|
|
523
|
+
"to the last clean round. Do NOT repeat the approach that led to the stall. " +
|
|
524
|
+
"Choose a different technique or task decomposition.",
|
|
525
|
+
check: "progress_stall",
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
return makeEnforcementResult({
|
|
529
|
+
action: "reject",
|
|
530
|
+
reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
|
|
531
|
+
`${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
|
|
532
|
+
fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
|
|
533
|
+
"(a) explain what is blocking progress, " +
|
|
534
|
+
"(b) propose a DIFFERENT technique or task decomposition, and " +
|
|
535
|
+
"(c) set a concrete, verifiable goal for the redo of this round. " +
|
|
536
|
+
"Do NOT repeat the same approach — it has not moved progress forward." +
|
|
537
|
+
buildEscalationNotice(),
|
|
538
|
+
check: "progress_stall",
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
if (consecutiveRejections >= 1) {
|
|
542
|
+
return makeEnforcementResult({
|
|
543
|
+
action: "terminate",
|
|
544
|
+
reason: `Progress stalled for 3+ rounds ` +
|
|
545
|
+
`(${stallDetail}) ` +
|
|
546
|
+
`and agent did not resolve after previous rejection. Terminating loop.`,
|
|
547
|
+
check: "progress_stall",
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return makeEnforcementResult({
|
|
551
|
+
action: "reject",
|
|
552
|
+
reason: `Progress has stalled: ${stallDetail} over the last 3 rounds` +
|
|
553
|
+
`${stallThreshold > 0 ? ` (delta < ${(stallThreshold * 100).toFixed(0)}% each round)` : ""}.`,
|
|
554
|
+
fix_instructions: "Your progress has been flat for 3 rounds. You must: " +
|
|
555
|
+
"(a) explain what is blocking progress, " +
|
|
556
|
+
"(b) propose a DIFFERENT technique or task decomposition, and " +
|
|
557
|
+
"(c) set a concrete, verifiable goal for the redo of this round. " +
|
|
558
|
+
"Do NOT repeat the same approach — it has not moved progress forward.",
|
|
559
|
+
check: "progress_stall",
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
/** R5: Progress completely stalled (delta = 0) for N consecutive rounds.
|
|
563
|
+
* Stricter than R4 (which triggers on delta < 5%). R4 gives the agent a chance
|
|
564
|
+
* to change approach via reject; this rule fires when progress has been exactly
|
|
565
|
+
* flat — the agent is making zero forward motion.
|
|
566
|
+
*
|
|
567
|
+
* v2.7: When enforcement_escalation_enabled is true, the first occurrence
|
|
568
|
+
* issues a rejection with an escalation notice instead of terminating.
|
|
569
|
+
* Termination only happens on the second consecutive flatline detection.
|
|
570
|
+
*
|
|
571
|
+
* Uses the engine.max_circuit_breaker policy value as the lookback window. */
|
|
572
|
+
function enforceProgressStallTerminal(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules = false) {
|
|
573
|
+
const breakerSize = getPolicy().engine.max_circuit_breaker;
|
|
574
|
+
if (currentRound < breakerSize || vaultEntries.length < breakerSize)
|
|
575
|
+
return null;
|
|
576
|
+
let allFlat = false;
|
|
577
|
+
let flatDetail = "";
|
|
578
|
+
if (skipEvidenceRules) {
|
|
579
|
+
// v3.2: heuristic path has no execution_evidence — fall back to machine
|
|
580
|
+
// observations: N consecutive rounds with no git change = flatline.
|
|
581
|
+
// No git signal → keep the legacy skip (the machine cannot observe).
|
|
582
|
+
const machine = machineProgressSeries(vaultEntries, currentRound, breakerSize);
|
|
583
|
+
if (machine === null)
|
|
584
|
+
return null;
|
|
585
|
+
allFlat = machine.every((value) => !value);
|
|
586
|
+
flatDetail = `no machine-observed changes for ${breakerSize} consecutive rounds`;
|
|
587
|
+
}
|
|
588
|
+
else {
|
|
589
|
+
const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
|
|
590
|
+
if (progressByRound.size < breakerSize)
|
|
591
|
+
return null;
|
|
592
|
+
const sortedRounds = [...progressByRound.keys()].sort((a, b) => a - b);
|
|
593
|
+
const recent = sortedRounds.slice(-breakerSize);
|
|
594
|
+
// v2.14: continuity guard (mirrors R4) — "consecutive" means the most
|
|
595
|
+
// recent rounds. Rounds without execution_evidence are unknown motion,
|
|
596
|
+
// not zero motion; taking "the last N rounds that happened to report
|
|
597
|
+
// progress" terminated loops whose recent rounds simply had no data.
|
|
598
|
+
if (recent[0] < currentRound - breakerSize)
|
|
599
|
+
return null;
|
|
600
|
+
// Check that progress values are ALL identical (delta = 0).
|
|
601
|
+
// Use epsilon comparison — JSON round-trips can produce float drift
|
|
602
|
+
// (e.g. 0.3 vs 0.30000000000000004) that would defeat strict === .
|
|
603
|
+
const EPSILON = 1e-10;
|
|
604
|
+
const first = progressByRound.get(recent[0]);
|
|
605
|
+
allFlat = recent.every((r) => Math.abs((progressByRound.get(r) ?? 0) - first) < EPSILON);
|
|
606
|
+
if (!allFlat || first >= 0.95)
|
|
607
|
+
return null;
|
|
608
|
+
flatDetail = `completely flat at ${(first * 100).toFixed(0)}%`;
|
|
609
|
+
}
|
|
610
|
+
if (!allFlat)
|
|
611
|
+
return null;
|
|
612
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
613
|
+
// v2.7: When escalation is enabled, give one rejection round with a
|
|
614
|
+
// "Seek Human Guidance" notice before terminating.
|
|
615
|
+
if (consecutiveRejections >= 1) {
|
|
616
|
+
return makeEnforcementResult({
|
|
617
|
+
action: "terminate",
|
|
618
|
+
reason: `Progress has been ${flatDetail} ` +
|
|
619
|
+
`for ${breakerSize} consecutive rounds and agent did not resolve ` +
|
|
620
|
+
`after escalation. The agent is making zero forward motion.`,
|
|
621
|
+
check: "progress_stall_terminal",
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
if (escalation) {
|
|
625
|
+
// v2.10: When backtrack is enabled, escalate to backtrack instead of
|
|
626
|
+
// reject. The agent is rolled back to the last clean round with a
|
|
627
|
+
// "radically different strategy" lesson injected.
|
|
628
|
+
if (getPolicy().engine.backtrack_enabled) {
|
|
629
|
+
// v2.14: Same deadlock guard as R4 — a backtrack that already fired
|
|
630
|
+
// for this round means the loop is re-walking rolled-back territory.
|
|
631
|
+
if (hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
632
|
+
return makeEnforcementResult({
|
|
633
|
+
action: "terminate",
|
|
634
|
+
reason: `Progress has been ${flatDetail} ` +
|
|
635
|
+
`for ${breakerSize} consecutive rounds and a backtrack already ` +
|
|
636
|
+
`fired for this round. Flatline persists after rollback — ` +
|
|
637
|
+
`terminating the loop.`,
|
|
638
|
+
check: "progress_stall_terminal",
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
return makeEnforcementResult({
|
|
642
|
+
action: "backtrack",
|
|
643
|
+
reason: `Progress has been ${flatDetail} ` +
|
|
644
|
+
`for ${breakerSize} consecutive rounds. The agent is making zero ` +
|
|
645
|
+
`forward motion.`,
|
|
646
|
+
fix_instructions: "Your progress has been exactly flat — zero forward motion. " +
|
|
647
|
+
"You are being rolled back to the last clean round. " +
|
|
648
|
+
"Choose a RADICALLY different approach. The previous one produced nothing.",
|
|
649
|
+
check: "progress_stall_terminal",
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
return makeEnforcementResult({
|
|
653
|
+
action: "reject",
|
|
654
|
+
reason: `Progress has been ${flatDetail} ` +
|
|
655
|
+
`for ${breakerSize} consecutive rounds. The agent is making zero ` +
|
|
656
|
+
`forward motion.`,
|
|
657
|
+
fix_instructions: "Your progress has been exactly flat — you are making zero forward " +
|
|
658
|
+
"motion. You must: (a) explain what is fundamentally blocking you, " +
|
|
659
|
+
"(b) propose a radically different approach, and " +
|
|
660
|
+
"(c) set a concrete verifiable goal. " +
|
|
661
|
+
"Do NOT resubmit the same output." +
|
|
662
|
+
buildEscalationNotice(),
|
|
663
|
+
check: "progress_stall_terminal",
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
return makeEnforcementResult({
|
|
667
|
+
action: "terminate",
|
|
668
|
+
reason: `Progress has been ${flatDetail} ` +
|
|
669
|
+
`for ${breakerSize} consecutive rounds. The agent is making zero ` +
|
|
670
|
+
`forward motion and cannot recover.`,
|
|
671
|
+
check: "progress_stall_terminal",
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
/** R6: Two consecutive rejections → escalate (v2.7) or terminate.
|
|
675
|
+
*
|
|
676
|
+
* v2.7: When enforcement_escalation_enabled is true, the second consecutive
|
|
677
|
+
* rejection issues an escalated rejection with a "Seek Human Guidance" notice
|
|
678
|
+
* instead of terminating immediately. Termination occurs on the third
|
|
679
|
+
* consecutive rejection. When the flag is disabled, behavior is unchanged
|
|
680
|
+
* (terminate at ≥2 rejections). */
|
|
681
|
+
function enforceMaxRejections(consecutiveRejections) {
|
|
682
|
+
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
683
|
+
if (escalation) {
|
|
684
|
+
if (consecutiveRejections >= 3) {
|
|
685
|
+
return makeEnforcementResult({
|
|
686
|
+
action: "terminate",
|
|
687
|
+
reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
|
|
688
|
+
`same issue without resolution. The agent has been unable to correct ` +
|
|
689
|
+
`the identified issue even after escalation. Terminating loop.`,
|
|
690
|
+
check: "max_rejections",
|
|
691
|
+
});
|
|
692
|
+
}
|
|
693
|
+
if (consecutiveRejections >= 2) {
|
|
694
|
+
return makeEnforcementResult({
|
|
695
|
+
action: "reject",
|
|
696
|
+
reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
|
|
697
|
+
`same issue without resolution.`,
|
|
698
|
+
fix_instructions: "You have been rejected multiple times for the same issue. " +
|
|
699
|
+
"You must address ALL items in the Evidence Gap section above. " +
|
|
700
|
+
"If you do not understand why you are being rejected, explain " +
|
|
701
|
+
"your confusion explicitly and ask for clarification." +
|
|
702
|
+
buildEscalationNotice(),
|
|
703
|
+
check: "max_rejections",
|
|
704
|
+
});
|
|
705
|
+
}
|
|
706
|
+
return null;
|
|
707
|
+
}
|
|
708
|
+
if (consecutiveRejections < 2)
|
|
709
|
+
return null;
|
|
710
|
+
return makeEnforcementResult({
|
|
711
|
+
action: "terminate",
|
|
712
|
+
reason: `${consecutiveRejections} consecutive enforcement rejections for the ` +
|
|
713
|
+
`same issue without resolution. The agent has been unable to correct the identified issue.`,
|
|
714
|
+
check: "max_rejections",
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/** v2.12: Check if a drift_clarification contains at least one semantic
|
|
718
|
+
* anchor — a constraint/criterion/sub-goal ID (v2.11) or a file-path-like
|
|
719
|
+
* reference. This distinguishes genuine explanations from filler text that
|
|
720
|
+
* just happens to be ≥ 20 characters. */
|
|
721
|
+
/** Extract anchor candidates from a clarification: c-/cr-/sg- IDs and
|
|
722
|
+
* file-path-like tokens. */
|
|
723
|
+
function extractAnchors(clarification) {
|
|
724
|
+
const anchors = new Set();
|
|
725
|
+
for (const match of clarification.matchAll(/[c]r?-[a-f0-9]{8}|sg-[a-f0-9]{8}/gi)) {
|
|
726
|
+
anchors.add(match[0].toLowerCase());
|
|
727
|
+
}
|
|
728
|
+
for (const match of clarification.matchAll(/[\w./-]+\.[a-z]{2,6}\b/gi)) {
|
|
729
|
+
anchors.add(match[0]);
|
|
730
|
+
}
|
|
731
|
+
return [...anchors];
|
|
732
|
+
}
|
|
733
|
+
/** v2.12: A drift clarification is substantive only when at least one anchor
|
|
734
|
+
* is REAL — a stable ID that derives from a known constraint/criterion/
|
|
735
|
+
* sub-goal text in the vault, or a file path present in reported/observed
|
|
736
|
+
* files. Regex patterns alone (e.g. a fabricated `c-00000000`) no longer
|
|
737
|
+
* count, closing the v2.12 filler-text loophole. */
|
|
738
|
+
function clarificationIsSubstantive(clarification, selfEval, vaultEntries) {
|
|
739
|
+
const anchors = extractAnchors(clarification);
|
|
740
|
+
if (anchors.length === 0)
|
|
741
|
+
return false;
|
|
742
|
+
const knownFiles = new Set();
|
|
743
|
+
const knownTexts = new Set();
|
|
744
|
+
const collect = (values) => {
|
|
745
|
+
if (!Array.isArray(values))
|
|
746
|
+
return;
|
|
747
|
+
for (const value of values) {
|
|
748
|
+
if (typeof value === "string" && value.length > 0)
|
|
749
|
+
knownTexts.add(value);
|
|
750
|
+
}
|
|
751
|
+
};
|
|
752
|
+
// The agent's own round report is a known set: its constraints, criteria,
|
|
753
|
+
// and files are legitimate anchor targets.
|
|
754
|
+
for (const file of selfEval.execution_evidence?.files_changed ?? [])
|
|
755
|
+
knownFiles.add(file);
|
|
756
|
+
collect(selfEval.discovered_constraints);
|
|
757
|
+
collect(selfEval.constraint_violations);
|
|
758
|
+
const selfEvidence = selfEval.execution_evidence;
|
|
759
|
+
if (selfEvidence) {
|
|
760
|
+
collect(selfEvidence.success_criteria_met);
|
|
761
|
+
collect(selfEvidence.success_criteria_remaining);
|
|
762
|
+
}
|
|
763
|
+
for (const entry of vaultEntries) {
|
|
764
|
+
collect(entry.constraint_violations);
|
|
765
|
+
collect(entry.discovered_constraints);
|
|
766
|
+
const ev = entry.execution_evidence;
|
|
767
|
+
if (ev) {
|
|
768
|
+
collect(ev.files_changed);
|
|
769
|
+
collect(ev.success_criteria_met);
|
|
770
|
+
collect(ev.success_criteria_remaining);
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
for (const anchor of anchors) {
|
|
774
|
+
if (STABLE_ID_RE.test(anchor)) {
|
|
775
|
+
const matches = anchor.startsWith("c-") && !anchor.startsWith("cr-")
|
|
776
|
+
? [...knownTexts].some((text) => deriveConstraintId(text) === anchor)
|
|
777
|
+
: anchor.startsWith("cr-")
|
|
778
|
+
? [...knownTexts].some((text) => deriveCriterionId(text) === anchor)
|
|
779
|
+
: [...knownTexts].some((text) => deriveSubGoalId(text) === anchor);
|
|
780
|
+
if (matches)
|
|
781
|
+
return true;
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
// File path: exact or suffix match against known files.
|
|
785
|
+
if ([...knownFiles].some((file) => file === anchor || file.endsWith(`/${anchor}`) || file.endsWith(`\\${anchor}`))) {
|
|
786
|
+
return true;
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return false;
|
|
790
|
+
}
|
|
791
|
+
/** Build a stronger rejection notice as the clarification streak grows. */
|
|
792
|
+
function buildClarificationRejectionNotice(streak) {
|
|
793
|
+
const base = "Your declared next_action from the previous round does not match " +
|
|
794
|
+
"what you actually did this round. You must either: " +
|
|
795
|
+
"(a) explain why you pivoted in the drift_clarification field, " +
|
|
796
|
+
"mentioning specific constraint/criterion/sub-goal IDs or file paths " +
|
|
797
|
+
"that prompted the change, or " +
|
|
798
|
+
"(b) redo this round and do what you said you would do.";
|
|
799
|
+
if (streak >= 2) {
|
|
800
|
+
return (base +
|
|
801
|
+
"\n\n### ⚠️ Repeated Weak Clarification\n\n" +
|
|
802
|
+
`You have submitted drift_clarification without concrete references ` +
|
|
803
|
+
`for ${streak} consecutive rounds. Your explanation MUST include at ` +
|
|
804
|
+
`least one of: a constraint ID (c-XXXXXXXX), criterion ID (cr-XXXXXXXX), ` +
|
|
805
|
+
`sub-goal ID (sg-XXXXXXXX), or a file path you changed.\n\n` +
|
|
806
|
+
`🚨 If the next clarification also lacks concrete anchors, the loop ` +
|
|
807
|
+
`will be terminated.`);
|
|
808
|
+
}
|
|
809
|
+
return base;
|
|
810
|
+
}
|
|
811
|
+
/** R7: Agent declared a next_action but did something unrelated.
|
|
812
|
+
*
|
|
813
|
+
* v2.8: When the agent provides a substantive drift_clarification (≥ 20
|
|
814
|
+
* characters), the rejection is waived — the agent has acknowledged and
|
|
815
|
+
* explained the pivot. An empty or absent clarification still triggers
|
|
816
|
+
* the normal reject/terminate path.
|
|
817
|
+
*
|
|
818
|
+
* v2.12: Clarification now requires a semantic anchor (constraint/criterion/
|
|
819
|
+
* sub-goal ID or file path) in addition to the ≥ 20 char minimum. Weak
|
|
820
|
+
* clarifications (no anchors) increment a streak counter; 3 consecutive
|
|
821
|
+
* weak clarifications terminate the loop.
|
|
822
|
+
*
|
|
823
|
+
* First occurrence → reject (agent must explain the gap or realign).
|
|
824
|
+
* Second consecutive → terminate (agent cannot/will not follow its own plan). */
|
|
825
|
+
function enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak = 0, vaultEntries = []) {
|
|
826
|
+
const flag = flags.find((f) => f.check === "intent_drift");
|
|
827
|
+
if (!flag)
|
|
828
|
+
return null;
|
|
829
|
+
const clarification = selfEval.drift_clarification?.trim();
|
|
830
|
+
// v2.12: Substantive anchors — the referenced ID/path must actually exist
|
|
831
|
+
// in the vault or in reported files; a fabricated ID no longer counts.
|
|
832
|
+
const hasAnchor = clarification
|
|
833
|
+
? clarificationIsSubstantive(clarification, selfEval, vaultEntries)
|
|
834
|
+
: false;
|
|
835
|
+
// v2.12: Substantive clarification = length check + semantic anchor
|
|
836
|
+
const isSubstantive = clarification
|
|
837
|
+
&& clarification.length >= 20
|
|
838
|
+
&& hasAnchor;
|
|
839
|
+
if (isSubstantive) {
|
|
840
|
+
// Genuine explanation with concrete references → accept the pivot
|
|
841
|
+
return makeEnforcementResult({
|
|
842
|
+
action: "accept",
|
|
843
|
+
reason: "",
|
|
844
|
+
fix_instructions: "",
|
|
845
|
+
check: "intent_drift",
|
|
846
|
+
clarification_accepted: true,
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
// v2.12: Clarification present but weak (no semantic anchors)
|
|
850
|
+
if (clarification && clarification.length >= 20 && !hasAnchor) {
|
|
851
|
+
const maxStreak = getPolicy().engine.drift_clarification_max_streak;
|
|
852
|
+
// When maxStreak is 0, disable the check (pre-v2.12 behavior)
|
|
853
|
+
if (maxStreak <= 0) {
|
|
854
|
+
return makeEnforcementResult({
|
|
855
|
+
action: "accept",
|
|
856
|
+
reason: "",
|
|
857
|
+
fix_instructions: "",
|
|
858
|
+
check: "intent_drift",
|
|
859
|
+
clarification_accepted: true,
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
const newStreak = driftClarificationStreak + 1;
|
|
863
|
+
if (newStreak >= maxStreak) {
|
|
864
|
+
return makeEnforcementResult({
|
|
865
|
+
action: "terminate",
|
|
866
|
+
reason: `Agent has submitted weak drift_clarification (no concrete IDs or ` +
|
|
867
|
+
`file references) for ${newStreak} consecutive rounds. The agent ` +
|
|
868
|
+
`systematically avoids following its own plan without substantive ` +
|
|
869
|
+
`explanation.`,
|
|
870
|
+
check: "intent_drift",
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
return makeEnforcementResult({
|
|
874
|
+
action: "reject",
|
|
875
|
+
reason: flag.detail,
|
|
876
|
+
fix_instructions: buildClarificationRejectionNotice(newStreak),
|
|
877
|
+
check: "intent_drift",
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
// No clarification or too short — the worst form of drift. v2.14: counts
|
|
881
|
+
// toward the SAME streak as weak clarifications (maxStreak consecutive
|
|
882
|
+
// un-explained drifts terminate, per the README's "three consecutive weak
|
|
883
|
+
// clarifications"). Previously this branch used the GLOBAL
|
|
884
|
+
// consecutiveRejections counter, so an unrelated earlier rejection
|
|
885
|
+
// (e.g. R1) terminated the loop on the very first drift.
|
|
886
|
+
const maxStreak = getPolicy().engine.drift_clarification_max_streak;
|
|
887
|
+
// When maxStreak is 0, disable the check (pre-v2.12 behavior)
|
|
888
|
+
if (maxStreak <= 0) {
|
|
889
|
+
return makeEnforcementResult({
|
|
890
|
+
action: "accept",
|
|
891
|
+
reason: "",
|
|
892
|
+
fix_instructions: "",
|
|
893
|
+
check: "intent_drift",
|
|
894
|
+
clarification_accepted: true,
|
|
895
|
+
});
|
|
896
|
+
}
|
|
897
|
+
const newStreak = driftClarificationStreak + 1;
|
|
898
|
+
if (newStreak >= maxStreak) {
|
|
899
|
+
return makeEnforcementResult({
|
|
900
|
+
action: "terminate",
|
|
901
|
+
reason: `Agent has drifted from its declared next_action for ${newStreak} ` +
|
|
902
|
+
`consecutive rounds without any drift_clarification. The agent ` +
|
|
903
|
+
`cannot or will not follow its own plan — terminating loop.`,
|
|
904
|
+
check: "intent_drift",
|
|
905
|
+
});
|
|
906
|
+
}
|
|
907
|
+
return makeEnforcementResult({
|
|
908
|
+
action: "reject",
|
|
909
|
+
reason: flag.detail,
|
|
910
|
+
fix_instructions: "Your declared next_action from the previous round does not match " +
|
|
911
|
+
"what you actually did this round. You must either: " +
|
|
912
|
+
"(a) explain why you pivoted and what you learned in the " +
|
|
913
|
+
"drift_clarification field, or " +
|
|
914
|
+
"(b) redo this round and do what you said you would do. " +
|
|
915
|
+
"If you intentionally changed direction, update your objective_refinement " +
|
|
916
|
+
"and set a new next_action that reflects the new plan.",
|
|
917
|
+
check: "intent_drift",
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
921
|
+
// Escalation notice builder (v2.7)
|
|
922
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
923
|
+
/** Build an escalation notice urging the agent to seek human guidance.
|
|
924
|
+
*
|
|
925
|
+
* Appended to fix_instructions when a rule would otherwise terminate.
|
|
926
|
+
* Gives the agent one final chance to correct course with human input
|
|
927
|
+
* before the loop is irreversibly stopped. */
|
|
928
|
+
function buildEscalationNotice() {
|
|
929
|
+
return [
|
|
930
|
+
"",
|
|
931
|
+
"### 🆘 Escalation — Seek Human Guidance",
|
|
932
|
+
"",
|
|
933
|
+
"You have been rejected multiple times for the same issue. " +
|
|
934
|
+
"Before retrying, pause and ask a human for help:",
|
|
935
|
+
"",
|
|
936
|
+
"1. Explain what you've tried so far and why each approach failed.",
|
|
937
|
+
"2. Ask the human for specific guidance on what to try next.",
|
|
938
|
+
"3. Incorporate their feedback before re-submitting your self-evaluation.",
|
|
939
|
+
"",
|
|
940
|
+
"⚠️ If this round is rejected again, the loop will be **terminated**. " +
|
|
941
|
+
"Do NOT resubmit without meaningful changes.",
|
|
942
|
+
].join("\n");
|
|
943
|
+
}
|
|
944
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
945
|
+
// Diagnostic gap builder (v2.7)
|
|
946
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
947
|
+
/** Build a diagnostic gap section from verification flags.
|
|
948
|
+
*
|
|
949
|
+
* Translates each error/warn-level VerificationFlag into a concrete
|
|
950
|
+
* claim-vs-evidence mismatch statement. Info-level flags are excluded
|
|
951
|
+
* because they are purely informational and do not represent gaps.
|
|
952
|
+
*
|
|
953
|
+
* Returns an empty string when there are no diagnostic flags to report. */
|
|
954
|
+
function buildDiagnosticGap(flags) {
|
|
955
|
+
const diagnosticFlags = flags.filter((f) => f.severity === "error" || f.severity === "warn");
|
|
956
|
+
if (diagnosticFlags.length === 0)
|
|
957
|
+
return "";
|
|
958
|
+
const lines = [
|
|
959
|
+
"### Evidence Gap",
|
|
960
|
+
"",
|
|
961
|
+
"The enforcement gate detected mismatches between your claims and the evidence. " +
|
|
962
|
+
"Each item below shows a specific discrepancy — address every one before re-submitting.",
|
|
963
|
+
"",
|
|
964
|
+
];
|
|
965
|
+
for (const flag of diagnosticFlags) {
|
|
966
|
+
const icon = flag.severity === "error" ? "🚫" : "⚠️";
|
|
967
|
+
lines.push(`- ${icon} **[${flag.check}]** \`${flag.field}\` — ${flag.detail}`);
|
|
968
|
+
}
|
|
969
|
+
lines.push("");
|
|
970
|
+
return lines.join("\n");
|
|
971
|
+
}
|
|
972
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
973
|
+
// Main entry point
|
|
974
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
975
|
+
/** Enforce round-boundary rules based on the verification gate's findings
|
|
976
|
+
* and the agent's self-evaluation integrity.
|
|
977
|
+
*
|
|
978
|
+
* Rules run in priority order (R1 → R5). The first rule that fires wins.
|
|
979
|
+
*
|
|
980
|
+
* @param selfEval The agent's self-evaluation for the current round.
|
|
981
|
+
* @param verifyResult The verification gate's output (from verifySelfEvaluation).
|
|
982
|
+
* @param currentRound Current round number (1-based, BEFORE increment).
|
|
983
|
+
* @param vaultEntries Vault entries for this loop (used for progress tracking).
|
|
984
|
+
* @param consecutiveRejections How many consecutive rounds have already been rejected.
|
|
985
|
+
* Starts at 0; increments on each reject; resets on accept.
|
|
986
|
+
*/
|
|
987
|
+
export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries, consecutiveRejections = 0, skipEvidenceRules = false,
|
|
988
|
+
/** v2.12: Current clarification streak for R7 escalation. */
|
|
989
|
+
driftClarificationStreak = 0) {
|
|
990
|
+
const { flags } = verifyResult;
|
|
991
|
+
// Run enforcement rules in priority order.
|
|
992
|
+
// Earlier rules take higher precedence.
|
|
993
|
+
const rules = [
|
|
994
|
+
() => enforceSuccessWithRemainingCriteria(flags),
|
|
995
|
+
() => enforceRecurringViolation(flags),
|
|
996
|
+
() => enforceEmptySuccess(selfEval, flags, skipEvidenceRules),
|
|
997
|
+
// v2.14: R-EVID — a required command failure or self-contradictory
|
|
998
|
+
// outcome claim contradicts the success claim before commit
|
|
999
|
+
() => enforceEvidenceContradiction(flags),
|
|
1000
|
+
// v2.12: R8 — success with zero machine-verifiable evidence
|
|
1001
|
+
() => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
|
|
1002
|
+
() => enforceProgressStall(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
|
|
1003
|
+
() => enforceProgressStallTerminal(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, skipEvidenceRules),
|
|
1004
|
+
() => enforceMaxRejections(consecutiveRejections),
|
|
1005
|
+
() => enforceIntentDrift(flags, selfEval, consecutiveRejections, driftClarificationStreak, vaultEntries),
|
|
1006
|
+
// v2.12: R9 — post-backtrack workspace not restored
|
|
1007
|
+
() => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
|
|
1008
|
+
];
|
|
1009
|
+
for (const rule of rules) {
|
|
1010
|
+
const result = rule();
|
|
1011
|
+
if (result)
|
|
1012
|
+
return result;
|
|
1013
|
+
}
|
|
1014
|
+
// All rules passed — accept the round
|
|
1015
|
+
return makeEnforcementResult({
|
|
1016
|
+
action: "accept",
|
|
1017
|
+
reason: "",
|
|
1018
|
+
fix_instructions: "",
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
/** Build a rejection prompt for the agent.
|
|
1022
|
+
*
|
|
1023
|
+
* The prompt clearly states the round was rejected, why, what the agent
|
|
1024
|
+
* must fix, and that the agent must redo the SAME round (not advance).
|
|
1025
|
+
*
|
|
1026
|
+
* v2.7: Accepts verificationFlags to render a diagnostic "Evidence Gap"
|
|
1027
|
+
* section. Each error/warn flag becomes a concrete claim-vs-evidence
|
|
1028
|
+
* mismatch statement so the agent knows exactly what to correct rather
|
|
1029
|
+
* than retrying blindly.
|
|
1030
|
+
*
|
|
1031
|
+
* @param currentRound The round number that was rejected (NOT incremented).
|
|
1032
|
+
* @param task The original loop task description.
|
|
1033
|
+
* @param enforceResult The enforcement decision with reason and fix instructions.
|
|
1034
|
+
* @param verificationFlags The verification gate's findings for this round.
|
|
1035
|
+
* Used to build the Evidence Gap section. */
|
|
1036
|
+
export function buildRejectionPrompt(currentRound, task, enforceResult, verificationFlags = []) {
|
|
1037
|
+
const lines = [
|
|
1038
|
+
"## ⛔ Round " + currentRound + " — REJECTED",
|
|
1039
|
+
"",
|
|
1040
|
+
"Your self-evaluation for Round " + currentRound +
|
|
1041
|
+
" was **rejected** by the enforcement gate.",
|
|
1042
|
+
"",
|
|
1043
|
+
"### Reason",
|
|
1044
|
+
"",
|
|
1045
|
+
enforceResult.reason,
|
|
1046
|
+
"",
|
|
1047
|
+
];
|
|
1048
|
+
// v2.7: Diagnostic gap — show the agent exactly what doesn't match.
|
|
1049
|
+
const gap = buildDiagnosticGap(verificationFlags);
|
|
1050
|
+
if (gap) {
|
|
1051
|
+
lines.push(gap);
|
|
1052
|
+
}
|
|
1053
|
+
if (enforceResult.fix_instructions) {
|
|
1054
|
+
lines.push("### Required Fix");
|
|
1055
|
+
lines.push("");
|
|
1056
|
+
lines.push(enforceResult.fix_instructions);
|
|
1057
|
+
lines.push("");
|
|
1058
|
+
}
|
|
1059
|
+
lines.push("### Your Task (Round " + currentRound + " — Retry)");
|
|
1060
|
+
lines.push("");
|
|
1061
|
+
lines.push(task);
|
|
1062
|
+
lines.push("");
|
|
1063
|
+
lines.push("### Instructions");
|
|
1064
|
+
lines.push("");
|
|
1065
|
+
lines.push("1. Read and address each issue in **Required Fix** above.", "2. Re-execute **Round " + currentRound +
|
|
1066
|
+
"** — do NOT advance to the next round.", "3. Submit a corrected self-evaluation via `loopforge_next`.", "4. Be honest in your self-evaluation — " +
|
|
1067
|
+
"claiming success when criteria are unmet will be rejected again.", "5. If you believe this rejection is incorrect, " +
|
|
1068
|
+
"explain why in your output_summary and the enforcement gate will re-evaluate.");
|
|
1069
|
+
return lines.join("\n");
|
|
1070
|
+
}
|
|
1071
|
+
//# sourceMappingURL=enforcement-gate.js.map
|