loopforge 2.11.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/README.md +4 -4
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +158 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +21 -9
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +34 -16
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +16 -6
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +40 -0
  14. package/dist/cognitive-governance.d.ts.map +1 -0
  15. package/dist/cognitive-governance.js +126 -0
  16. package/dist/cognitive-governance.js.map +1 -0
  17. package/dist/enforcement-gate.d.ts +8 -3
  18. package/dist/enforcement-gate.d.ts.map +1 -1
  19. package/dist/enforcement-gate.js +419 -92
  20. package/dist/enforcement-gate.js.map +1 -1
  21. package/dist/engine.d.ts +47 -7
  22. package/dist/engine.d.ts.map +1 -1
  23. package/dist/engine.js +303 -60
  24. package/dist/engine.js.map +1 -1
  25. package/dist/evidence-claims.d.ts +50 -0
  26. package/dist/evidence-claims.d.ts.map +1 -0
  27. package/dist/evidence-claims.js +134 -0
  28. package/dist/evidence-claims.js.map +1 -0
  29. package/dist/generate-schema.js +6 -1
  30. package/dist/generate-schema.js.map +1 -1
  31. package/dist/index.d.ts +3 -3
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +2 -2
  34. package/dist/index.js.map +1 -1
  35. package/dist/loop-compiler.d.ts +41 -3
  36. package/dist/loop-compiler.d.ts.map +1 -1
  37. package/dist/loop-compiler.js +310 -38
  38. package/dist/loop-compiler.js.map +1 -1
  39. package/dist/loop-projection.d.ts +26 -0
  40. package/dist/loop-projection.d.ts.map +1 -0
  41. package/dist/loop-projection.js +142 -0
  42. package/dist/loop-projection.js.map +1 -0
  43. package/dist/loop-store.d.ts +91 -41
  44. package/dist/loop-store.d.ts.map +1 -1
  45. package/dist/loop-store.js +243 -173
  46. package/dist/loop-store.js.map +1 -1
  47. package/dist/mcp/round-lifecycle.d.ts +216 -0
  48. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  49. package/dist/mcp/round-lifecycle.js +926 -0
  50. package/dist/mcp/round-lifecycle.js.map +1 -0
  51. package/dist/mcp/server.d.ts +3 -2
  52. package/dist/mcp/server.d.ts.map +1 -1
  53. package/dist/mcp/server.js +65 -9
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/mcp/session.d.ts +47 -139
  56. package/dist/mcp/session.d.ts.map +1 -1
  57. package/dist/mcp/session.js +284 -784
  58. package/dist/mcp/session.js.map +1 -1
  59. package/dist/mcp/tools.d.ts +23 -1
  60. package/dist/mcp/tools.d.ts.map +1 -1
  61. package/dist/mcp/tools.js +270 -79
  62. package/dist/mcp/tools.js.map +1 -1
  63. package/dist/policy-metrics.d.ts +11 -0
  64. package/dist/policy-metrics.d.ts.map +1 -1
  65. package/dist/policy-metrics.js +118 -21
  66. package/dist/policy-metrics.js.map +1 -1
  67. package/dist/policy.d.ts +26 -1
  68. package/dist/policy.d.ts.map +1 -1
  69. package/dist/policy.js +13 -2
  70. package/dist/policy.js.map +1 -1
  71. package/dist/prompt-assembler.d.ts +34 -1
  72. package/dist/prompt-assembler.d.ts.map +1 -1
  73. package/dist/prompt-assembler.js +320 -42
  74. package/dist/prompt-assembler.js.map +1 -1
  75. package/dist/protocol.d.ts +169 -1
  76. package/dist/protocol.d.ts.map +1 -1
  77. package/dist/protocol.js +39 -0
  78. package/dist/protocol.js.map +1 -1
  79. package/dist/replay.d.ts +5 -5
  80. package/dist/replay.d.ts.map +1 -1
  81. package/dist/replay.js +9 -8
  82. package/dist/replay.js.map +1 -1
  83. package/dist/round-coordinator.d.ts +6 -3
  84. package/dist/round-coordinator.d.ts.map +1 -1
  85. package/dist/round-coordinator.js +38 -19
  86. package/dist/round-coordinator.js.map +1 -1
  87. package/dist/round-driver.d.ts +9 -4
  88. package/dist/round-driver.d.ts.map +1 -1
  89. package/dist/round-driver.js +15 -7
  90. package/dist/round-driver.js.map +1 -1
  91. package/dist/round-transaction.d.ts +7 -3
  92. package/dist/round-transaction.d.ts.map +1 -1
  93. package/dist/round-transaction.js +93 -88
  94. package/dist/round-transaction.js.map +1 -1
  95. package/dist/self-eval.d.ts +31 -1
  96. package/dist/self-eval.d.ts.map +1 -1
  97. package/dist/self-eval.js +115 -12
  98. package/dist/self-eval.js.map +1 -1
  99. package/dist/storage.d.ts +3 -4
  100. package/dist/storage.d.ts.map +1 -1
  101. package/dist/storage.js +5 -4
  102. package/dist/storage.js.map +1 -1
  103. package/dist/token-utils.d.ts +20 -7
  104. package/dist/token-utils.d.ts.map +1 -1
  105. package/dist/token-utils.js +68 -9
  106. package/dist/token-utils.js.map +1 -1
  107. package/dist/verification-gate.d.ts +54 -2
  108. package/dist/verification-gate.d.ts.map +1 -1
  109. package/dist/verification-gate.js +433 -25
  110. package/dist/verification-gate.js.map +1 -1
  111. package/loop_policy.json +35 -8
  112. package/package.json +3 -2
  113. package/skills/perception/SKILL.md +24 -6
  114. package/dist/backends/interface.d.ts +0 -39
  115. package/dist/backends/interface.d.ts.map +0 -1
  116. package/dist/backends/interface.js +0 -6
  117. package/dist/backends/interface.js.map +0 -1
