loopforge 3.6.0 → 3.7.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.
Files changed (109) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +28 -13
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +3 -8
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +23 -4
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +148 -47
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +8 -16
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts.map +1 -1
  14. package/dist/cognitive-facts.js +2 -3
  15. package/dist/cognitive-facts.js.map +1 -1
  16. package/dist/cognitive-governance.d.ts +30 -7
  17. package/dist/cognitive-governance.d.ts.map +1 -1
  18. package/dist/cognitive-governance.js +75 -22
  19. package/dist/cognitive-governance.js.map +1 -1
  20. package/dist/committed-round.d.ts +48 -4
  21. package/dist/committed-round.d.ts.map +1 -1
  22. package/dist/committed-round.js +177 -10
  23. package/dist/committed-round.js.map +1 -1
  24. package/dist/enforcement-gate.d.ts +24 -19
  25. package/dist/enforcement-gate.d.ts.map +1 -1
  26. package/dist/enforcement-gate.js +216 -146
  27. package/dist/enforcement-gate.js.map +1 -1
  28. package/dist/engine.d.ts +10 -5
  29. package/dist/engine.d.ts.map +1 -1
  30. package/dist/engine.js +52 -31
  31. package/dist/engine.js.map +1 -1
  32. package/dist/evidence-claims.d.ts.map +1 -1
  33. package/dist/evidence-claims.js +3 -9
  34. package/dist/evidence-claims.js.map +1 -1
  35. package/dist/evidence-provider.d.ts +13 -19
  36. package/dist/evidence-provider.d.ts.map +1 -1
  37. package/dist/evidence-provider.js +65 -100
  38. package/dist/evidence-provider.js.map +1 -1
  39. package/dist/generate-schema.js +104 -43
  40. package/dist/generate-schema.js.map +1 -1
  41. package/dist/index.d.ts +3 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +2 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/loop-compiler.d.ts +18 -10
  46. package/dist/loop-compiler.d.ts.map +1 -1
  47. package/dist/loop-compiler.js +205 -343
  48. package/dist/loop-compiler.js.map +1 -1
  49. package/dist/loop-store.d.ts +11 -16
  50. package/dist/loop-store.d.ts.map +1 -1
  51. package/dist/loop-store.js +21 -63
  52. package/dist/loop-store.js.map +1 -1
  53. package/dist/mcp/round-lifecycle.d.ts +18 -3
  54. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  55. package/dist/mcp/round-lifecycle.js +162 -35
  56. package/dist/mcp/round-lifecycle.js.map +1 -1
  57. package/dist/mcp/server.d.ts.map +1 -1
  58. package/dist/mcp/server.js +12 -3
  59. package/dist/mcp/server.js.map +1 -1
  60. package/dist/mcp/session.d.ts +35 -4
  61. package/dist/mcp/session.d.ts.map +1 -1
  62. package/dist/mcp/session.js +169 -52
  63. package/dist/mcp/session.js.map +1 -1
  64. package/dist/mcp/tools.d.ts.map +1 -1
  65. package/dist/mcp/tools.js +170 -39
  66. package/dist/mcp/tools.js.map +1 -1
  67. package/dist/policy.d.ts +10 -13
  68. package/dist/policy.d.ts.map +1 -1
  69. package/dist/policy.js +3 -6
  70. package/dist/policy.js.map +1 -1
  71. package/dist/prompt-assembler.d.ts +6 -4
  72. package/dist/prompt-assembler.d.ts.map +1 -1
  73. package/dist/prompt-assembler.js +46 -100
  74. package/dist/prompt-assembler.js.map +1 -1
  75. package/dist/protocol.d.ts +52 -51
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +5 -28
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/round-coordinator.d.ts +19 -0
  80. package/dist/round-coordinator.d.ts.map +1 -1
  81. package/dist/round-coordinator.js +63 -4
  82. package/dist/round-coordinator.js.map +1 -1
  83. package/dist/round-driver.d.ts +4 -3
  84. package/dist/round-driver.d.ts.map +1 -1
  85. package/dist/round-driver.js +3 -10
  86. package/dist/round-driver.js.map +1 -1
  87. package/dist/round-transaction.d.ts +4 -0
  88. package/dist/round-transaction.d.ts.map +1 -1
  89. package/dist/round-transaction.js +9 -0
  90. package/dist/round-transaction.js.map +1 -1
  91. package/dist/self-eval.d.ts +41 -3
  92. package/dist/self-eval.d.ts.map +1 -1
  93. package/dist/self-eval.js +125 -13
  94. package/dist/self-eval.js.map +1 -1
  95. package/dist/token-utils.d.ts +7 -3
  96. package/dist/token-utils.d.ts.map +1 -1
  97. package/dist/token-utils.js +18 -3
  98. package/dist/token-utils.js.map +1 -1
  99. package/dist/verification-gate.d.ts +32 -24
  100. package/dist/verification-gate.d.ts.map +1 -1
  101. package/dist/verification-gate.js +262 -178
  102. package/dist/verification-gate.js.map +1 -1
  103. package/dist/version.d.ts +5 -0
  104. package/dist/version.d.ts.map +1 -0
  105. package/dist/version.js +5 -0
  106. package/dist/version.js.map +1 -0
  107. package/loop_policy.json +19 -7
  108. package/package.json +6 -6
  109. package/skills/perception/SKILL.md +56 -16
@@ -11,26 +11,41 @@
11
11
  * this round is excluded from the success trend (NOT
12
12
  * modified). Flags become hard constraints — the agent
13
13
  * must respond in the next round.
