loopforge 3.8.2 → 3.9.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 +145 -123
- package/dist/canonical-state.d.ts +1 -2
- package/dist/canonical-state.d.ts.map +1 -1
- package/dist/canonical-state.js +4 -12
- package/dist/canonical-state.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +115 -15
- package/dist/cli.js.map +1 -1
- package/dist/committed-round.d.ts +35 -7
- package/dist/committed-round.d.ts.map +1 -1
- package/dist/committed-round.js +81 -23
- package/dist/committed-round.js.map +1 -1
- package/dist/enforcement-gate.d.ts +21 -5
- package/dist/enforcement-gate.d.ts.map +1 -1
- package/dist/enforcement-gate.js +194 -139
- package/dist/enforcement-gate.js.map +1 -1
- package/dist/engine.d.ts.map +1 -1
- package/dist/engine.js +4 -5
- package/dist/engine.js.map +1 -1
- package/dist/evidence-provider.d.ts +44 -2
- package/dist/evidence-provider.d.ts.map +1 -1
- package/dist/evidence-provider.js +125 -23
- package/dist/evidence-provider.js.map +1 -1
- package/dist/loop-compiler.d.ts.map +1 -1
- package/dist/loop-compiler.js +1 -4
- package/dist/loop-compiler.js.map +1 -1
- package/dist/loop-store.d.ts.map +1 -1
- package/dist/loop-store.js +44 -7
- package/dist/loop-store.js.map +1 -1
- package/dist/mcp/round-lifecycle.d.ts +32 -1
- package/dist/mcp/round-lifecycle.d.ts.map +1 -1
- package/dist/mcp/round-lifecycle.js +102 -14
- package/dist/mcp/round-lifecycle.js.map +1 -1
- package/dist/mcp/server.d.ts.map +1 -1
- package/dist/mcp/server.js +7 -0
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.d.ts.map +1 -1
- package/dist/mcp/tools.js +0 -6
- package/dist/mcp/tools.js.map +1 -1
- package/dist/policy.d.ts +14 -6
- package/dist/policy.d.ts.map +1 -1
- package/dist/policy.js +52 -14
- package/dist/policy.js.map +1 -1
- package/dist/prompt-assembler.d.ts.map +1 -1
- package/dist/prompt-assembler.js +8 -24
- package/dist/prompt-assembler.js.map +1 -1
- package/dist/protocol.d.ts +4 -8
- package/dist/protocol.d.ts.map +1 -1
- package/dist/protocol.js +0 -2
- package/dist/protocol.js.map +1 -1
- package/dist/round-coordinator.d.ts +43 -0
- package/dist/round-coordinator.d.ts.map +1 -1
- package/dist/round-coordinator.js +104 -15
- package/dist/round-coordinator.js.map +1 -1
- package/dist/round-driver.d.ts +4 -0
- package/dist/round-driver.d.ts.map +1 -1
- package/dist/round-driver.js +2 -0
- package/dist/round-driver.js.map +1 -1
- package/dist/round-transaction.d.ts +5 -0
- package/dist/round-transaction.d.ts.map +1 -1
- package/dist/round-transaction.js +3 -0
- package/dist/round-transaction.js.map +1 -1
- package/dist/self-eval.d.ts +10 -0
- package/dist/self-eval.d.ts.map +1 -1
- package/dist/self-eval.js +14 -4
- package/dist/self-eval.js.map +1 -1
- package/dist/verification-gate.d.ts +18 -1
- package/dist/verification-gate.d.ts.map +1 -1
- package/dist/verification-gate.js +133 -41
- package/dist/verification-gate.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/loop_policy.json +58 -59
- package/package.json +1 -1
- package/skills/perception/SKILL.md +326 -264
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"enforcement-gate.d.ts","sourceRoot":"","sources":["../src/enforcement-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EACV,iBAAiB,EACjB,cAAc,EACd,gBAAgB,EAChB,kBAAkB,EACnB,MAAM,eAAe,CAAC;
|
|
1
|
+
{"version":3,"file":"enforcement-gate.d.ts","sourceRoot":"","sources":["../src/enforcement-gate.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAClD,OAAO,KAAK,EACV,iBAAiB,EACjB,cAAc,EACd,gBAAgB,EAChB,kBAAkB,EACnB,MAAM,eAAe,CAAC;AAwDvB;;iEAEiE;AACjE,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,UAAU,EAAE,EAC1B,QAAQ,EAAE,MAAM,GACf;IAAE,KAAK,EAAE,MAAM,CAAC;IAAC,kBAAkB,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAgCxD;AAED;;;;;;mCAMmC;AACnC,MAAM,WAAW,uBAAuB;IACtC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,EAAE,CAAC;CACtB;AAED,wBAAgB,2BAA2B,CACzC,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,UAAU,EAAE,GACzB,uBAAuB,GAAG,IAAI,CAmBhC;AAED;;;;oDAIoD;AACpD,MAAM,WAAW,sBAAsB;IACrC,sEAAsE;IACtE,YAAY,EAAE,MAAM,EAAE,CAAC;IACvB,yEAAyE;IACzE,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,sEAAsE;IACtE,gBAAgB,EAAE,MAAM,EAAE,CAAC;CAC5B;AAED;;;;;;;;;0BAS0B;AAC1B,wBAAgB,kBAAkB,CAAC,KAAK,EAAE;IACxC,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;IACpB,YAAY,EAAE,MAAM,EAAE,GAAG,MAAM,EAAE,CAAC;IAClC,UAAU,EAAE,MAAM,EAAE,CAAC;IACrB,gBAAgB,EAAE,MAAM,EAAE,CAAC;IAC3B,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,GAAG,MAAM,EAAE,CA6BX;AAED,wBAAgB,oBAAoB,CAClC,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,WAAW,EAAE,MAAM,EACnB,kBAAkB,EAAE,MAAM,EAAE;AAC5B;gEACgE;AAChE,YAAY,GAAE,MAAM,EAAO;AAC3B;mEACmE;AACnE,OAAO,CAAC,EAAE,MAAM;AAChB,2EAA2E;AAC3E,eAAe,CAAC,EAAE,MAAM;AACxB;uDACuD;AACvD,QAAQ,CAAC,EAAE,sBAAsB,GAChC,MAAM,CAuKR;AAswBD,wBAAgB,YAAY,CAC1B,QAAQ,EAAE,cAAc,EACxB,YAAY,EAAE,kBAAkB,EAChC,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,UAAU,EAAE,EAC1B,qBAAqB,GAAE,MAAU;AACjC;;;;uCAIuC;AACvC,kBAAkB,GAAE,MAAW;AAC/B;;;;yCAIyC;AACzC,gBAAgB,GAAE,OAAe;AACjC;6CAC6C;AAC7C,cAAc,GAAE,OAAO,GAAG,IAAW,GACpC,iBAAiB,CA2GnB;AAED;;;;;;;;;;;;;;sEAcsE;AACtE,wBAAgB,oBAAoB,CAClC,YAAY,EAAE,MAAM,EACpB,IAAI,EAAE,MAAM,EACZ,aAAa,EAAE,iBAAiB,EAChC,iBAAiB,GAAE,gBAAgB,EAAO,GACzC,MAAM,CA6CR"}
|
package/dist/enforcement-gate.js
CHANGED
|
@@ -24,33 +24,6 @@ import { derivationRounds, decodeCommittedRound, machineEvidenceForRound, } from
|
|
|
24
24
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
25
25
|
// Helpers
|
|
26
26
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
27
|
-
/** Collect progress estimates from vault entries into a round→estimate map.
|
|
28
|
-
* Shared by the progress evaluator's stall and flatline tiers. */
|
|
29
|
-
function collectProgressByRound(vaultEntries, currentRound) {
|
|
30
|
-
const progressByRound = new Map();
|
|
31
|
-
for (const round of derivationRounds(vaultEntries, currentRound)) {
|
|
32
|
-
const progress = round.executionReport?.progress_estimate;
|
|
33
|
-
if (typeof progress === "number")
|
|
34
|
-
progressByRound.set(round.round, progress);
|
|
35
|
-
}
|
|
36
|
-
return progressByRound;
|
|
37
|
-
}
|
|
38
|
-
/** v3.2.1: R4/R5 progress window — after a backtrack committed for this
|
|
39
|
-
* round, the agent's redo submission is the freshest data point. The
|
|
40
|
-
* pre-rollback history (which triggered the stall) stays in the vault and
|
|
41
|
-
* the redo does not commit while it is being evaluated, so without this
|
|
42
|
-
* the window never changes: a genuinely fixed redo is rejected forever and
|
|
43
|
-
* the loop dies in a spurious terminate. A redo that is STILL stalled
|
|
44
|
-
* keeps the window flat and hits the normal reject → terminate ladder. */
|
|
45
|
-
function progressWindow(vaultEntries, currentRound, selfEval) {
|
|
46
|
-
const progressByRound = collectProgressByRound(vaultEntries, currentRound);
|
|
47
|
-
if (hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
48
|
-
const pe = selfEval.execution_report?.progress_estimate;
|
|
49
|
-
if (typeof pe === "number")
|
|
50
|
-
progressByRound.set(currentRound, pe);
|
|
51
|
-
}
|
|
52
|
-
return progressByRound;
|
|
53
|
-
}
|
|
54
27
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
55
28
|
// v2.10: Backtrack helpers
|
|
56
29
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -118,22 +91,28 @@ export function findSafeRestorePoint(currentRound, vaultEntries, maxDepth) {
|
|
|
118
91
|
}
|
|
119
92
|
return null;
|
|
120
93
|
}
|
|
121
|
-
|
|
122
|
-
* round's committed transaction snapshot (after evidence preferred, before
|
|
123
|
-
* evidence as fallback). Returns null when the round has no git snapshot
|
|
124
|
-
* or no committed feedback entry. */
|
|
125
|
-
export function findBacktrackTargetGitHead(restoreRound, vaultEntries) {
|
|
94
|
+
export function findBacktrackTargetSnapshot(restoreRound, vaultEntries) {
|
|
126
95
|
const committed = derivationRounds(vaultEntries)
|
|
127
96
|
.find((view) => view.round === restoreRound);
|
|
128
97
|
if (!committed)
|
|
129
98
|
return null;
|
|
130
|
-
const
|
|
131
|
-
for (const item of evidence) {
|
|
99
|
+
for (const item of machineEvidenceForRound(committed)) {
|
|
132
100
|
if (!isRecord(item) || item.providerId !== "git" || !isRecord(item.data))
|
|
133
101
|
continue;
|
|
134
102
|
const head = item.data.head;
|
|
135
|
-
if (typeof head
|
|
136
|
-
|
|
103
|
+
if (typeof head !== "string" || head.length === 0)
|
|
104
|
+
continue;
|
|
105
|
+
const dirtyFiles = [];
|
|
106
|
+
for (const field of ["tracked", "staged", "untracked"]) {
|
|
107
|
+
const list = item.data[field];
|
|
108
|
+
if (!Array.isArray(list))
|
|
109
|
+
continue;
|
|
110
|
+
for (const file of list) {
|
|
111
|
+
if (typeof file === "string" && !dirtyFiles.includes(file))
|
|
112
|
+
dirtyFiles.push(file);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { head, dirtyFiles };
|
|
137
116
|
}
|
|
138
117
|
return null;
|
|
139
118
|
}
|
|
@@ -215,8 +194,7 @@ recovery) {
|
|
|
215
194
|
// tier still distinguishes the enforcement reason itself.
|
|
216
195
|
if (triggerRule === "progress_stall") {
|
|
217
196
|
lines.push("### Why This Happened", "", `Progress stalled over rounds ${toRound + 1}–${fromRound}: the work ` +
|
|
218
|
-
`produced no
|
|
219
|
-
`criteria met, no progress estimates moving.`, "", "The approach used in those rounds **did not work**. It should not be " +
|
|
197
|
+
`produced no machine-observed Git changes.`, "", "The approach used in those rounds **did not work**. It should not be " +
|
|
220
198
|
"repeated — a **radically different** strategy is needed.", "");
|
|
221
199
|
}
|
|
222
200
|
// ── v2.13: Workspace Restore ──────────────────────────────────────────
|
|
@@ -242,9 +220,14 @@ recovery) {
|
|
|
242
220
|
// belongs here.
|
|
243
221
|
if (gitHead) {
|
|
244
222
|
lines.push("**The workspace must be returned to the restore point before you " +
|
|
245
|
-
"continue:**", "", `- HEAD must be at \`${gitHead.slice(0, 12)}\``, `- Every file listed above must be back in its state at Round ${toRound}`, `- No leftover change from rounds ${toRound + 1}–${fromRound} may remain`, "", "Your next submission's git observation is compared against that HEAD
|
|
246
|
-
"and
|
|
247
|
-
"
|
|
223
|
+
"continue:**", "", `- HEAD must be at \`${gitHead.slice(0, 12)}\``, `- Every file listed above must be back in its state at Round ${toRound}`, `- No leftover change from rounds ${toRound + 1}–${fromRound} may remain`, "", "Your next submission's git observation is compared against that HEAD " +
|
|
224
|
+
"and against the files Round " + toRound + " already had dirty — work " +
|
|
225
|
+
"that was already in progress then may stay, but a file whose " +
|
|
226
|
+
"fingerprint still matches the failed round, or any change the restore " +
|
|
227
|
+
"point did not already carry, proves the restore did not happen. Submit " +
|
|
228
|
+
"one recovery evaluation after the restore so LoopForge can verify the " +
|
|
229
|
+
"restored starting state before you redo the work; only then continue " +
|
|
230
|
+
"with the next slice.", "");
|
|
248
231
|
}
|
|
249
232
|
else {
|
|
250
233
|
lines.push(`**The workspace must be returned to its state at Round ${toRound}** — ` +
|
|
@@ -350,23 +333,69 @@ function enforceEvidenceContradiction(flags) {
|
|
|
350
333
|
/** v3.3: R-EVID-VERIFY — the verification command's entrypoint changed in
|
|
351
334
|
* the same round the command ran. The runtime executed a script the agent
|
|
352
335
|
* just rewrote, so the "machine observation" has no stable baseline and
|
|
353
|
-
* cannot back the success claim.
|
|
354
|
-
*
|
|
355
|
-
*
|
|
356
|
-
|
|
336
|
+
* cannot back the success claim.
|
|
337
|
+
*
|
|
338
|
+
* v3.8.3: the row owns its whole ladder (RULE_TABLE ladder: "internal").
|
|
339
|
+
*
|
|
340
|
+
* The old text said "resubmit with the entrypoint unchanged", which is not an
|
|
341
|
+
* instruction the agent can carry out when the round CREATED the file — it
|
|
342
|
+
* did not exist to keep unchanged — and the round's baseline never moves, so
|
|
343
|
+
* the same file was re-detected on every retry until the uniform ladder
|
|
344
|
+
* terminated the loop. The recovery text now states the round-start state of
|
|
345
|
+
* each file (the gate puts it in the detail) and the two routes that exist:
|
|
346
|
+
* restore it, or take the rollback so the work is redone in a round whose
|
|
347
|
+
* baseline already contains the entrypoint. Repeated non-restoration is a
|
|
348
|
+
* rollback, not a kill. */
|
|
349
|
+
function enforceVerificationEntrypointTampered(flags, vaultEntries, currentRound, consecutiveRejections, lastRejectionCheck) {
|
|
357
350
|
const flag = flags.find((f) => f.check === CHECK_VERIFICATION_ENTRYPOINT_MODIFIED && f.severity === "error");
|
|
358
351
|
if (!flag)
|
|
359
352
|
return null;
|
|
353
|
+
const check = "verification_entrypoint_modified";
|
|
354
|
+
const ownStreak = lastRejectionCheck === check ? consecutiveRejections : 0;
|
|
355
|
+
if (ownStreak >= 1) {
|
|
356
|
+
// The agent has already been told, once, exactly what to restore and has
|
|
357
|
+
// not restored it. Retrying the same round cannot help — the baseline is
|
|
358
|
+
// frozen on purpose — so escalate to the rollback, which redoes the work
|
|
359
|
+
// in a fresh round where the entrypoint is part of the starting state.
|
|
360
|
+
// `backtrack_enabled` off, or a rollback already committed for this round
|
|
361
|
+
// (which would loop), leaves termination as the honest end.
|
|
362
|
+
if (getPolicy().engine.backtrack_enabled &&
|
|
363
|
+
!hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
364
|
+
return makeEnforcementResult({
|
|
365
|
+
action: "backtrack",
|
|
366
|
+
reason: "The verification command's entrypoint is still not back to its " +
|
|
367
|
+
"round-start state after being asked once, so this round cannot " +
|
|
368
|
+
"produce a trustworthy observation.",
|
|
369
|
+
fix_instructions: "After the rollback, restore the workspace as the Recovery Brief " +
|
|
370
|
+
"states. The restore check requires every skipped file to differ " +
|
|
371
|
+
"from its state at the rollback, so no entrypoint may still be an " +
|
|
372
|
+
"uncommitted change: revert it, or make a script you intend to keep " +
|
|
373
|
+
"part of the committed workspace before the round that runs it. " +
|
|
374
|
+
"Then redo the round.",
|
|
375
|
+
check,
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return makeEnforcementResult({
|
|
379
|
+
action: "terminate",
|
|
380
|
+
reason: "The verification command's entrypoint was changed in the round it " +
|
|
381
|
+
"verified and was not restored after the rollback.",
|
|
382
|
+
check,
|
|
383
|
+
});
|
|
384
|
+
}
|
|
360
385
|
return makeEnforcementResult({
|
|
361
386
|
action: "reject",
|
|
362
387
|
reason: flag.detail,
|
|
363
388
|
fix_instructions: "The verification command entrypoint changed in this round, so its " +
|
|
364
|
-
"result cannot be trusted as machine evidence
|
|
365
|
-
"
|
|
366
|
-
"
|
|
367
|
-
"
|
|
368
|
-
"NOT rewrite the verification
|
|
369
|
-
|
|
389
|
+
"result cannot be trusted as machine evidence — the runtime executed a " +
|
|
390
|
+
"script this round rewrote. To make this round verifiable again: restore " +
|
|
391
|
+
"each of those files to the state it had when the round started (for a " +
|
|
392
|
+
"file that did not exist then, that means removing it), then resubmit " +
|
|
393
|
+
"this same round's SelfEvaluation. Do NOT rewrite the verification " +
|
|
394
|
+
"script to make it pass. If the work genuinely needs that file, this " +
|
|
395
|
+
"round cannot verify it: make the file part of the committed workspace " +
|
|
396
|
+
"first — a script that is not an uncommitted change — and let a later " +
|
|
397
|
+
"round run it.",
|
|
398
|
+
check,
|
|
370
399
|
});
|
|
371
400
|
}
|
|
372
401
|
/** R8 (v2.12/v3.7): The single success-evidence row. The verification gate
|
|
@@ -374,12 +403,20 @@ function enforceVerificationEntrypointTampered(flags) {
|
|
|
374
403
|
* missing-evidence arm (v3.7: the ex-R3 empty_success posture moved into
|
|
375
404
|
* checkSuccessWithoutVerifiedEvidence) or the claims arm (zero verified
|
|
376
405
|
* claims). First occurrence → reject with concrete evidence requirements;
|
|
377
|
-
* second consecutive → terminate.
|
|
378
|
-
|
|
406
|
+
* second consecutive → terminate.
|
|
407
|
+
*
|
|
408
|
+
* v3.8.3: "consecutive" means consecutive for THIS check (L4's own-streak
|
|
409
|
+
* rule, which until now reached only the uniform rows and the contract row).
|
|
410
|
+
* The persisted counter is one scalar, so a success-evidence round arriving
|
|
411
|
+
* after two unrelated rejections used to terminate on its FIRST occurrence. */
|
|
412
|
+
function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections, lastRejectionCheck) {
|
|
379
413
|
const flag = flags.find((f) => f.check === CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE && f.severity === "error");
|
|
380
414
|
if (!flag)
|
|
381
415
|
return null;
|
|
382
|
-
|
|
416
|
+
const ownStreak = lastRejectionCheck === CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE
|
|
417
|
+
? consecutiveRejections
|
|
418
|
+
: 0;
|
|
419
|
+
if (ownStreak >= 2) {
|
|
383
420
|
return makeEnforcementResult({
|
|
384
421
|
action: "terminate",
|
|
385
422
|
reason: "Repeated success claims with zero machine-verifiable evidence.",
|
|
@@ -387,7 +424,7 @@ function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
|
|
|
387
424
|
});
|
|
388
425
|
}
|
|
389
426
|
const escalation = getPolicy().engine.enforcement_escalation_enabled &&
|
|
390
|
-
|
|
427
|
+
ownStreak >= 1
|
|
391
428
|
? buildEscalationNotice()
|
|
392
429
|
: "";
|
|
393
430
|
return makeEnforcementResult({
|
|
@@ -471,12 +508,36 @@ function enforceContractItemsUnverified(flags, currentRound, vaultEntries, conse
|
|
|
471
508
|
* scope. v3.8: scope drift is a MACHINE fact and is no longer waivable by an
|
|
472
509
|
* agent explanation (the drift_clarification channel was deleted) — the
|
|
473
510
|
* agent must revert the out-of-scope changes or close the active contract
|
|
474
|
-
* and declare an extended scope.
|
|
475
|
-
|
|
511
|
+
* and declare an extended scope.
|
|
512
|
+
*
|
|
513
|
+
* v3.8.3: that second route is now actually reachable. The v3.7 wording
|
|
514
|
+
* ("extend the scope in your re-declared round_contract") was structurally
|
|
515
|
+
* dead — a replacement proposal is IGNORED while the active contract is open
|
|
516
|
+
* — so the fix text was rewritten to the only legal route (blocked + the
|
|
517
|
+
* extended scope as the next proposal) without the matching escape being
|
|
518
|
+
* implemented. `scopeDriftWaiver` is that missing half: the coordinator has
|
|
519
|
+
* verified that the round declares blocked AND that its proposal covers every
|
|
520
|
+
* drifted file. What is waived is the REJECTION, never the fact. */
|
|
521
|
+
function enforceScopeDrift(flags, consecutiveRejections, lastRejectionCheck, scopeDriftWaiver) {
|
|
476
522
|
const flag = flags.find((f) => f.check === CHECK_ROUND_SCOPE_DRIFT);
|
|
477
523
|
if (!flag)
|
|
478
524
|
return null;
|
|
479
|
-
|
|
525
|
+
// v3.8.3: the legal exit. A round that declares itself blocked may switch
|
|
526
|
+
// contracts, but only by declaring a proposal that covers every file it
|
|
527
|
+
// changed out of scope — the coverage test is the coordinator's, because
|
|
528
|
+
// only there are the git files and the active contract both in reach.
|
|
529
|
+
//
|
|
530
|
+
// Checked ABOVE the terminate arm on purpose: the counter accumulates while
|
|
531
|
+
// the agent tries to get the declaration right, so an escape that only
|
|
532
|
+
// works at streak 0 or 1 is not an escape — it is a race the agent loses.
|
|
533
|
+
// The flag is NOT suppressed; it stays on the committed round, so
|
|
534
|
+
// audit/explain still report the drift as a machine fact. */
|
|
535
|
+
if (scopeDriftWaiver)
|
|
536
|
+
return null;
|
|
537
|
+
const ownStreak = lastRejectionCheck === CHECK_ROUND_SCOPE_DRIFT
|
|
538
|
+
? consecutiveRejections
|
|
539
|
+
: 0;
|
|
540
|
+
if (ownStreak >= 2) {
|
|
480
541
|
return makeEnforcementResult({
|
|
481
542
|
action: "terminate",
|
|
482
543
|
reason: `Repeated scope drift — the agent keeps changing files outside its ` +
|
|
@@ -485,7 +546,7 @@ function enforceScopeDrift(flags, consecutiveRejections) {
|
|
|
485
546
|
});
|
|
486
547
|
}
|
|
487
548
|
const escalation = getPolicy().engine.enforcement_escalation_enabled &&
|
|
488
|
-
|
|
549
|
+
ownStreak >= 1
|
|
489
550
|
? buildEscalationNotice()
|
|
490
551
|
: "";
|
|
491
552
|
return makeEnforcementResult({
|
|
@@ -498,12 +559,16 @@ function enforceScopeDrift(flags, consecutiveRejections) {
|
|
|
498
559
|
// legal scope change is to CLOSE the active contract first: resubmit with
|
|
499
560
|
// outcome="blocked" (blocker naming the too-narrow scope) and the
|
|
500
561
|
// extended scope as the NEXT round's contract in the same submission.
|
|
562
|
+
// v3.8.3: that route now works, and its one condition is stated here —
|
|
563
|
+
// the new scope must cover EVERY drifted file, not just the listed ones
|
|
564
|
+
// (the detail above truncates its list).
|
|
501
565
|
fix_instructions: "Files changed outside the contract's declared scope. You must either: " +
|
|
502
566
|
"(a) revert the out-of-scope changes, or (b) legally extend the scope: " +
|
|
503
567
|
"a replacement contract is IGNORED while the current one is active, so " +
|
|
504
568
|
"resubmit this work with outcome: \"blocked\" (blocker naming the " +
|
|
505
|
-
"too-narrow scope) plus
|
|
506
|
-
"
|
|
569
|
+
"too-narrow scope) plus a round_contract whose scope covers EVERY file " +
|
|
570
|
+
"listed above (and any other file this round changed outside the current " +
|
|
571
|
+
"scope). Scope drift is a machine fact — no explanation waives " +
|
|
507
572
|
"it. Repeated scope drift terminates the loop." +
|
|
508
573
|
escalation,
|
|
509
574
|
check: "round_scope_drift",
|
|
@@ -543,101 +608,74 @@ function enforceBacktrackNotRestored(flags, vaultEntries = [], currentRound = 0)
|
|
|
543
608
|
});
|
|
544
609
|
}
|
|
545
610
|
/** Shared observation pipeline for the progress evaluator (stall window,
|
|
546
|
-
* flatline tier).
|
|
547
|
-
|
|
548
|
-
function evaluateStallWindow(tier, selfEval, currentRound, vaultEntries,
|
|
611
|
+
* flatline tier). Only observable machine history can establish a stall. */
|
|
612
|
+
function evaluateStallWindow(tier, currentRound, vaultEntries,
|
|
549
613
|
/** v3.8.1: the round's success is machine-verified (`deriveEvidenceStatus`).
|
|
550
614
|
* A verified round is finishing work; a motion window cannot outvote it. */
|
|
551
|
-
machineBackedSuccess
|
|
615
|
+
machineBackedSuccess,
|
|
616
|
+
/** v3.9: the submitted round's own Git motion (`inFlightGitMotion`).
|
|
617
|
+
* null = no Git observation, so no excuse. */
|
|
618
|
+
inFlightMotion) {
|
|
552
619
|
// A round the machine verified is not churn, whatever the window says. This
|
|
553
620
|
// is the machine-backed replacement for the old self-reported `>= 0.95`
|
|
554
621
|
// near-completion guard: it lets a loop FINISH (a closing round that only
|
|
555
622
|
// runs the verification and declares success changes no files).
|
|
556
623
|
if (machineBackedSuccess)
|
|
557
624
|
return null;
|
|
625
|
+
// The round being submitted is part of the recent work: when IT shows
|
|
626
|
+
// machine motion, no window of older rounds can call the loop stalled.
|
|
627
|
+
// Without this the abandoned window that triggered a rollback judges every
|
|
628
|
+
// redo forever — the redo's work is in flight and cannot be in that window.
|
|
629
|
+
// This is the machine replacement for the removed self-reported progress
|
|
630
|
+
// injection; a round that moves nothing still reaches the verdict and its
|
|
631
|
+
// deadlock guard.
|
|
632
|
+
if (inFlightMotion === true)
|
|
633
|
+
return null;
|
|
558
634
|
const window = tier === "progress_stall"
|
|
559
635
|
? 3
|
|
560
636
|
: Math.max(1, getPolicy().engine.stall_lookback_rounds);
|
|
561
637
|
if (currentRound < window)
|
|
562
638
|
return null;
|
|
563
|
-
const progressByRound = progressWindow(vaultEntries, currentRound, selfEval);
|
|
564
|
-
const threshold = getPolicy().evolution.progress_stall_threshold;
|
|
565
|
-
let stalled = false;
|
|
566
|
-
let detail = "";
|
|
567
|
-
// v3.8.1: when the MACHINE series is observable it decides the verdict, and
|
|
568
|
-
// the agent's own estimates are not consulted at all. Reading the estimates
|
|
569
|
-
// first (the pre-3.8.1 order) let an agent cancel a stall by writing a
|
|
570
|
-
// rising `progress_estimate` every round — a verdict about machine motion
|
|
571
|
-
// that the agent's own report could move, which is the one thing an external
|
|
572
|
-
// verifier must not allow. Machine motion still only ever EXCUSES: it can
|
|
573
|
-
// veto a stall it did not create; it can never create one.
|
|
574
|
-
//
|
|
575
|
-
// The self-reported window is the FALLBACK for a loop with no machine
|
|
576
|
-
// history to read (no git provider, or fewer rounds than the window). There
|
|
577
|
-
// the evaluator can only catch a stall the agent's own reports state, and
|
|
578
|
-
// the near-completion guard keeps it from rejecting a loop that reports
|
|
579
|
-
// itself nearly done.
|
|
580
639
|
const machine = machineProgressSeries(vaultEntries, currentRound, window);
|
|
581
|
-
if (machine
|
|
582
|
-
stalled = machine.every((value) => !value);
|
|
583
|
-
detail = tier === "progress_stall"
|
|
584
|
-
? `no machine-observed changes in the last ${window} rounds`
|
|
585
|
-
: `no machine-observed changes for ${window} consecutive rounds`;
|
|
586
|
-
}
|
|
587
|
-
else if (progressByRound.size >= window) {
|
|
588
|
-
const recent = [...progressByRound.keys()].sort((a, b) => a - b).slice(-window);
|
|
589
|
-
if (recent[0] < currentRound - window)
|
|
590
|
-
return null;
|
|
591
|
-
const values = recent.map((round) => progressByRound.get(round));
|
|
592
|
-
if (values[values.length - 1] >= 0.95)
|
|
593
|
-
return null;
|
|
594
|
-
if (tier === "progress_stall") {
|
|
595
|
-
stalled = values.slice(1).every((value, index) => value - values[index] < threshold);
|
|
596
|
-
detail = `${values.map((value) => `${(value * 100).toFixed(0)}%`).join(" → ")}` +
|
|
597
|
-
" (agent-reported, no machine history to read)";
|
|
598
|
-
}
|
|
599
|
-
else {
|
|
600
|
-
const first = values[0];
|
|
601
|
-
stalled = values.every((value) => Math.abs(value - first) < 1e-10);
|
|
602
|
-
detail = `completely flat at ${(first * 100).toFixed(0)}%`;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
else {
|
|
606
|
-
return null;
|
|
607
|
-
}
|
|
608
|
-
if (!stalled)
|
|
640
|
+
if (machine === null || machine.some((value) => value))
|
|
609
641
|
return null;
|
|
610
|
-
|
|
642
|
+
const detail = tier === "progress_stall"
|
|
643
|
+
? `no machine-observed changes in the last ${window} rounds`
|
|
644
|
+
: `no machine-observed changes for ${window} consecutive rounds`;
|
|
645
|
+
return { tier, detail, window };
|
|
611
646
|
}
|
|
612
647
|
/** Shared progress escalation and backtrack deadlock guard (v3.7: one
|
|
613
|
-
* evaluator). The flatline tier keeps its steeper
|
|
648
|
+
* evaluator). The flatline tier keeps its steeper ladder while
|
|
614
649
|
* using the same mechanics — the tier is an internal diagnostic only. */
|
|
615
|
-
function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections) {
|
|
616
|
-
const { tier, detail, window
|
|
650
|
+
function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck) {
|
|
651
|
+
const { tier, detail, window } = verdict;
|
|
617
652
|
const flatline = tier === "progress_flatline";
|
|
618
653
|
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
619
654
|
const stalledReason = flatline
|
|
620
655
|
? `Progress has been ${detail} for ${window} consecutive rounds.`
|
|
621
|
-
: `Progress has stalled: ${detail}
|
|
622
|
-
`${threshold > 0 ? ` (delta < ${(threshold * 100).toFixed(0)}% each round)` : ""}.`;
|
|
656
|
+
: `Progress has stalled: ${detail}.`;
|
|
623
657
|
// v3.7: both tiers emit the single progress_stall check id — the tier
|
|
624
658
|
// survives only in the reason/fix wording (former progress_flatline id).
|
|
625
659
|
const check = "progress_stall";
|
|
626
|
-
|
|
660
|
+
// v3.8.3: the stall escalates on its OWN streak. The persisted counter is
|
|
661
|
+
// one scalar shared by every check, so two rejections of any other kind used
|
|
662
|
+
// to turn a first-time stall straight into termination.
|
|
663
|
+
const ownStreak = lastRejectionCheck === check ? consecutiveRejections : 0;
|
|
664
|
+
if ((flatline && ownStreak >= 1) || ownStreak >= 2) {
|
|
627
665
|
return makeEnforcementResult({
|
|
628
666
|
action: "terminate",
|
|
629
667
|
reason: `${stalledReason} The stall persisted after escalation. Terminating loop.`,
|
|
630
668
|
check,
|
|
631
669
|
});
|
|
632
670
|
}
|
|
633
|
-
if (
|
|
671
|
+
if (ownStreak >= 1 && !escalation) {
|
|
634
672
|
return makeEnforcementResult({
|
|
635
673
|
action: "terminate",
|
|
636
674
|
reason: `${stalledReason} The stall persisted after the previous rejection. Terminating loop.`,
|
|
637
675
|
check,
|
|
638
676
|
});
|
|
639
677
|
}
|
|
640
|
-
const escalated = flatline ? escalation :
|
|
678
|
+
const escalated = flatline ? escalation : ownStreak >= 1 && escalation;
|
|
641
679
|
if (escalated && getPolicy().engine.backtrack_enabled) {
|
|
642
680
|
if (hasCommittedBacktrack(vaultEntries, currentRound)) {
|
|
643
681
|
return makeEnforcementResult({
|
|
@@ -672,19 +710,13 @@ function resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiv
|
|
|
672
710
|
check,
|
|
673
711
|
});
|
|
674
712
|
}
|
|
675
|
-
/**
|
|
676
|
-
*
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
* shorter lookback configs), where it applies its steeper disposition
|
|
681
|
-
* ladder. Machine git motion is exculpatory only — it may veto a
|
|
682
|
-
* self-reported stall but can never create one. */
|
|
683
|
-
function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, consecutiveRejections, machineBackedSuccess) {
|
|
684
|
-
const verdict = evaluateStallWindow("progress_stall", selfEval, currentRound, vaultEntries, machineBackedSuccess)
|
|
685
|
-
?? evaluateStallWindow("progress_flatline", selfEval, currentRound, vaultEntries, machineBackedSuccess);
|
|
713
|
+
/** The stall-tier window is consulted first. A shorter configured flatline
|
|
714
|
+
* lookback retains its steeper disposition ladder. */
|
|
715
|
+
function enforceProgressStall(_flags, currentRound, vaultEntries, consecutiveRejections, machineBackedSuccess, lastRejectionCheck, inFlightMotion) {
|
|
716
|
+
const verdict = evaluateStallWindow("progress_stall", currentRound, vaultEntries, machineBackedSuccess, inFlightMotion)
|
|
717
|
+
?? evaluateStallWindow("progress_flatline", currentRound, vaultEntries, machineBackedSuccess, inFlightMotion);
|
|
686
718
|
return verdict
|
|
687
|
-
? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections)
|
|
719
|
+
? resolveStallDisposition(verdict, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck)
|
|
688
720
|
: null;
|
|
689
721
|
}
|
|
690
722
|
/** R6: Two consecutive rejections → escalate (v2.7) or terminate.
|
|
@@ -694,7 +726,15 @@ function enforceProgressStall(selfEval, _flags, currentRound, vaultEntries, cons
|
|
|
694
726
|
* instead of terminating immediately. Termination occurs on the third
|
|
695
727
|
* consecutive rejection. When the flag is disabled, behavior is unchanged
|
|
696
728
|
* (terminate at ≥2 rejections). */
|
|
697
|
-
function enforceMaxRejections(consecutiveRejections) {
|
|
729
|
+
function enforceMaxRejections(consecutiveRejections, driftWaived) {
|
|
730
|
+
// v3.8.3: this row is the catch-all for a round that LOOKS clean while the
|
|
731
|
+
// persisted counter says otherwise. A round waived past scope drift is not
|
|
732
|
+
// that shape: it declares blocked and its new proposal covers every file it
|
|
733
|
+
// drifted into, so the counter it carries is the drift streak it just
|
|
734
|
+
// resolved — not unresolved persistence. Without this, the legal exit would
|
|
735
|
+
// still dead-end here at a counter of 2 or more.
|
|
736
|
+
if (driftWaived)
|
|
737
|
+
return null;
|
|
698
738
|
const escalation = getPolicy().engine.enforcement_escalation_enabled;
|
|
699
739
|
if (escalation) {
|
|
700
740
|
if (consecutiveRejections >= 3) {
|
|
@@ -783,7 +823,11 @@ const RULE_TABLE = [
|
|
|
783
823
|
{ checks: ["success_with_remaining_criteria"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
|
|
784
824
|
{ checks: ["recurring_violation"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
|
|
785
825
|
{ checks: ["required_command_failed", "command_evidence_mismatch", "outcome_success_contradiction"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
|
|
786
|
-
|
|
826
|
+
// v3.8.3: own ladder — reject → backtrack → terminate. A rewritten
|
|
827
|
+
// entrypoint is not fixed by asking again (the round's baseline is frozen on
|
|
828
|
+
// purpose), so the second occurrence rolls the work back instead of killing
|
|
829
|
+
// the loop. See enforceVerificationEntrypointTampered.
|
|
830
|
+
{ checks: ["verification_entrypoint_modified"], ladder: "internal" },
|
|
787
831
|
{ checks: ["success_without_verified_evidence"], ladder: "internal" },
|
|
788
832
|
{ checks: ["round_scope_drift"], ladder: "internal" },
|
|
789
833
|
// v3.8.1: "internal" — the handler owns this row's ladder (it must terminate
|
|
@@ -844,7 +888,16 @@ export function enforceRound(selfEval, verifyResult, currentRound, vaultEntries,
|
|
|
844
888
|
* counter only ever measures one check's streak — this field names that
|
|
845
889
|
* check and lets uniform rows escalate on THEIR OWN streak instead of
|
|
846
890
|
* inheriting an unrelated history. */
|
|
847
|
-
lastRejectionCheck = ""
|
|
891
|
+
lastRejectionCheck = "",
|
|
892
|
+
/** v3.8.3: the coordinator has verified that this round declares itself
|
|
893
|
+
* blocked AND that its new contract proposal's scope covers every file the
|
|
894
|
+
* round changed outside the active contract's scope. Computed there, not
|
|
895
|
+
* here: neither the observations nor the active contract reach this
|
|
896
|
+
* function. See `enforceScopeDrift`. */
|
|
897
|
+
scopeDriftWaiver = false,
|
|
898
|
+
/** v3.9: the submitted round's own Git motion — see `inFlightGitMotion`.
|
|
899
|
+
* null when no Git observation was made. */
|
|
900
|
+
inFlightMotion = null) {
|
|
848
901
|
const { flags } = verifyResult;
|
|
849
902
|
// Run enforcement rules in priority order — the array order IS the
|
|
850
903
|
// priority; the R-labels in the comments are historical (RULE_TABLE is
|
|
@@ -858,7 +911,7 @@ lastRejectionCheck = "") {
|
|
|
858
911
|
// v3.3: R-EVID-VERIFY — the verification command entrypoint changed in
|
|
859
912
|
// the same round it ran. Runs before R8: when the entrypoint is tainted
|
|
860
913
|
// R8 also fires, but this reason is more specific.
|
|
861
|
-
() => enforceVerificationEntrypointTampered(flags),
|
|
914
|
+
() => enforceVerificationEntrypointTampered(flags, vaultEntries, currentRound, consecutiveRejections, lastRejectionCheck),
|
|
862
915
|
// v3.5: contract completion claimed without its verification_plan
|
|
863
916
|
// commands passing — runs before R-C1: the completion-truth question
|
|
864
917
|
// outranks boundary nuance.
|
|
@@ -867,20 +920,22 @@ lastRejectionCheck = "") {
|
|
|
867
920
|
// contract's own reason.)
|
|
868
921
|
// v2.12/v3.7: R8 — the single success-evidence row (empty/missing-evidence
|
|
869
922
|
// arm merged from the former R3 empty_success posture + claims arm)
|
|
870
|
-
() => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections),
|
|
923
|
+
() => enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections, lastRejectionCheck),
|
|
871
924
|
// v3.3: R-C2 — files changed outside the contract's declared scope
|
|
872
|
-
() => enforceScopeDrift(flags, consecutiveRejections),
|
|
925
|
+
() => enforceScopeDrift(flags, consecutiveRejections, lastRejectionCheck, scopeDriftWaiver),
|
|
873
926
|
// v3.8: verification debt — contract items claimed met without machine
|
|
874
927
|
// verification (fires only after the configured streak)
|
|
875
928
|
() => enforceContractItemsUnverified(flags, currentRound, vaultEntries, consecutiveRejections, lastRejectionCheck),
|
|
876
929
|
// v3.7: single progress evaluator (former R4/R5 slots merged) —
|
|
877
930
|
// reject → backtrack → terminate with the deadlock guard
|
|
878
|
-
() => enforceProgressStall(
|
|
931
|
+
() => enforceProgressStall(flags, currentRound, vaultEntries, consecutiveRejections, verifyResult.machineBackedSuccess, lastRejectionCheck, inFlightMotion),
|
|
879
932
|
// R6: rejection-counter catch-all (evaluated only when no row above
|
|
880
933
|
// fired — a clean-looking round carrying a high persisted counter)
|
|
881
934
|
// v3.7.1: cited gate without an approved human decision
|
|
882
935
|
() => enforceUserGateUnresolved(flags),
|
|
883
|
-
|
|
936
|
+
// v3.8.3: the counter is shared, so this row must know that a scope-drift
|
|
937
|
+
// waiver already excused the streak it carries (see enforceMaxRejections).
|
|
938
|
+
() => enforceMaxRejections(consecutiveRejections, scopeDriftWaiver && flags.some((f) => f.check === CHECK_ROUND_SCOPE_DRIFT)),
|
|
884
939
|
// v2.12: R9 — post-backtrack workspace not restored
|
|
885
940
|
() => enforceBacktrackNotRestored(flags, vaultEntries, currentRound),
|
|
886
941
|
];
|