@@ -14,7 +14,37 @@
14
14
  */
15
15
  import { getPolicy } from "./policy.js";
16
16
  import { makeVerificationFlag, makeVerificationResult } from "./protocol.js";
17
- import { jaccardSimilarity, entryRound as sharedEntryRound } from "./token-utils.js";
17
+ import { jaccardSimilarity, tokenize, entryRound as sharedEntryRound, isRecord } from "./token-utils.js";
18
+ import { deriveSubGoalId } from "./loop-compiler.js";
19
+ import { deriveClaimView, resolveRoundFiles } from "./evidence-claims.js";
20
+ import { effectiveSuccess } from "./self-eval.js";
21
+ // ═══════════════════════════════════════════════════════════════════════════
22
+ // v2.12: Check-name constants — single source of truth so the enforcement
23
+ // gate and audit can reference checks without string-literal drift.
24
+ // ═══════════════════════════════════════════════════════════════════════════
25
+ export const CHECK_PROGRESS_REGRESSION = "progress_regression";
26
+ export const CHECK_EMPTY_CHANGE_WITH_PASSING = "empty_change_with_passing";
27
+ export const CHECK_SUCCESS_WITH_REMAINING_CRITERIA = "success_with_remaining_criteria";
28
+ export const CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE = "success_without_verified_evidence";
29
+ export const CHECK_OUTCOME_SUCCESS_CONTRADICTION = "outcome_success_contradiction";
30
+ export const CHECK_SUCCESS_CLAIM_CONFLICT = "success_claim_conflict";
31
+ export const CHECK_BLOCKED_WITHOUT_BLOCKER = "blocked_without_blocker";
32
+ export const CHECK_RETROACTIVE_CLAIM_BAD_ROUND = "retroactive_claim_bad_round";
33
+ export const CHECK_RETROACTIVE_CLAIM_UNVERIFIED = "retroactive_claim_unverified";
34
+ export const CHECK_DUPLICATE_CONSTRAINT_DISCOVERY = "duplicate_constraint_discovery";
35
+ export const CHECK_RECURRING_VIOLATION = "recurring_violation";
36
+ export const CHECK_RETRACT_FRESH_CONSTRAINT = "retract_fresh_constraint";
37
+ export const CHECK_EVIDENCE_INTEGRITY = "evidence_integrity";
38
+ export const CHECK_REQUIRED_COMMAND_FAILED = "required_command_failed";
39
+ export const CHECK_COMMAND_EVIDENCE_MISMATCH = "command_evidence_mismatch";
40
+ export const CHECK_INTENT_DRIFT = "intent_drift";
41
+ export const CHECK_SUBGOAL_DRIFT = "subgoal_drift";
42
+ export const CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED = "backtrack_workspace_not_restored";
43
+ export const CHECK_CRITERIA_CLAIMS_UNVERIFIED = "criteria_claims_unverified";
44
+ /** v3.2: success declared with no machine-verified observation this round
45
+ * (providerStatus ≠ verified). Warn-level: the round still commits, but its
46
+ * success never enters the trajectory and trust drops. */
47
+ export const CHECK_SUCCESS_UNVERIFIED = "success_unverified";
18
48
  // ═══════════════════════════════════════════════════════════════════════════
19
49
  // Helpers
20
50
  // ═══════════════════════════════════════════════════════════════════════════
@@ -34,6 +64,90 @@ function entryViolations(entry) {
34
64
  return viols.filter((v) => typeof v === "string");
35
65
  return [];
36
66
  }