14
+ *
15
+ * v3.7: every check belongs to one of four verification domains (CHECK_DOMAIN
16
+ * below): evaluation consistency / evidence integrity / plan & contract /
17
+ * progress & recovery. A domain describes a check's semantic job — it never
18
+ * changes run order or verdict aggregation. Round Contract checks are further
19
+ * framed as declaration / execution / closure stages in their doc comments.
14
20
  */
15
21
  import { getPolicy, isConfiguredCommand } from "./policy.js";
16
22
  import { makeVerificationFlag, makeVerificationResult } from "./protocol.js";
17
- import { jaccardSimilarity, tokenize, entryRound as sharedEntryRound, isRecord } from "./token-utils.js";
23
+ import { jaccardSimilarity, tokenize, isRecord, entryRound, extractFilePathTokens } from "./token-utils.js";
18
24
  import { deriveSubGoalId } from "./loop-compiler.js";
19
- import { committedRoundsFromEntries, machineGitMotionSeries } from "./committed-round.js";
25
+ import { committedRoundsFromEntries, entrySubGoalUpdates, machineGitMotionSeries, entryViolations, } from "./committed-round.js";
20
26
  import { contractRoundEvaluations, contractDoneWhenSatisfied, contractItemMatches, deriveActiveRoundContract, } from "./round-contract.js";
21
27
  import { stableStringify } from "./canonical-state.js";
22
28
  import { deriveClaimView, resolveRoundFiles } from "./evidence-claims.js";
23
29
  import { effectiveSuccess, parseRoundContract } from "./self-eval.js";
30
+ import { deriveGate, preflightStructuredGate } from "./cognitive-governance.js";
24
31
  // ═══════════════════════════════════════════════════════════════════════════
25
32
  // v2.12: Check-name constants — single source of truth so the enforcement
26
33
  // gate and audit can reference checks without string-literal drift.
34
+ //
35
+ // v3.7: every check belongs to one of four verification domains (CHECK_DOMAIN
36
+ // below). The flag set was converged from 27 to 24:
37
+ // - progress_regression removed — self-report-only decoration; machine-side
38
+ // progress policing lives in the enforcement gate's progress stall row.
39
+ // - empty_change_with_passing removed — success without machine backing is
40
+ // already covered by the success-evidence arms below.
41
+ // - success_claim_conflict merged into outcome_success_contradiction.
27
42
  // ═══════════════════════════════════════════════════════════════════════════
28
- export const CHECK_PROGRESS_REGRESSION = "progress_regression";
29
- export const CHECK_EMPTY_CHANGE_WITH_PASSING = "empty_change_with_passing";
30
43
  export const CHECK_SUCCESS_WITH_REMAINING_CRITERIA = "success_with_remaining_criteria";
31
44
  export const CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE = "success_without_verified_evidence";
45
+ // v3.7: single id for the outcome-vs-success axis — error when a declared
46
+ // success contradicts success=false, warn when success=true but the outcome
47
+ // declares a non-success (the former success_claim_conflict direction).
32
48
  export const CHECK_OUTCOME_SUCCESS_CONTRADICTION = "outcome_success_contradiction";
33
- export const CHECK_SUCCESS_CLAIM_CONFLICT = "success_claim_conflict";
34
49
  export const CHECK_BLOCKED_WITHOUT_BLOCKER = "blocked_without_blocker";
35
50
  export const CHECK_RETROACTIVE_CLAIM_BAD_ROUND = "retroactive_claim_bad_round";
36
51
  export const CHECK_RETROACTIVE_CLAIM_UNVERIFIED = "retroactive_claim_unverified";
@@ -62,6 +77,13 @@ export const CHECK_CRITERIA_CLAIMS_UNVERIFIED = "criteria_claims_unverified";
62
77
  // silent when their target is absent: contract-less rounds behave exactly
63
78
  // as before these checks existed, and round 1 (no committed rounds → no
64
79
  // active contract) never produces declaration off-by-one noise.
80
+ // v3.7: the checks are framed as three contract stages — Declaration
81
+ // (proposal quality: round_underspecified / round_unverifiable /
82
+ // contract_premature), Execution (conformance under the ACTIVE contract:
83
+ // round_scope_drift; premature_boundary's silently-dropped arm), Closure
84
+ // (completion truth: contract_completion_unverified; premature_boundary's
85
+ // claimed-met-unverified arm). The framing is documentation only — no phase
86
+ // field exists on the contract.
65
87
  /** Contract proposed with an empty done_when — nothing is promised, so
66
88
  * nothing can be verified at the boundary. Warn: the contract can be fixed
67
89
  * by re-declaring next round. */
@@ -91,25 +113,45 @@ export const CHECK_CONTRACT_COMPLETION_UNVERIFIED = "contract_completion_unverif
91
113
  * the active one closes. Warn: the walker still ignores it; this only
92
114
  * surfaces the otherwise-silent state. */
93
115
  export const CHECK_CONTRACT_PREMATURE = "contract_premature";
