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.
Files changed (75) hide show
  1. package/README.md +145 -123
  2. package/dist/canonical-state.d.ts +1 -2
  3. package/dist/canonical-state.d.ts.map +1 -1
  4. package/dist/canonical-state.js +4 -12
  5. package/dist/canonical-state.js.map +1 -1
  6. package/dist/cli.d.ts.map +1 -1
  7. package/dist/cli.js +115 -15
  8. package/dist/cli.js.map +1 -1
  9. package/dist/committed-round.d.ts +35 -7
  10. package/dist/committed-round.d.ts.map +1 -1
  11. package/dist/committed-round.js +81 -23
  12. package/dist/committed-round.js.map +1 -1
  13. package/dist/enforcement-gate.d.ts +21 -5
  14. package/dist/enforcement-gate.d.ts.map +1 -1
  15. package/dist/enforcement-gate.js +194 -139
  16. package/dist/enforcement-gate.js.map +1 -1
  17. package/dist/engine.d.ts.map +1 -1
  18. package/dist/engine.js +4 -5
  19. package/dist/engine.js.map +1 -1
  20. package/dist/evidence-provider.d.ts +44 -2
  21. package/dist/evidence-provider.d.ts.map +1 -1
  22. package/dist/evidence-provider.js +125 -23
  23. package/dist/evidence-provider.js.map +1 -1
  24. package/dist/loop-compiler.d.ts.map +1 -1
  25. package/dist/loop-compiler.js +1 -4
  26. package/dist/loop-compiler.js.map +1 -1
  27. package/dist/loop-store.d.ts.map +1 -1
  28. package/dist/loop-store.js +44 -7
  29. package/dist/loop-store.js.map +1 -1
  30. package/dist/mcp/round-lifecycle.d.ts +32 -1
  31. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  32. package/dist/mcp/round-lifecycle.js +102 -14
  33. package/dist/mcp/round-lifecycle.js.map +1 -1
  34. package/dist/mcp/server.d.ts.map +1 -1
  35. package/dist/mcp/server.js +7 -0
  36. package/dist/mcp/server.js.map +1 -1
  37. package/dist/mcp/tools.d.ts.map +1 -1
  38. package/dist/mcp/tools.js +0 -6
  39. package/dist/mcp/tools.js.map +1 -1
  40. package/dist/policy.d.ts +14 -6
  41. package/dist/policy.d.ts.map +1 -1
  42. package/dist/policy.js +52 -14
  43. package/dist/policy.js.map +1 -1
  44. package/dist/prompt-assembler.d.ts.map +1 -1
  45. package/dist/prompt-assembler.js +8 -24
  46. package/dist/prompt-assembler.js.map +1 -1
  47. package/dist/protocol.d.ts +4 -8
  48. package/dist/protocol.d.ts.map +1 -1
  49. package/dist/protocol.js +0 -2
  50. package/dist/protocol.js.map +1 -1
  51. package/dist/round-coordinator.d.ts +43 -0
  52. package/dist/round-coordinator.d.ts.map +1 -1
  53. package/dist/round-coordinator.js +104 -15
  54. package/dist/round-coordinator.js.map +1 -1
  55. package/dist/round-driver.d.ts +4 -0
  56. package/dist/round-driver.d.ts.map +1 -1
  57. package/dist/round-driver.js +2 -0
  58. package/dist/round-driver.js.map +1 -1
  59. package/dist/round-transaction.d.ts +5 -0
  60. package/dist/round-transaction.d.ts.map +1 -1
  61. package/dist/round-transaction.js +3 -0
  62. package/dist/round-transaction.js.map +1 -1
  63. package/dist/self-eval.d.ts +10 -0
  64. package/dist/self-eval.d.ts.map +1 -1
  65. package/dist/self-eval.js +14 -4
  66. package/dist/self-eval.js.map +1 -1
  67. package/dist/verification-gate.d.ts +18 -1
  68. package/dist/verification-gate.d.ts.map +1 -1
  69. package/dist/verification-gate.js +133 -41
  70. package/dist/verification-gate.js.map +1 -1
  71. package/dist/version.d.ts +1 -1
  72. package/dist/version.js +1 -1
  73. package/loop_policy.json +58 -59
  74. package/package.json +1 -1
  75. 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;AA0FvB;;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;;;sCAGsC;AACtC,wBAAgB,0BAA0B,CACxC,YAAY,EAAE,MAAM,EACpB,YAAY,EAAE,UAAU,EAAE,GACzB,MAAM,GAAG,IAAI,CAWf;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,CAmKR;AAwrBD,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,GAC9B,iBAAiB,CAoGnB;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"}
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"}
@@ -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
- /** v2.12: Git HEAD commit hash at the restore point. Read from the restore
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 evidence = machineEvidenceForRound(committed);
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 === "string" && head.length > 0)
136
- return head;
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 verifiable forward motion — no new files changed, 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 a file whose fingerprint still matches the failed round proves the " +
247
- "restore did not happen.", "");
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. Reject so the agent resubmits the SAME
354
- * round with the entrypoint unchanged (a freshly created entrypoint passes
355
- * on the redo — the diff no longer contains it). */
356
- function enforceVerificationEntrypointTampered(flags) {
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. You must: (a) keep the " +
365
- "verification command (and its entrypoint files) stable, (b) resubmit " +
366
- "your SelfEvaluation for the SAME round with the entrypoint unchanged " +
367
- "so the runtime can re-run it against a stable baseline, and (c) do " +
368
- "NOT rewrite the verification script to make it pass.",
369
- check: "verification_entrypoint_modified",
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
- function enforceSuccessWithoutVerifiedEvidence(flags, consecutiveRejections) {
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
- if (consecutiveRejections >= 2) {
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
- consecutiveRejections >= 1
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. Repeated drift terminates. */
475
- function enforceScopeDrift(flags, consecutiveRejections) {
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
- if (consecutiveRejections >= 2) {
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
- consecutiveRejections >= 1
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 the extended scope as the next round's " +
506
- "round_contract. Scope drift is a machine fact — no explanation waives " +
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). Machine git motion is exculpatory only: it may veto a
547
- * self-reported stall but can never create one. */
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 !== null) {
582
- stalled = machine.every((value) => !value);
583
- detail = tier === "progress_stall"
584
- ? `no machine-observed changes in the last ${window} rounds`
585
- : `no machine-observed changes for ${window} consecutive rounds`;
586
- }
587
- else if (progressByRound.size >= window) {
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
- return { tier, detail, window, threshold };
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 threshold-0 ladder while
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, threshold } = verdict;
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} over the last ${window} rounds` +
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
- if ((flatline && consecutiveRejections >= 1) || consecutiveRejections >= 2) {
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 (consecutiveRejections >= 1 && !escalation) {
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 : consecutiveRejections >= 1 && 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
- /** v3.7: The single progress evaluator (former R4 stall and R5 flatline rows
676
- * merged into one). The stall-tier window is consulted first — under the
677
- * default positive threshold its predicate covers exactly-flat runs too,
678
- * reproducing the v3.6 R4-before-R5 shadowing; the flatline tier is
679
- * consulted only when the stall predicate cannot hold (threshold ≤ 0,
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
- { checks: ["verification_entrypoint_modified"], ladder: "uniform", terminalAfter: 2, noticeOnRepeat: true },
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(selfEval, flags, currentRound, vaultEntries, consecutiveRejections, verifyResult.machineBackedSuccess),
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
- () => enforceMaxRejections(consecutiveRejections),
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
  ];