67
+ function passedAfterCommand(snapshots) {
68
+ return snapshots.find((snapshot) => isRecord(snapshot.data) &&
69
+ snapshot.data.kind === "command" &&
70
+ snapshot.data.phase === "after" &&
71
+ snapshot.data.status === "passed") ?? null;
72
+ }
73
+ /** v3.2: Derive the machine-verification status of a round. The verification
74
+ * capability is modeled explicitly (verified / unavailable / absent) instead
75
+ * of letting evidence-dependent checks silently disappear when snapshots are
76
+ * missing — the fix for the "weakest when it matters most" gap. */
77
+ export function deriveEvidenceStatus(selfEval, evidenceSnapshots) {
78
+ const gitSnap = evidenceSnapshots.find((snapshot) => snapshot.provider === "git") ?? null;
79
+ const gitObserved = gitSnap !== null && gitSnap.files.length > 0;
80
+ const command = passedAfterCommand(evidenceSnapshots);
81
+ const commandVerified = command !== null;
82
+ // testsMachineBacked: agent-reported test_results agree with a passed
83
+ // command's parsed stdout (the same comparison checkCommandEvidenceIntegrity
84
+ // performs — parseTestOutput lives in this module).
85
+ let testsMachineBacked = false;
86
+ const reported = selfEval.execution_evidence?.test_results;
87
+ if (command && reported) {
88
+ const parsed = parseTestOutput(String(command.data.stdout ?? ""));
89
+ testsMachineBacked = parsed !== null &&
90
+ parsed.passed === reported.passed &&
91
+ parsed.failed === reported.failed &&
92
+ parsed.skipped === reported.skipped;
93
+ }
94
+ // reportedFilesMatch: agent-reported files_changed equals the git diff set.
95
+ let reportedFilesMatch = false;
96
+ if (gitSnap && selfEval.execution_evidence) {
97
+ const reportedSet = [...selfEval.execution_evidence.files_changed].sort();
98
+ const actualSet = [...gitSnap.files].sort();
99
+ reportedFilesMatch = reportedSet.length === actualSet.length &&
100
+ reportedSet.every((file, index) => file === actualSet[index]);
101
+ }
102
+ const providerStatus = evidenceSnapshots.length === 0
103
+ ? "absent"
104
+ : gitObserved || commandVerified ? "verified" : "unavailable";
105
+ return {
106
+ providerStatus,
107
+ gitObserved,
108
+ commandVerified,
109
+ testsMachineBacked,
110
+ reportedFilesMatch,
111
+ };
112
+ }
113
+ /** v3.2: Per-round machine progress — whether git observed file changes in
114
+ * each of the last `lookback` committed rounds, rebuilt from the feedback
115
+ * entries' roundEvidence snapshots (already persisted at commit). Returns
116
+ * null when fewer than `lookback` rounds carry git snapshots — the machine
117
+ * signal is unavailable and callers (R4/R5 on the heuristic path) keep
118
+ * their legacy skip. The snapshot shape is parsed defensively here because
119
+ * this module must not import round-transaction (import cycle). */
120
+ export function machineProgressSeries(vaultEntries, currentRound, lookback) {
121
+ const byRound = new Map();
122
+ for (const entry of vaultEntries) {
123
+ const tid = String(entry.task_id ?? "");
124
+ if (!tid.endsWith(":feedback"))
125
+ continue;
126
+ const rnd = entryRound(entry);
127
+ if (rnd <= 0 || rnd >= currentRound)
128
+ continue;
129
+ const rt = isRecord(entry.loop_lineage)
130
+ ? entry.loop_lineage.round_transaction
131
+ : null;
132
+ const snapshot = isRecord(rt) ? rt.snapshot : null;
133
+ const roundEvidence = isRecord(snapshot) ? snapshot.roundEvidence : null;
134
+ if (!Array.isArray(roundEvidence))
135
+ continue;
136
+ const git = roundEvidence.find((value) => isRecord(value) && value.provider === "git");
137
+ if (!git)
138
+ continue;
139
+ const gitFiles = git.files;
140
+ byRound.set(rnd, Array.isArray(gitFiles) && gitFiles.length > 0);
141
+ }
142
+ if (byRound.size < lookback)
143
+ return null;
144
+ const sorted = [...byRound.keys()].sort((a, b) => a - b);
145
+ const recent = sorted.slice(-lookback);
146
+ // Continuity: the recent rounds must be the actual last `lookback` rounds.
147
+ if (recent[0] < currentRound - lookback)
148
+ return null;
149
+ return recent.map((round) => byRound.get(round));
150
+ }
37
151
  // ═══════════════════════════════════════════════════════════════════════════
38
152
  // Individual checks — each returns a VerificationFlag or null
39
153
  // ═══════════════════════════════════════════════════════════════════════════