116
+ /** v3.7.1: A cited gate (evaluation.gate_ids) is not approved. Opt-in:
117
+ * checked ONLY when policy.gate.enabled — citations are meaningless when
118
+ * the gate layer is off. Error → enforcement rejects the round. */
119
+ export const CHECK_USER_GATE_UNRESOLVED = "user_gate_unresolved";
120
+ export const CHECK_DOMAIN = {
121
+ // ── evaluation_consistency ────────────────────────────────────────────────
122
+ [CHECK_SUCCESS_WITH_REMAINING_CRITERIA]: "evaluation_consistency",
123
+ [CHECK_OUTCOME_SUCCESS_CONTRADICTION]: "evaluation_consistency",
124
+ [CHECK_BLOCKED_WITHOUT_BLOCKER]: "evaluation_consistency",
125
+ [CHECK_RETROACTIVE_CLAIM_BAD_ROUND]: "evaluation_consistency",
126
+ [CHECK_RETROACTIVE_CLAIM_UNVERIFIED]: "evaluation_consistency",
127
+ [CHECK_DUPLICATE_CONSTRAINT_DISCOVERY]: "evaluation_consistency",
128
+ [CHECK_RECURRING_VIOLATION]: "evaluation_consistency",
129
+ [CHECK_RETRACT_FRESH_CONSTRAINT]: "evaluation_consistency",
130
+ // ── evidence_integrity ────────────────────────────────────────────────────
131
+ [CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE]: "evidence_integrity",
132
+ [CHECK_CRITERIA_CLAIMS_UNVERIFIED]: "evidence_integrity",
133
+ [CHECK_EVIDENCE_INTEGRITY]: "evidence_integrity",
134
+ [CHECK_REQUIRED_COMMAND_FAILED]: "evidence_integrity",
135
+ [CHECK_COMMAND_EVIDENCE_MISMATCH]: "evidence_integrity",
136
+ [CHECK_VERIFICATION_ENTRYPOINT_MODIFIED]: "evidence_integrity",
137
+ [CHECK_TEST_FILES_MODIFIED]: "evidence_integrity",
138
+ // ── plan_contract ─────────────────────────────────────────────────────────
139
+ [CHECK_INTENT_DRIFT]: "plan_contract",
140
+ [CHECK_SUBGOAL_DRIFT]: "plan_contract",
141
+ [CHECK_ROUND_UNDERSPECIFIED]: "plan_contract",
142
+ [CHECK_ROUND_UNVERIFIABLE]: "plan_contract",
143
+ [CHECK_ROUND_SCOPE_DRIFT]: "plan_contract",
144
+ [CHECK_PREMATURE_BOUNDARY]: "plan_contract",
145
+ [CHECK_CONTRACT_COMPLETION_UNVERIFIED]: "plan_contract",
146
+ [CHECK_CONTRACT_PREMATURE]: "plan_contract",
147
+ // v3.7.1: cited-gate authorization conformance (opt-in blocking layer).
148
+ [CHECK_USER_GATE_UNRESOLVED]: "plan_contract",
149
+ // ── progress_recovery ─────────────────────────────────────────────────────
150
+ [CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED]: "progress_recovery",
151
+ };
94
152
  // ═══════════════════════════════════════════════════════════════════════════
95
153
  // Helpers
96
154
  // ═══════════════════════════════════════════════════════════════════════════
97
- /** Extract the round number from a vault entry's loop_lineage.
98
- * Returns 0 if the entry has no lineage or no round field.
99
- * In practice, persistLoopLineage always writes round ≥ 1, so 0
100
- * unambiguously means "not a valid round entry" in this context.
101
- * Exported for reuse by enforcement-gate.ts. */
102
- export function entryRound(entry) {
103
- return sharedEntryRound(entry);
104
- }
105
- /** Read constraint_violations from a vault entry (entry-level, stored from
106
- * the previous round's last_round_result at persist time). */
107
- function entryViolations(entry) {
108
- const viols = entry.constraint_violations;
109
- if (Array.isArray(viols))
110
- return viols.filter((v) => typeof v === "string");
111
- return [];
112
- }
113
155
  // ── v3.3: Round Contract scope matching ─────────────────────────────────────