@@ -52,7 +166,7 @@ function checkProgressRegression(selfEval, prevSelfEval) {
52
166
  return makeVerificationFlag({
53
167
  severity: "warn",
54
168
  field: "progress_estimate",
55
- check: "progress_regression",
169
+ check: CHECK_PROGRESS_REGRESSION,
56
170
  detail: `Progress dropped from ${prevProgress.toFixed(2)} to ` +
57
171
  `${currProgress.toFixed(2)} (delta: ${(currProgress - prevProgress).toFixed(2)})`,
58
172
  });
@@ -65,18 +179,18 @@ function checkEmptyChangeWithPassing(selfEval) {
65
179
  const testsAllPass = ev.test_results !== null &&
66
180
  ev.test_results.failed === 0 &&
67
181
  ev.test_results.passed > 0;
68
- if (!filesEmpty || !testsAllPass || !selfEval.success)
182
+ if (!filesEmpty || !testsAllPass || !effectiveSuccess(selfEval))
69
183
  return null;
70
184
  return makeVerificationFlag({
71
185
  severity: "warn",
72
186
  field: "execution_evidence",
73
- check: "empty_change_with_passing",
187
+ check: CHECK_EMPTY_CHANGE_WITH_PASSING,
74
188
  detail: "Agent claims success with no files changed and all tests passing — " +
75
189
  "verify that work was actually performed",
76
190
  });
77
191
  }
78
192
  function checkSuccessWithRemainingCriteria(selfEval) {
79
- if (!selfEval.success)
193
+ if (!effectiveSuccess(selfEval))
80
194
  return null;
81
195
  const remaining = selfEval.execution_evidence?.success_criteria_remaining;
82
196
  if (!remaining || remaining.length === 0)
@@ -84,11 +198,154 @@ function checkSuccessWithRemainingCriteria(selfEval) {
84
198
  return makeVerificationFlag({
85
199
  severity: "error",
86
200
  field: "success",
87
- check: "success_with_remaining_criteria",
201
+ check: CHECK_SUCCESS_WITH_REMAINING_CRITERIA,
88
202
  detail: `Agent claims success but ${remaining.length} criteria remain unmet: ` +
89
203
  remaining.slice(0, 3).join("; "),
90
204
  });
91
205
  }
206
+ /** v2.12: Criteria reported met but none machine-verified. The agent puts
207
+ * completed criteria into the ledger, but no passing test evidence or
208
+ * command snapshot backs them. Warn — completion still counts, but the
209
+ * next prompt tells the agent its claims are unverified. This is the
210
+ * mild form of the 3.x criterion-specific completion rule. */
211
+ function checkUnverifiedCriteriaClaims(selfEval, claimView) {
212
+ const met = selfEval.execution_evidence?.success_criteria_met;
213
+ if (!met || met.length === 0)
214
+ return null;
215
+ if (claimView.verifiedCount > 0 || claimView.hasMachineEvidence)
216
+ return null;
217
+ return makeVerificationFlag({
218
+ severity: "warn",
219
+ field: "execution_evidence",
220
+ check: CHECK_CRITERIA_CLAIMS_UNVERIFIED,
221
+ detail: `${met.length} criteria reported met but none machine-verified ` +
222
+ "(no passing test evidence or command snapshot)",
223
+ });
224
+ }
225
+ /** v2.12: Declared outcome vs legacy boolean consistency. A declared success
226
+ * with success=false is a self-contradiction (error); a declared non-success
227
+ * with success=true is a compat conflict (warn). Also: outcome==="blocked"
228
+ * without a blocker description gets a warn so the next prompt asks for it. */
229
+ function checkOutcomeConsistency(selfEval) {
230
+ const outcome = selfEval.outcome;
231
+ if (outcome === "success" && selfEval.success === false) {
232
+ return makeVerificationFlag({
233
+ severity: "error",
234
+ field: "outcome",
235
+ check: CHECK_OUTCOME_SUCCESS_CONTRADICTION,
236
+ detail: "outcome=success but success=false — self-contradictory",
237
+ });
238
+ }
239
+ if (outcome !== undefined && outcome !== "success" && selfEval.success === true) {
240
+ return makeVerificationFlag({
241
+ severity: "warn",
242
+ field: "success",
243
+ check: CHECK_SUCCESS_CLAIM_CONFLICT,
244
+ detail: `outcome=${outcome} but success=true — the declared outcome wins`,
245
+ });
246
+ }
247
+ if (outcome === "blocked" &&
248
+ (typeof selfEval.blocker !== "string" || selfEval.blocker.trim().length === 0)) {
249
+ return makeVerificationFlag({
250
+ severity: "warn",
251
+ field: "blocker",
252
+ check: CHECK_BLOCKED_WITHOUT_BLOCKER,
253
+ detail: "outcome=blocked but no blocker description was provided",
254
+ });
255
+ }
256
+ return null;
257
+ }
258
+ /** v2.12: Retroactive claims reference PRIOR rounds. Each claim's file
259
+ * paths are checked against the referenced round's committed git evidence
260
+ * (P0 provenance layer). Unverifiable claims warn — they never upgrade
261
+ * criteria status. */
262
+ function checkRetroactiveClaims(selfEval, vaultEntries, currentRound) {
263
+ const claims = selfEval.retroactiveClaims;
264
+ if (!claims || claims.length === 0)
265
+ return null;
266
+ const loopId = vaultEntries.find((entry) => typeof entry.loop_id === "string")
267
+ ?.loop_id;
268
+ const flags = [];
269
+ for (const claim of claims) {
270
+ if (claim.round < 1 || claim.round >= currentRound) {
271
+ flags.push(makeVerificationFlag({
272
+ severity: "warn",
273
+ field: "retroactiveClaims",
274
+ check: CHECK_RETROACTIVE_CLAIM_BAD_ROUND,
275
+ detail: `retroactive claim targets round ${claim.round} (must be a prior round)`,
276
+ }));
277
+ continue;
278
+ }
279
+ if (!loopId)
280
+ continue; // no vault context → cannot verify (fail-open)
281
+ const observed = resolveRoundFiles(vaultEntries, loopId, claim.round);
282
+ if (observed === null) {
283
+ flags.push(makeVerificationFlag({
284
+ severity: "warn",
285
+ field: "retroactiveClaims",
286
+ check: CHECK_RETROACTIVE_CLAIM_UNVERIFIED,
287
+ detail: `retroactive claim for round ${claim.round} has no observable git evidence`,
288
+ }));
289
+ continue;
290
+ }
291
+ const paths = extractFilePaths(claim.claim);
292
+ const unverified = paths.filter((path) => !observed.some((file) => file === path || file.endsWith(path) || path.endsWith(file)));
293
+ if (paths.length > 0 && unverified.length > 0) {
294
+ flags.push(makeVerificationFlag({
295
+ severity: "warn",
296
+ field: "retroactiveClaims",
297
+ check: CHECK_RETROACTIVE_CLAIM_UNVERIFIED,
298
+ detail: `retroactive claim references files not observed in round ${claim.round}: ` +
299
+ unverified.slice(0, 3).join(", "),
300
+ }));
301
+ }
302
+ }
303
+ return flags.length > 0 ? flags[0] : null;
304
+ }
305
+ /** v2.12: success with zero machine-verifiable evidence. The agent claims
306
+ * completion but nothing the runtime observed backs it — no verified claims
307
+ * (test evidence), no test results, no required commands. A declared
308
+ * no_change_reason is the honest escape hatch: it downgrades to info. */
309
+ function checkSuccessWithoutVerifiedEvidence(selfEval, claimView) {
310
+ if (!effectiveSuccess(selfEval))
311
+ return null;
312
+ if (claimView.verifiedCount > 0 || claimView.hasMachineEvidence)
313
+ return null;
314
+ const noChange = typeof selfEval.no_change_reason === "string" &&
315
+ selfEval.no_change_reason.trim().length > 0;
316
+ return makeVerificationFlag({
317
+ severity: noChange ? "info" : "error",
318
+ field: "success",
319
+ check: CHECK_SUCCESS_WITHOUT_VERIFIED_EVIDENCE,
320
+ detail: noChange
321
+ ? "success with zero verified claims (no_change_reason declared)"
322
+ : "success with zero verified claims and no test evidence",
323
+ });
324
+ }
325
+ /** v3.2: success declared but no machine-verified observation this round.
326
+ * Unlike R8 (which keys off the agent's SELF-REPORTED test_results), this
327
+ * keys off the runtime-derived providerStatus: git diff observed, or a
328
+ * passed after-command. Warn-level by design — the round commits, but its
329
+ * success never enters the trajectory (and trust drops). A declared
330
+ * no_change_reason is the honest escape hatch, mirroring R8.
331
+ * Crucially this check does NOT self-skip on the heuristic-extraction path:
332
+ * an unstructured success claim without machine observation is flagged
333
+ * exactly the same — the check degrades, it never disappears. */
334
+ function checkSuccessUnverified(selfEval, status) {
335
+ if (!effectiveSuccess(selfEval))
336
+ return null;
337
+ if (typeof selfEval.no_change_reason === "string" &&
338
+ selfEval.no_change_reason.trim().length > 0)
339
+ return null;
340
+ if (status.providerStatus === "verified")
341
+ return null;
342
+ return makeVerificationFlag({
343
+ severity: "warn",
344
+ field: "success",
345
+ check: CHECK_SUCCESS_UNVERIFIED,
346
+ detail: `success declared but no machine-verified observation this round (providerStatus: ${status.providerStatus})`,
347
+ });
348
+ }
92
349
  function checkDuplicateConstraintDiscovery(selfEval, prevSelfEval, olderViolations) {
93
350
  const discovered = selfEval.discovered_constraints;
94
351
  if (!discovered || discovered.length === 0)
@@ -111,7 +368,7 @@ function checkDuplicateConstraintDiscovery(selfEval, prevSelfEval, olderViolatio
111
368
  return makeVerificationFlag({
112
369
  severity: "warn",
113
370
  field: "discovered_constraints",
114
- check: "duplicate_constraint_discovery",
371
+ check: CHECK_DUPLICATE_CONSTRAINT_DISCOVERY,
115
372
  detail: `Constraint "${d}" was already known from a previous round`,
116
373
  });
117
374
  }
@@ -151,7 +408,7 @@ function checkRecurringViolation(selfEval, prevSelfEval, vaultEntries, currentRo
151
408
  return makeVerificationFlag({
152
409
  severity: "error",
153
410
  field: "constraint_violations",
154
- check: "recurring_violation",
411
+ check: CHECK_RECURRING_VIOLATION,
155
412
  detail: `Constraint violation "${v}" has appeared in 3 consecutive rounds ` +
156
413
  `(rounds ${currentRound - 2}–${currentRound}) without resolution`,
157
414
  });
@@ -174,7 +431,7 @@ function checkRetractFreshConstraint(selfEval, prevSelfEval, currentRound) {
174
431
  return makeVerificationFlag({
175
432
  severity: "warn",
176
433
  field: "retracted_constraints",
177
- check: "retract_fresh_constraint",
434
+ check: CHECK_RETRACT_FRESH_CONSTRAINT,
178
435
  detail: `Retracting constraint "${r}" that was just discovered in round ` +
179
436
  `${currentRound - 1} — may indicate rapid flip-flopping`,
180
437
  });
@@ -200,7 +457,7 @@ function checkEvidenceIntegrity(selfEval, evidenceSnapshots) {
200
457
  return makeVerificationFlag({
201
458
  severity: "warn",
202
459
  field: "files_changed",
203
- check: "evidence_integrity",
460
+ check: CHECK_EVIDENCE_INTEGRITY,
204
461
  detail: `Agent reported no files changed but evidence shows: ${actual.join(", ")}`,
205
462
  });
206
463
  }
@@ -215,7 +472,7 @@ function checkEvidenceIntegrity(selfEval, evidenceSnapshots) {
215
472
  return makeVerificationFlag({
216
473
  severity: "warn",
217
474
  field: "files_changed",
218
- check: "evidence_integrity",
475
+ check: CHECK_EVIDENCE_INTEGRITY,
219
476
  detail: `Agent files_changed doesn't match evidence: ${parts.join("; ")}`,
220
477
  });
221
478
  }
@@ -224,7 +481,7 @@ function checkEvidenceIntegrity(selfEval, evidenceSnapshots) {
224
481
  /** A required, explicitly configured verification command is authoritative
225
482
  * when the Agent claims success. Optional commands remain observational. */
226
483
  function checkRequiredCommandEvidence(selfEval, evidenceSnapshots) {
227
- if (!selfEval.success)
484
+ if (!effectiveSuccess(selfEval))
228
485
  return null;
229
486
  for (const snapshot of evidenceSnapshots) {
230
487
  if (snapshot.data.kind !== "command")
@@ -243,7 +500,7 @@ function checkRequiredCommandEvidence(selfEval, evidenceSnapshots) {
243
500
  return makeVerificationFlag({
244
501
  severity: "error",
245
502
  field: "execution_evidence",
246
- check: "required_command_failed",
503
+ check: CHECK_REQUIRED_COMMAND_FAILED,
247
504
  detail: `Agent claims success but required command "${name}" ${String(status)}${exitCode}`,
248
505
  });
249
506
  }
@@ -251,30 +508,123 @@ function checkRequiredCommandEvidence(selfEval, evidenceSnapshots) {
251
508
  }
252
509
  // ── Test output parsing ────────────────────────────────────────────────────
253
510
  // jaccardSimilarity() imported from token-utils.ts (v2.1 — intent-action drift detection)
511
+ /** Extract stable sub-goal IDs (sg-XXXXXXXX) mentioned in text.
512
+ * Matches the ID format rendered in prompts (loop-compiler deriveSubGoalId). */
513
+ function extractSubGoalIds(text) {
514
+ return [...new Set(text.toLowerCase().match(/sg-[a-f0-9]{8}/g) ?? [])];
515
+ }
516
+ /** Extract file-path-like tokens (e.g. "src/auth/login.ts") from text. */
517
+ function extractFilePaths(text) {
518
+ return [...new Set(text.match(/[\w./-]+\.[a-z]{2,6}\b/gi) ?? [])];
519
+ }
520
+ /** Whether a changed path looks like a test file. */
521
+ function isTestFile(path) {
522
+ return /\.(test|spec)\.[a-z0-9]+$/i.test(path) ||
523
+ /(^|[\\/])tests?[\\/]/i.test(path) ||
524
+ /_test\.[a-z0-9]+$/i.test(path);
525
+ }
526
+ /** Map each referenced sub-goal ID to its known description(s), reconstructed
527
+ * from emerged_subtasks in the vault lineage and the previous self-eval. */
528
+ function referencedSubGoalDescriptions(ids, vaultEntries, prevSelfEval) {
529
+ const map = new Map();
530
+ for (const id of ids)
531
+ map.set(id, []);
532
+ const descriptions = [];
533
+ for (const entry of vaultEntries) {
534
+ const emerged = Array.isArray(entry.emerged_subtasks)
535
+ ? entry.emerged_subtasks.filter((v) => typeof v === "string")
536
+ : [];
537
+ descriptions.push(...emerged);
538
+ }
539
+ descriptions.push(...(prevSelfEval?.emerged_subtasks ?? []));
540
+ for (const desc of descriptions) {
541
+ const trimmed = desc.trim();
542
+ if (!trimmed)
543
+ continue;
544
+ const id = deriveSubGoalId(trimmed);
545
+ if (map.has(id))
546
+ map.get(id).push(trimmed);
547
+ }
548
+ return map;
549
+ }
254
550
  /** v2.1 (check 11): Compare the previous round's declared next_action with
255
551
  * the current round's actual output_summary. Detect when the agent says it
256
552
  * will do X but then does Y — silent drift without explanation.
257
553
  *
258
554
  * Only fires when both next_action and output_summary are non-empty.
259
- * Uses configurable Jaccard token similarity threshold from policy. */
260
- function checkIntentDrift(selfEval, prevSelfEval) {
555
+ *
556
+ * v2.14: Structured-ID-first detection. Alignment is decided by a waterfall
557
+ * of concrete signals before falling back to string similarity:
558
+ * 1. Sub-goal IDs (sg-XXXXXXXX) in next_action matched against this
559
+ * round's completed_subtasks (exact ID, ID derived from the
560
+ * description text, or Jaccard against the referenced sub-goal's
561
+ * known description).
562
+ * 2. File paths named in next_action appearing in files_changed.
563
+ * 3. Test evidence — next_action mentions tests and test files were
564
+ * changed with tests actually run.
565
+ * 4. Jaccard token similarity against the policy threshold (weak signal).
566
+ * Any signal aligning is sufficient — text similarity is never the sole
567
+ * authority when structured evidence exists. */
568
+ function checkIntentDrift(selfEval, prevSelfEval, vaultEntries) {
261
569
  if (!prevSelfEval?.next_action?.trim())
262
570
  return null;
263
571
  if (!selfEval.output_summary.trim())
264
572
  return null;
265
573
  const intent = prevSelfEval.next_action.trim();
266
574
  const actual = selfEval.output_summary.trim();
575
+ const policy = getPolicy();
576
+ const filesChanged = selfEval.execution_evidence?.files_changed ?? [];
577
+ // ── Signal 1: sub-goal IDs referenced by next_action → completed this round
578
+ const intentIds = extractSubGoalIds(intent);
579
+ if (intentIds.length > 0) {
580
+ const completed = selfEval.completed_subtasks ?? [];
581
+ const referenced = referencedSubGoalDescriptions(intentIds, vaultEntries, prevSelfEval);
582
+ const idMatched = intentIds.some((id) => completed.some((entry) => {
583
+ const text = entry.trim();
584
+ if (!text)
585
+ return false;
586
+ if (text.toLowerCase() === id)
587
+ return true;
588
+ if (deriveSubGoalId(text) === id)
589
+ return true;
590
+ const descriptions = referenced.get(id) ?? [];
591
+ return descriptions.some((desc) => jaccardSimilarity(text, desc) >= policy.evolution.subgoal_match_threshold);
592
+ }));
593
+ if (idMatched)
594
+ return null;
595
+ }
596
+ // ── Signal 2: file paths named in next_action appear in files_changed
597
+ const intentPaths = extractFilePaths(intent);
598
+ if (intentPaths.length > 0 && filesChanged.length > 0) {
599
+ const pathMatched = intentPaths.some((p) => filesChanged.some((f) => f === p || f.endsWith(p) || p.endsWith(f)));
600
+ if (pathMatched)
601
+ return null;
602
+ }
603
+ // ── Signal 3: test evidence — intent mentions tests, test files changed,
604
+ // and tests actually ran this round
605
+ const intentTokens = tokenize(intent);
606
+ const mentionsTests = intentTokens.has("test") || intentTokens.has("tests");
607
+ const testResults = selfEval.execution_evidence?.test_results;
608
+ const testsRan = !!testResults && testResults.passed + testResults.failed > 0;
609
+ const testFilesChanged = filesChanged.some((f) => isTestFile(f));
610
+ if (mentionsTests && testsRan && testFilesChanged)
611
+ return null;
612
+ // ── Signal 4 (weak): Jaccard token similarity
267
613
  const score = jaccardSimilarity(intent, actual);
268
- const threshold = getPolicy().evolution.intent_drift_threshold;
614
+ const threshold = policy.evolution.intent_drift_threshold;
269
615
  if (score >= threshold)
270
616
  return null;
617
+ const idNote = intentIds.length > 0
618
+ ? `; ${intentIds.length} referenced sub-goal ID(s) did not match completed_subtasks`
619
+ : "";
271
620
  return makeVerificationFlag({
272
621
  severity: "warn",
273
622
  field: "output_summary",
274
- check: "intent_drift",
623
+ check: CHECK_INTENT_DRIFT,
275
624
  detail: `Declared intent was "${intent.slice(0, 120)}" ` +
276
625
  `but actual output "${actual.slice(0, 120)}" — ` +
277
- `similarity ${(score * 100).toFixed(0)}% below threshold ${(threshold * 100).toFixed(0)}%`,
626
+ `similarity ${(score * 100).toFixed(0)}% below threshold ${(threshold * 100).toFixed(0)}%` +
627
+ idNote,
278
628
  });
279
629
  }
280
630
  /** Best-effort parse of test counts from common test-runner output formats.
@@ -384,7 +734,7 @@ function checkCommandEvidenceIntegrity(selfEval, evidenceSnapshots) {
384
734
  return makeVerificationFlag({
385
735
  severity,
386
736
  field: "test_results",
387
- check: "command_evidence_mismatch",
737
+ check: CHECK_COMMAND_EVIDENCE_MISMATCH,
388
738
  detail: `Agent test_results don't match "${name}" output: ${mismatches.join("; ")}`,
389
739
  });
390
740
  }
@@ -393,7 +743,11 @@ function checkCommandEvidenceIntegrity(selfEval, evidenceSnapshots) {
393
743
  /** v2.2: Check if the agent's declared next_action aligns with any pending
394
744
  * or in_progress sub-goal. When 3+ pending sub-goals exist but the agent
395
745
  * plans work unrelated to any of them, it may indicate task drift.
396
- * This is a warn-level advisory — the agent always owns prioritization. */
746
+ * This is a warn-level advisory — the agent always owns prioritization.
747
+ *
748
+ * v2.14: Exact sub-goal ID alignment first — next_action naming a pending
749
+ * sub-goal by its stable ID (sg-XXXXXXXX) is aligned by definition.
750
+ * Jaccard description similarity remains as the fallback. */
397
751
  function checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound) {
398
752
  const nextAction = selfEval.next_action?.trim();
399
753
  if (!nextAction)
@@ -438,6 +792,14 @@ function checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound) {
438
792
  // Need at least 3 pending sub-goals for the check to be meaningful
439
793
  if (pendingSubGoals.size < 3)
440
794
  return null;
795
+ // v2.14: Stable ID match first — next_action naming a pending sub-goal
796
+ // by ID is aligned by definition, regardless of description wording.
797
+ const nextActionIds = extractSubGoalIds(nextAction);
798
+ if (nextActionIds.length > 0) {
799
+ const pendingIds = new Set([...pendingSubGoals].map((d) => deriveSubGoalId(d)));
800
+ if (nextActionIds.some((id) => pendingIds.has(id)))
801
+ return null;
802
+ }
441
803
  // Check if next_action aligns with any pending sub-goal
442
804
  let aligns = false;
443
805
  for (const sg of pendingSubGoals) {
@@ -452,7 +814,7 @@ function checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound) {
452
814
  return makeVerificationFlag({
453
815
  severity: "warn",
454
816
  field: "next_action",
455
- check: "subgoal_drift",
817
+ check: CHECK_SUBGOAL_DRIFT,
456
818
  detail: `Agent's next_action doesn't align with any of ${pendingSubGoals.size} pending sub-goals. ` +
457
819
  `Pending: ${sample}… Consider completing existing sub-goals before starting new work, ` +
458
820
  `or cancel outdated sub-goals via canceled_subtasks.`,
@@ -479,7 +841,7 @@ function checkBacktrackWorkspaceRestore(selfEval, backtrackSkippedFiles) {
479
841
  return makeVerificationFlag({
480
842
  severity: "error",
481
843
  field: "files_changed",
482
- check: "backtrack_workspace_not_restored",
844
+ check: CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED,
483
845
  detail: `Agent modified ${overlap.length} file(s) that were part of ` +
484
846
  `the skipped (failed) rounds: ${overlap.slice(0, 5).join(", ")}. ` +
485
847
  `The workspace was likely not restored before working.`,
@@ -489,11 +851,35 @@ function checkBacktrackWorkspaceRestore(selfEval, backtrackSkippedFiles) {
489
851
  return makeVerificationFlag({
490
852
  severity: "warn",
491
853
  field: "files_changed",
492
- check: "backtrack_workspace_not_restored",
854
+ check: CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED,
493
855
  detail: `Agent modified files that overlap with skipped rounds: ` +
494
856
  `${overlap.join(", ")}. Verify the workspace was restored.`,
495
857
  });
496
858
  }
859
+ /** v2.12: After a backtrack, the working tree must return to the restore
860
+ * point's git HEAD. Compares the first 12 characters (short hash), matching
861
+ * how git itself disambiguates. */
862
+ function checkBacktrackGitHeadRestore(evidenceSnapshots, backtrackTargetGitHead) {
863
+ if (!backtrackTargetGitHead)
864
+ return null;
865
+ const git = evidenceSnapshots.find((snapshot) => snapshot.provider === "git" && isRecord(snapshot.data) &&
866
+ typeof snapshot.data.head === "string");
867
+ if (!git)
868
+ return null; // git unavailable → cannot verify → skip (fail-open)
869
+ const currentHead = git.data.head;
870
+ if (currentHead.length === 0)
871
+ return null;
872
+ if (currentHead.slice(0, 12) === backtrackTargetGitHead.slice(0, 12))
873
+ return null;
874
+ return makeVerificationFlag({
875
+ severity: "error",
876
+ field: "workspace",
877
+ check: CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED,
878
+ detail: `Workspace HEAD (${currentHead.slice(0, 12)}) does not match the ` +
879
+ `backtrack restore commit (${backtrackTargetGitHead.slice(0, 12)}). ` +
880
+ `Restore the working tree before continuing.`,
881
+ });
882
+ }
497
883
  // ═══════════════════════════════════════════════════════════════════════════
498
884
  // Main entry point
499
885
  // ═══════════════════════════════════════════════════════════════════════════
@@ -511,7 +897,11 @@ export function verifySelfEvaluation(selfEval, currentRound, vaultEntries, prevS
511
897
  /** v2.13: Files from skipped backtrack rounds. If the agent's
512
898
  * files_changed overlaps significantly with these, the workspace
513
899
  * was not properly restored before working. */
514
- backtrackSkippedFiles = []) {
900
+ backtrackSkippedFiles = [],
901
+ /** v2.12: Git HEAD commit of the backtrack restore point. When set, the
902
+ * current git snapshot must sit at this commit — otherwise the workspace
903
+ * was not restored and the round cannot be accepted. */
904
+ backtrackTargetGitHead) {
515
905
  const flags = [];
516
906
  // Collect violations from all previous vault entries for duplicate-discovery
517
907
  // and other checks that need deeper history.
@@ -520,11 +910,27 @@ backtrackSkippedFiles = []) {
520
910
  for (const v of entryViolations(entry))
521
911
  olderViolations.push(v);
522
912
  }
913
+ // v2.12: Derived claim provenance — which reported criteria completions
914
+ // are backed by machine evidence (pure derivation, not persisted).
915
+ const claimView = deriveClaimView(selfEval, evidenceSnapshots);
916
+ // v3.2: Runtime machine-verification status (git observed / command passed /
917
+ // absent). Feeds the success_unverified check that never self-skips.
918
+ const evidenceStatus = deriveEvidenceStatus(selfEval, evidenceSnapshots);
523
919
  // Run all checks
524
920
  const checks = [
525
921
  () => checkProgressRegression(selfEval, prevSelfEval),
526
922
  () => checkEmptyChangeWithPassing(selfEval),
527
923
  () => checkSuccessWithRemainingCriteria(selfEval),
924
+ // v2.12: success with zero verified claims and no test evidence
925
+ () => checkSuccessWithoutVerifiedEvidence(selfEval, claimView),
926
+ // v3.2: success without a machine-verified observation (never self-skips)
927
+ () => checkSuccessUnverified(selfEval, evidenceStatus),
928
+ // v2.12: criteria met but none machine-verified (mild criterion-specific rule)
929
+ () => checkUnverifiedCriteriaClaims(selfEval, claimView),
930
+ // v2.12: declared outcome vs legacy boolean consistency
931
+ () => checkOutcomeConsistency(selfEval),
932
+ // v2.12: retroactive claims against prior rounds
933
+ () => checkRetroactiveClaims(selfEval, vaultEntries, currentRound),
528
934
  () => checkDuplicateConstraintDiscovery(selfEval, prevSelfEval, olderViolations),
529
935
  () => checkRecurringViolation(selfEval, prevSelfEval, vaultEntries, currentRound),
530
936
  () => checkRetractFreshConstraint(selfEval, prevSelfEval, currentRound),
@@ -534,11 +940,13 @@ backtrackSkippedFiles = []) {
534
940
  // v2.0: Cross-validate agent-reported test_results against command output
535
941
  () => checkCommandEvidenceIntegrity(selfEval, evidenceSnapshots),
536
942
  // v2.1: Detect intent-action drift — agent said X but did Y
537
- () => checkIntentDrift(selfEval, prevSelfEval),
943
+ () => checkIntentDrift(selfEval, prevSelfEval, vaultEntries),
538
944
  // v2.2: Detect sub-goal drift — next_action doesn't align with pending sub-goals
539
945
  () => checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound),
540
946
  // v2.13: Post-backtrack workspace restore check
541
947
  () => checkBacktrackWorkspaceRestore(selfEval, backtrackSkippedFiles),
948
+ // v2.12: Post-backtrack git HEAD restore check
949
+ () => checkBacktrackGitHeadRestore(evidenceSnapshots, backtrackTargetGitHead),
542
950
  ];
543
951
  for (const run of checks) {
544
952
  const flag = run();