114
156
  /** Normalize a contract scope entry: backslashes → forward slashes, strip
115
157
  * leading "./", trim, strip trailing slashes. "." / "./" / "" normalize to
@@ -248,44 +290,6 @@ export function machineProgressSeries(vaultEntries, currentRound, lookback) {
248
290
  // ═══════════════════════════════════════════════════════════════════════════
249
291
  // Individual checks — each returns a VerificationFlag or null
250
292
  // ═══════════════════════════════════════════════════════════════════════════
251
- function checkProgressRegression(selfEval, prevSelfEval) {
252
- if (!prevSelfEval?.execution_evidence)
253
- return null;
254
- if (!selfEval.execution_evidence)
255
- return null;
256
- const prevProgress = prevSelfEval.execution_evidence.progress_estimate;
257
- const currProgress = selfEval.execution_evidence.progress_estimate;
258
- if (typeof prevProgress !== "number" || typeof currProgress !== "number")
259
- return null;
260
- // Use delta with epsilon to avoid IEEE 754 rounding issues (0.8 - 0.2 > 0.6 in float)
261
- if (prevProgress - currProgress <= 0.2 + 1e-10)
262
- return null;
263
- return makeVerificationFlag({
264
- severity: "warn",
265
- field: "progress_estimate",
266
- check: CHECK_PROGRESS_REGRESSION,
267
- detail: `Progress dropped from ${prevProgress.toFixed(2)} to ` +
268
- `${currProgress.toFixed(2)} (delta: ${(currProgress - prevProgress).toFixed(2)})`,
269
- });
270
- }
271
- function checkEmptyChangeWithPassing(selfEval) {
272
- const ev = selfEval.execution_evidence;
273
- if (!ev)
274
- return null;
275
- const filesEmpty = ev.files_changed.length === 0;
276
- const testsAllPass = ev.test_results !== null &&
277
- ev.test_results.failed === 0 &&
278
- ev.test_results.passed > 0;
279
- if (!filesEmpty || !testsAllPass || !effectiveSuccess(selfEval))
280
- return null;
281
- return makeVerificationFlag({
282
- severity: "warn",
283
- field: "execution_evidence",
284
- check: CHECK_EMPTY_CHANGE_WITH_PASSING,
285
- detail: "Agent claims success with no files changed and all tests passing — " +
286
- "verify that work was actually performed",
287
- });
288
- }
289
293
  function checkSuccessWithRemainingCriteria(selfEval) {
290
294
  if (!effectiveSuccess(selfEval))
291
295
  return null;
@@ -319,10 +323,12 @@ function checkUnverifiedCriteriaClaims(selfEval, claimView) {
319
323
  "(no passing test evidence or command snapshot)",
320
324
  });
321
325
  }
322
- /** Declared outcome vs the core success flag. A declared success with
323
- * success=false is a self-contradiction (error); a declared non-success with
324
- * success=true is an inconsistent claim (warn). Also: outcome==="blocked"
325
- * without a blocker description gets a warn so the next prompt asks for it. */
326
+ /** Declared outcome vs the core success flag — one check id for the whole
327
+ * axis (v3.7: the former success_claim_conflict warn direction merged here).
328
+ * A declared success with success=false is a self-contradiction (error);
329
+ * success=true with a non-success outcome is an inconsistent claim (warn).
330
+ * Also: outcome==="blocked" without a blocker description gets a warn so the
331
+ * next prompt asks for it. */
326
332
  function checkOutcomeConsistency(selfEval) {
327
333
  const outcome = selfEval.outcome;
328
334
  if (outcome === "success" && selfEval.success === false) {
@@ -337,7 +343,7 @@ function checkOutcomeConsistency(selfEval) {
337
343
  return makeVerificationFlag({
338
344
  severity: "warn",
339
345
  field: "success",
340
- check: CHECK_SUCCESS_CLAIM_CONFLICT,
346
+ check: CHECK_OUTCOME_SUCCESS_CONTRADICTION,
341
347
  detail: `outcome=${outcome} but success=true — the declared outcome wins`,
342
348
  });
343
349
  }
@@ -385,7 +391,7 @@ function checkRetroactiveClaims(selfEval, vaultEntries, currentRound) {
385
391
  }));
386
392
  continue;
387
393
  }
388
- const paths = extractFilePaths(claim.claim);
394
+ const paths = extractFilePathTokens(claim.claim);
389
395
  const unverified = paths.filter((path) => !observed.some((file) => file === path || file.endsWith(path) || path.endsWith(file)));
390
396
  if (paths.length > 0 && unverified.length > 0) {
391
397
  flags.push(makeVerificationFlag({
@@ -401,25 +407,59 @@ function checkRetroactiveClaims(selfEval, vaultEntries, currentRound) {
401
407
  // hid all but the first problem until the next round.
402
408
  return flags.length > 0 ? flags : null;
403
409
  }
404
- /** v2.12/v3.6: success without a machine-verified observation. The agent
405
- * claims completion but the runtime observed no machine evidence for it.
406
- * v3.6: the trigger is the runtime-derived providerStatus (verified /
407
- * unavailable / absent) — the v3.2 success_unverified check merged here.
408
- * ProviderStatus "verified" requires an UNTAMPERED passed after-command, so
409
- * an entrypoint-tampered command no longer counts as machine evidence
410
- * (that coverage previously lived in success_unverified alone); the check
411
- * never self-skips on the heuristic-extraction path. Severity follows
412
- * evidence.machine_backed_success — "required" rejects (error), "warn"
413
- * tolerates with a warn (round commits, success excluded from the
414
- * trajectory, trust drops). A declared no_change_reason is the honest
415
- * escape hatch: it downgrades to info. no_change_reason is honored HERE
416
- * ONLY — contract checks refuse it (v3.6). */
410
+ /** v2.12/v3.6/v3.7: success without machine backing — the single
411
+ * "success evidence" semantic. Two arms, evaluated in order:
412
+ * 1. Empty/missing-evidence arm (v3.7: absorbed the enforcement-only
413
+ * empty_success rule): success=true with NO execution_evidence at all, or
414
+ * with files_changed empty AND test_results null, is an error
415
+ * unconditionally — BEFORE the providerStatus early-exit, so a passed
416
+ * command never rescues it (execution_evidence stays mandatory), and
417
+ * neither evidence.machine_backed_success "warn" nor a declared
418
+ * no_change_reason downgrades it ("no change" with literally no evidence
419
+ * recorded is still an unbacked claim).
420
+ * 2. Claims arm (v3.6): success=true but the runtime observed no machine
421
+ * evidence for it. The trigger is the runtime-derived providerStatus
422
+ * (verified / unavailable / absent) — "verified" requires an UNTAMPERED
423
+ * passed after-command, so an entrypoint-tampered command no longer
424
+ * counts as machine evidence. Severity follows evidence.machine_backed_
425
+ * success — "required" rejects (error), "warn" tolerates with a warn
426
+ * (round commits, success excluded from the trajectory, trust drops). A
427
+ * declared no_change_reason is the honest escape hatch: it downgrades to
428
+ * info — honored only while NO enabled verification command is
429
+ * configured (machine verification was structurally impossible); a
430
+ * configured command closes the escape. no_change_reason is honored HERE
431
+ * ONLY — contract checks refuse it. */
417
432
  function checkSuccessWithoutVerifiedEvidence(selfEval, status) {
418
433
  if (!effectiveSuccess(selfEval))
419
434
  return null;
435
+ // v3.7: empty/missing-evidence arm (ex-R3 empty_success posture).
436
+ const ev = selfEval.execution_evidence;
437
+ if (!ev || (ev.files_changed.length === 0 && ev.test_results === null)) {
438
+ return makeVerificationFlag({
439
+ severity: "error",
440
+ field: "success",
441
+ check: CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE,
442
+ detail: ev
443
+ ? "Agent claims success but execution_evidence shows no files changed " +
444
+ "and no tests were run. There is no verifiable evidence of work."
445
+ : "Agent claims success but provided no execution_evidence. " +
446
+ "Every successful round MUST include execution_evidence with " +
447
+ "files_changed, test_results, and progress_estimate.",
448
+ });
449
+ }
450
+ // Claims arm (v3.6).
420
451
  if (status.providerStatus === "verified")
421
452
  return null;
422
- const noChange = typeof selfEval.no_change_reason === "string" &&
453
+ // M2 (v3.7.x): the no_change_reason escape is only honest when machine
454
+ // verification was STRUCTURALLY impossible. With an enabled verification
455
+ // command configured, the runtime had a machine-verifiable path for this
456
+ // round — a declared reason cannot waive it (that would let an agent
457
+ // dodge the configured verification by writing a sentence, even while git
458
+ // observed real file changes). Verification-less policies (the default:
459
+ // commands: []) keep the escape: there is genuinely nothing to run.
460
+ const verificationConfigured = getPolicy().evidence.commands.some((command) => command.enabled);
461
+ const noChange = !verificationConfigured &&
462
+ typeof selfEval.no_change_reason === "string" &&
423
463
  selfEval.no_change_reason.trim().length > 0;
424
464
  // v3.3: severity follows evidence.machine_backed_success — "required"
425
465
  // rejects (error), "warn" tolerates with a warn (round commits, success
@@ -605,52 +645,23 @@ function checkRequiredCommandEvidence(selfEval, evidenceSnapshots) {
605
645
  function extractSubGoalIds(text) {
606
646
  return [...new Set(text.toLowerCase().match(/sg-[a-f0-9]{8}/g) ?? [])];
607
647
  }
608
- /** Extract file-path-like tokens (e.g. "src/auth/login.ts") from text. */
609
- function extractFilePaths(text) {
610
- return [...new Set(text.match(/[\w./-]+\.[a-z]{2,6}\b/gi) ?? [])];
611
- }
612
648
  /** Whether a changed path looks like a test file. */
613
649
  function isTestFile(path) {
614
650
  return /\.(test|spec)\.[a-z0-9]+$/i.test(path) ||
615
651
  /(^|[\\/])tests?[\\/]/i.test(path) ||
616
652
  /_test\.[a-z0-9]+$/i.test(path);
617
653
  }
618
- /** Map each referenced sub-goal ID to its known description(s), reconstructed
619
- * from emerged_subtasks in the vault lineage and the previous self-eval. */
620
- function referencedSubGoalDescriptions(ids, vaultEntries, prevSelfEval) {
621
- const map = new Map();
622
- for (const id of ids)
623
- map.set(id, []);
624
- const descriptions = [];
625
- for (const entry of vaultEntries) {
626
- const emerged = Array.isArray(entry.emerged_subtasks)
627
- ? entry.emerged_subtasks.filter((v) => typeof v === "string")
628
- : [];
629
- descriptions.push(...emerged);
630
- }
631
- descriptions.push(...(prevSelfEval?.emerged_subtasks ?? []));
632
- for (const desc of descriptions) {
633
- const trimmed = desc.trim();
634
- if (!trimmed)
635
- continue;
636
- const id = deriveSubGoalId(trimmed);
637
- if (map.has(id))
638
- map.get(id).push(trimmed);
639
- }
640
- return map;
641
- }
642
654
  /** v2.1 (check 11): Compare the previous round's declared next_action with
643
655
  * the current round's actual output_summary. Detect when the agent says it
644
656
  * will do X but then does Y — silent drift without explanation.
645
657
  *
646
658
  * Only fires when both next_action and output_summary are non-empty.
647
659
  *
648
- * v2.14: Structured-ID-first detection. Alignment is decided by a waterfall
649
- * of concrete signals before falling back to string similarity:
660
+ * v2.14/v3.7.1: Structured-ID-first detection. Alignment is decided by a
661
+ * waterfall of concrete signals before falling back to string similarity:
650
662
  * 1. Sub-goal IDs (sg-XXXXXXXX) in next_action matched against this
651
- * round's completed_subtasks (exact ID, ID derived from the
652
- * description text, or Jaccard against the referenced sub-goal's
653
- * known description).
663
+ * round's declared done transitions (subgoal_updates). Description
664
+ * matching is gone — transitions are strict ID references now.
654
665
  * 2. File paths named in next_action appearing in files_changed.
655
666
  * 3. Test evidence — next_action mentions tests and test files were
656
667
  * changed with tests actually run.
@@ -666,27 +677,18 @@ function checkIntentDrift(selfEval, prevSelfEval, vaultEntries) {
666
677
  const actual = selfEval.output_summary.trim();
667
678
  const policy = getPolicy();
668
679
  const filesChanged = selfEval.execution_evidence?.files_changed ?? [];
669
- // ── Signal 1: sub-goal IDs referenced by next_action → completed this round
680
+ // ── Signal 1: sub-goal IDs referenced by next_action → a done transition
670
681
  const intentIds = extractSubGoalIds(intent);
671
682
  if (intentIds.length > 0) {
672
- const completed = selfEval.completed_subtasks ?? [];
673
- const referenced = referencedSubGoalDescriptions(intentIds, vaultEntries, prevSelfEval);
674
- const idMatched = intentIds.some((id) => completed.some((entry) => {
675
- const text = entry.trim();
676
- if (!text)
677
- return false;
678
- if (text.toLowerCase() === id)
679
- return true;
680
- if (deriveSubGoalId(text) === id)
681
- return true;
682
- const descriptions = referenced.get(id) ?? [];
683
- return descriptions.some((desc) => jaccardSimilarity(text, desc) >= policy.evolution.subgoal_match_threshold);
684
- }));
683
+ const doneIds = new Set((selfEval.subgoal_updates ?? [])
684
+ .filter((u) => u.status === "done")
685
+ .map((u) => u.id));
686
+ const idMatched = intentIds.some((id) => doneIds.has(id));
685
687
  if (idMatched)
686
688
  return null;
687
689
  }
688
690
  // ── Signal 2: file paths named in next_action appear in files_changed
689
- const intentPaths = extractFilePaths(intent);
691
+ const intentPaths = extractFilePathTokens(intent);
690
692
  if (intentPaths.length > 0 && filesChanged.length > 0) {
691
693
  const pathMatched = intentPaths.some((p) => filesChanged.some((f) => f === p || f.endsWith(p) || p.endsWith(f)));
692
694
  if (pathMatched)
@@ -707,7 +709,7 @@ function checkIntentDrift(selfEval, prevSelfEval, vaultEntries) {
707
709
  if (score >= threshold)
708
710
  return null;
709
711
  const idNote = intentIds.length > 0
710
- ? `; ${intentIds.length} referenced sub-goal ID(s) did not match completed_subtasks`
712
+ ? `; ${intentIds.length} referenced sub-goal ID(s) did not match a declared done transition`
711
713
  : "";
712
714
  return makeVerificationFlag({
713
715
  severity: "warn",
@@ -886,9 +888,12 @@ function checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound) {
886
888
  const nextAction = selfEval.next_action?.trim();
887
889
  if (!nextAction)
888
890
  return null;
889
- // Reconstruct pending sub-goal descriptions from vault entries
891
+ // Reconstruct the pending set from vault entries and the current eval.
892
+ // v3.7.1: emerged descriptions are the creation channel; done/canceled are
893
+ // terminal ids reached through subgoal_updates (monotone — a terminal
894
+ // transition can never be undone, so the terminal id set only grows).
890
895
  const pendingSubGoals = new Set();
891
- const completedOrCanceled = new Set();
896
+ const terminalIds = new Set();
892
897
  for (const entry of vaultEntries) {
893
898
  const emerged = Array.isArray(entry.emerged_subtasks)
894
899
  ? entry.emerged_subtasks.filter((v) => typeof v === "string")
@@ -896,32 +901,23 @@ function checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound) {
896
901
  for (const desc of emerged) {
897
902
  pendingSubGoals.add(desc.trim());
898
903
  }
899
- const done = Array.isArray(entry.completed_subtasks)
900
- ? entry.completed_subtasks.filter((v) => typeof v === "string")
901
- : [];
902
- for (const desc of done) {
903
- completedOrCanceled.add(desc.trim());
904
- }
905
- const canceled = Array.isArray(entry.canceled_subtasks)
906
- ? entry.canceled_subtasks.filter((v) => typeof v === "string")
907
- : [];
908
- for (const desc of canceled) {
909
- completedOrCanceled.add(desc.trim());
904
+ for (const u of entrySubGoalUpdates(entry)) {
905
+ if (u.status === "done" || u.status === "canceled")
906
+ terminalIds.add(u.id);
910
907
  }
911
908
  }
912
- // Also process current selfEval
909
+ // Also process the current selfEval
913
910
  for (const desc of selfEval.emerged_subtasks ?? []) {
914
911
  pendingSubGoals.add(desc.trim());
915
912
  }
916
- for (const desc of selfEval.completed_subtasks ?? []) {
917
- completedOrCanceled.add(desc.trim());
918
- }
919
- for (const desc of selfEval.canceled_subtasks ?? []) {
920
- completedOrCanceled.add(desc.trim());
913
+ for (const u of selfEval.subgoal_updates ?? []) {
914
+ if (u.status === "done" || u.status === "canceled")
915
+ terminalIds.add(u.id);
921
916
  }
922
- // Remove completed/canceled from pending
923
- for (const desc of completedOrCanceled) {
924
- pendingSubGoals.delete(desc);
917
+ // Remove descriptions whose derived id reached a terminal state
918
+ for (const desc of [...pendingSubGoals]) {
919
+ if (terminalIds.has(deriveSubGoalId(desc)))
920
+ pendingSubGoals.delete(desc);
925
921
  }
926
922
  // Need at least 3 pending sub-goals for the check to be meaningful
927
923
  if (pendingSubGoals.size < 3)
@@ -951,37 +947,60 @@ function checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound) {
951
947
  check: CHECK_SUBGOAL_DRIFT,
952
948
  detail: `Agent's next_action doesn't align with any of ${pendingSubGoals.size} pending sub-goals. ` +
953
949
  `Pending: ${sample}… Consider completing existing sub-goals before starting new work, ` +
954
- `or cancel outdated sub-goals via canceled_subtasks.`,
950
+ `or cancel outdated sub-goals via subgoal_updates.`,
955
951
  });
956
952
  }
957
953
  return null;
958
954
  }
959
955
  /** v2.13: After a backtrack, check that the agent restored the workspace
960
- * before working. If the agent's files_changed overlaps with files from
961
- * skipped (failed) rounds, the workspace was likely not clean. */
962
- function checkBacktrackWorkspaceRestore(selfEval, backtrackSkippedFiles) {
963
- if (backtrackSkippedFiles.length === 0)
964
- return null;
956
+ * before working. Two arms:
957
+ *
958
+ * 1. Machine arm (M3, error): a skipped file whose CURRENT git fingerprint
959
+ * still equals the fingerprint recorded at its failed round was never
960
+ * touched since the rollback — the workspace was provably not restored.
961
+ * Restored or re-modified files change fingerprint and can never be
962
+ * misjudged by this arm.
963
+ *
964
+ * 2. Claims arm (warn only): the agent's files_changed overlaps the skipped
965
+ * list. A self-report alone cannot distinguish a restored-then-redone
966
+ * file from a never-restored one — a legitimate multi-file redo would
967
+ * otherwise be falsely terminated — so overlap is guidance, not a
968
+ * verdict. (The old error tier for >= 3 overlapping files was removed
969
+ * for exactly this reason: it fired on honest redos.) */
970
+ function checkBacktrackWorkspaceRestore(selfEval, backtrackSkippedFiles, backtrackSkippedFingerprints = {}, evidenceSnapshots = []) {
971
+ // Machine arm — untouched-since-rollback proof, independent of claims.
972
+ const failedEntries = Object.entries(backtrackSkippedFingerprints);
973
+ if (failedEntries.length > 0) {
974
+ const git = evidenceSnapshots.find((snapshot) => snapshot.provider === "git" && isRecord(snapshot.data) &&
975
+ isRecord(snapshot.data.fingerprints));
976
+ const currentFingerprints = git
977
+ ? git.data.fingerprints
978
+ : null;
979
+ if (currentFingerprints) {
980
+ const untouched = failedEntries
981
+ .filter(([file, failedFp]) => currentFingerprints[file] === failedFp)
982
+ .map(([file]) => file)
983
+ .sort();
984
+ if (untouched.length > 0) {
985
+ return makeVerificationFlag({
986
+ severity: "error",
987
+ field: "workspace",
988
+ check: CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED,
989
+ detail: `File(s) from the failed rounds are byte-identical to their ` +
990
+ `state at the rollback: ${untouched.slice(0, 5).join(", ")}. The ` +
991
+ `workspace was never restored — restore it to the restore point ` +
992
+ `(git checkout/reset) before redoing the work.`,
993
+ });
994
+ }
995
+ }
996
+ }
965
997
  const ev = selfEval.execution_evidence;
966
998
  if (!ev || ev.files_changed.length === 0)
967
999
  return null;
968
- // Check overlap: files the agent changed vs. files from skipped rounds
1000
+ // Claims arm — overlap as guidance only.
969
1001
  const overlap = ev.files_changed.filter((f) => backtrackSkippedFiles.some((sf) => sf === f || f.endsWith(sf) || sf.endsWith(f)));
970
1002
  if (overlap.length === 0)
971
1003
  return null;
972
- // Agent is modifying files that were part of failed rounds without
973
- // having properly restored the workspace first.
974
- if (overlap.length >= 3 || overlap.length === ev.files_changed.length) {
975
- return makeVerificationFlag({
976
- severity: "error",
977
- field: "files_changed",
978
- check: CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED,
979
- detail: `Agent modified ${overlap.length} file(s) that were part of ` +
980
- `the skipped (failed) rounds: ${overlap.slice(0, 5).join(", ")}. ` +
981
- `The workspace was likely not restored before working.`,
982
- });
983
- }
984
- // Minor overlap — warn only
985
1004
  return makeVerificationFlag({
986
1005
  severity: "warn",
987
1006
  field: "files_changed",
@@ -1239,15 +1258,80 @@ function checkContractPremature(activeContract, selfEval) {
1239
1258
  * @param evidenceSnapshots v1.18: Evidence snapshots from configured providers.
1240
1259
  * Used by checkEvidenceIntegrity for multi-provider
1241
1260
  * cross-validation. Defaults to empty array. */
1242
- export function verifySelfEvaluation(selfEval, currentRound, vaultEntries, prevSelfEval = null, evidenceSnapshots = [],
1261
+ /** v3.7.1: Opt-in round blocking for the gate layer. For every gate id the
1262
+ * round cites (gate_ids), the gate_opened record must exist, must be a
1263
+ * USER gate, and must have an approved gate_decision. Error flags list
1264
+ * every failing id with a reason. Entirely skipped when the feature is
1265
+ * disabled — there is nothing to cite against. */
1266
+ function checkUserGateUnresolved(selfEval, gateEntries) {
1267
+ if (!getPolicy().gate.enabled)
1268
+ return null;
1269
+ const cited = (selfEval.gate_ids ?? []).filter((id) => typeof id === "string");
1270
+ if (cited.length === 0)
1271
+ return null;
1272
+ const opened = new Set();
1273
+ const userKind = new Set();
1274
+ const approved = new Set();
1275
+ for (const entry of gateEntries) {
1276
+ if (entry.task_type === "gate_opened" && typeof entry.gate_id === "string") {
1277
+ opened.add(entry.gate_id);
1278
+ // v3.7.1: structured records declare gate_kind; legacy flat records
1279
+ // (blocked-round auto-records) classify their stored action text.
1280
+ if (entry.gate_kind === "user") {
1281
+ userKind.add(entry.gate_id);
1282
+ }
1283
+ else if (typeof entry.gate_action === "string") {
1284
+ let kind = null;
1285
+ try {
1286
+ kind = preflightStructuredGate(JSON.parse(entry.gate_action)).kind;
1287
+ }
1288
+ catch {
1289
+ kind = deriveGate(entry.gate_action).gate.kind;
1290
+ }
1291
+ if (kind === "user")
1292
+ userKind.add(entry.gate_id);
1293
+ }
1294
+ }
1295
+ if (entry.task_type === "gate_decision" && entry.approved === true &&
1296
+ typeof entry.gate_id === "string") {
1297
+ approved.add(entry.gate_id);
1298
+ }
1299
+ }
1300
+ const failures = [];
1301
+ for (const gateId of cited) {
1302
+ if (!opened.has(gateId))
1303
+ failures.push(`${gateId} (not_found)`);
1304
+ else if (!userKind.has(gateId))
1305
+ failures.push(`${gateId} (not_user_gate)`);
1306
+ else if (!approved.has(gateId))
1307
+ failures.push(`${gateId} (not_approved)`);
1308
+ }
1309
+ if (failures.length === 0)
1310
+ return null;
1311
+ return makeVerificationFlag({
1312
+ severity: "error",
1313
+ field: "evaluation",
1314
+ check: CHECK_USER_GATE_UNRESOLVED,
1315
+ detail: `The round cites gates without an approved human decision: ` +
1316
+ `${failures.join("; ")}. Obtain approval via loopforge_gate_check / ` +
1317
+ `loopforge_gate_resolve before claiming the action is done.`,
1318
+ });
1319
+ }
1320
+ export function verifySelfEvaluation(selfEval, currentRound, vaultEntries, prevSelfEval = null, evidenceSnapshots = [],
1243
1321
  /** v2.13: Files from skipped backtrack rounds. If the agent's
1244
1322
  * files_changed overlaps significantly with these, the workspace
1245
1323
  * was not properly restored before working. */
1246
- backtrackSkippedFiles = [],
1324
+ backtrackSkippedFiles = [],
1325
+ /** M3 (v3.7.x): skipped-file git fingerprints at their failed rounds —
1326
+ * machine proof of "untouched since the rollback" for the restore check. */
1327
+ backtrackSkippedFingerprints = {},
1247
1328
  /** v2.12: Git HEAD commit of the backtrack restore point. When set, the
1248
1329
  * current git snapshot must sit at this commit — otherwise the workspace
1249
1330
  * was not restored and the round cannot be accepted. */
1250
- backtrackTargetGitHead) {
1331
+ backtrackTargetGitHead,
1332
+ /** v3.7.1: gate records (task_type gate_opened / gate_decision) live
1333
+ * outside the round prefix — the caller passes them in explicitly. */
1334
+ gateEntries = []) {
1251
1335
  const flags = [];
1252
1336
  // Collect violations from all previous vault entries for duplicate-discovery
1253
1337
  // and other checks that need deeper history.
@@ -1275,11 +1359,9 @@ backtrackTargetGitHead) {
1275
1359
  // the list at the first problem, hiding every other bad retroactive claim
1276
1360
  // from the agent until the next round.
1277
1361
  const checks = [
1278
- () => checkProgressRegression(selfEval, prevSelfEval),
1279
- () => checkEmptyChangeWithPassing(selfEval),
1280
1362
  () => checkSuccessWithRemainingCriteria(selfEval),
1281
- // v2.12/v3.6: success without a machine-verified observation — the merged
1282
- // success_unverified semantics (runtime providerStatus lens, tamper-aware)
1363
+ // v2.12/v3.6/v3.7: the single success-evidence semantic — empty/missing-
1364
+ // evidence arm (ex-R3) + claims arm (providerStatus lens, tamper-aware)
1283
1365
  () => checkSuccessWithoutVerifiedEvidence(selfEval, evidenceStatus),
1284
1366
  // v2.12: criteria met but none machine-verified (mild criterion-specific rule)
1285
1367
  () => checkUnverifiedCriteriaClaims(selfEval, claimView),
@@ -1303,7 +1385,7 @@ backtrackTargetGitHead) {
1303
1385
  // v2.2: Detect sub-goal drift — next_action doesn't align with pending sub-goals
1304
1386
  () => checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound),
1305
1387
  // v2.13: Post-backtrack workspace restore check
1306
- () => checkBacktrackWorkspaceRestore(selfEval, backtrackSkippedFiles),
1388
+ () => checkBacktrackWorkspaceRestore(selfEval, backtrackSkippedFiles, backtrackSkippedFingerprints, evidenceSnapshots),
1307
1389
  // v2.12: Post-backtrack git HEAD restore check
1308
1390
  () => checkBacktrackGitHeadRestore(evidenceSnapshots, backtrackTargetGitHead),
1309
1391
  // v3.3: Round Contract checks. v3.4: split by target — structural
@@ -1317,6 +1399,8 @@ backtrackTargetGitHead) {
1317
1399
  () => checkPrematureBoundary(activeContract, selfEval, claimView),
1318
1400
  () => checkContractCompletionUnverified(activeContract, selfEval, evidenceSnapshots),
1319
1401
  () => checkContractPremature(activeContract, selfEval),
1402
+ // v3.7.1: opt-in gate-layer blocking (skipped when disabled)
1403
+ () => checkUserGateUnresolved(selfEval, gateEntries),
1320
1404
  ];
1321
1405
  for (const run of checks) {
1322
1406
  const result = run();