loopforge 3.0.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/README.md +128 -145
  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 +28 -20
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +82 -173
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +25 -16
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +25 -33
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +38 -138
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/enforcement-gate.d.ts +76 -0
  18. package/dist/enforcement-gate.d.ts.map +1 -0
  19. package/dist/enforcement-gate.js +1071 -0
  20. package/dist/enforcement-gate.js.map +1 -0
  21. package/dist/engine.d.ts +99 -0
  22. package/dist/engine.d.ts.map +1 -0
  23. package/dist/engine.js +839 -0
  24. package/dist/engine.js.map +1 -0
  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/evidence-provider.d.ts +21 -23
  30. package/dist/evidence-provider.d.ts.map +1 -1
  31. package/dist/evidence-provider.js +74 -34
  32. package/dist/evidence-provider.js.map +1 -1
  33. package/dist/generate-schema.js +1 -84
  34. package/dist/generate-schema.js.map +1 -1
  35. package/dist/index.d.ts +19 -22
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +16 -15
  38. package/dist/index.js.map +1 -1
  39. package/dist/loop-compiler.d.ts +41 -3
  40. package/dist/loop-compiler.d.ts.map +1 -1
  41. package/dist/loop-compiler.js +632 -273
  42. package/dist/loop-compiler.js.map +1 -1
  43. package/dist/loop-extras-parser.d.ts +2 -2
  44. package/dist/loop-extras-parser.d.ts.map +1 -1
  45. package/dist/loop-extras-parser.js +2 -2
  46. package/dist/loop-extras-parser.js.map +1 -1
  47. package/dist/loop-projection.d.ts +26 -0
  48. package/dist/loop-projection.d.ts.map +1 -0
  49. package/dist/loop-projection.js +142 -0
  50. package/dist/loop-projection.js.map +1 -0
  51. package/dist/loop-store.d.ts +112 -24
  52. package/dist/loop-store.d.ts.map +1 -1
  53. package/dist/loop-store.js +365 -218
  54. package/dist/loop-store.js.map +1 -1
  55. package/dist/mcp/round-lifecycle.d.ts +216 -0
  56. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  57. package/dist/mcp/round-lifecycle.js +926 -0
  58. package/dist/mcp/round-lifecycle.js.map +1 -0
  59. package/dist/mcp/server.d.ts +2 -1
  60. package/dist/mcp/server.d.ts.map +1 -1
  61. package/dist/mcp/server.js +28 -42
  62. package/dist/mcp/server.js.map +1 -1
  63. package/dist/mcp/session.d.ts +53 -101
  64. package/dist/mcp/session.d.ts.map +1 -1
  65. package/dist/mcp/session.js +306 -1105
  66. package/dist/mcp/session.js.map +1 -1
  67. package/dist/mcp/tools.d.ts +13 -16
  68. package/dist/mcp/tools.d.ts.map +1 -1
  69. package/dist/mcp/tools.js +479 -413
  70. package/dist/mcp/tools.js.map +1 -1
  71. package/dist/policy-metrics.d.ts +12 -17
  72. package/dist/policy-metrics.d.ts.map +1 -1
  73. package/dist/policy-metrics.js +95 -161
  74. package/dist/policy-metrics.js.map +1 -1
  75. package/dist/policy.d.ts +44 -35
  76. package/dist/policy.d.ts.map +1 -1
  77. package/dist/policy.js +31 -115
  78. package/dist/policy.js.map +1 -1
  79. package/dist/prompt-assembler.d.ts +37 -6
  80. package/dist/prompt-assembler.d.ts.map +1 -1
  81. package/dist/prompt-assembler.js +389 -160
  82. package/dist/prompt-assembler.js.map +1 -1
  83. package/dist/prompt-policy.d.ts +1 -8
  84. package/dist/prompt-policy.d.ts.map +1 -1
  85. package/dist/prompt-policy.js +8 -26
  86. package/dist/prompt-policy.js.map +1 -1
  87. package/dist/protocol.d.ts +357 -229
  88. package/dist/protocol.d.ts.map +1 -1
  89. package/dist/protocol.js +139 -15
  90. package/dist/protocol.js.map +1 -1
  91. package/dist/replay.d.ts +5 -13
  92. package/dist/replay.d.ts.map +1 -1
  93. package/dist/replay.js +19 -55
  94. package/dist/replay.js.map +1 -1
  95. package/dist/round-coordinator.d.ts +20 -24
  96. package/dist/round-coordinator.d.ts.map +1 -1
  97. package/dist/round-coordinator.js +99 -92
  98. package/dist/round-coordinator.js.map +1 -1
  99. package/dist/round-driver.d.ts +14 -13
  100. package/dist/round-driver.d.ts.map +1 -1
  101. package/dist/round-driver.js +26 -14
  102. package/dist/round-driver.js.map +1 -1
  103. package/dist/round-transaction.d.ts +15 -18
  104. package/dist/round-transaction.d.ts.map +1 -1
  105. package/dist/round-transaction.js +38 -47
  106. package/dist/round-transaction.js.map +1 -1
  107. package/dist/self-eval.d.ts +53 -0
  108. package/dist/self-eval.d.ts.map +1 -0
  109. package/dist/self-eval.js +284 -0
  110. package/dist/self-eval.js.map +1 -0
  111. package/dist/storage.d.ts +9 -4
  112. package/dist/storage.d.ts.map +1 -1
  113. package/dist/storage.js +18 -6
  114. package/dist/storage.js.map +1 -1
  115. package/dist/token-utils.d.ts +15 -3
  116. package/dist/token-utils.d.ts.map +1 -1
  117. package/dist/token-utils.js +24 -5
  118. package/dist/token-utils.js.map +1 -1
  119. package/dist/verification-gate.d.ts +108 -0
  120. package/dist/verification-gate.d.ts.map +1 -0
  121. package/dist/verification-gate.js +971 -0
  122. package/dist/verification-gate.js.map +1 -0
  123. package/loop_policy.json +44 -15
  124. package/package.json +5 -10
  125. package/skills/perception/SKILL.md +182 -199
  126. package/dist/canonical-event-writer.d.ts +0 -20
  127. package/dist/canonical-event-writer.d.ts.map +0 -1
  128. package/dist/canonical-event-writer.js +0 -131
  129. package/dist/canonical-event-writer.js.map +0 -1
  130. package/dist/compiler-subgoals.d.ts +0 -7
  131. package/dist/compiler-subgoals.d.ts.map +0 -1
  132. package/dist/compiler-subgoals.js +0 -93
  133. package/dist/compiler-subgoals.js.map +0 -1
  134. package/dist/derive-id.d.ts +0 -12
  135. package/dist/derive-id.d.ts.map +0 -1
  136. package/dist/derive-id.js +0 -31
  137. package/dist/derive-id.js.map +0 -1
  138. package/dist/mcp/session-projection.d.ts +0 -5
  139. package/dist/mcp/session-projection.d.ts.map +0 -1
  140. package/dist/mcp/session-projection.js +0 -18
  141. package/dist/mcp/session-projection.js.map +0 -1
  142. package/dist/mcp/session-queue.d.ts +0 -3
  143. package/dist/mcp/session-queue.d.ts.map +0 -1
  144. package/dist/mcp/session-queue.js +0 -18
  145. package/dist/mcp/session-queue.js.map +0 -1
  146. package/dist/mcp/session-types.d.ts +0 -72
  147. package/dist/mcp/session-types.d.ts.map +0 -1
  148. package/dist/mcp/session-types.js +0 -2
  149. package/dist/mcp/session-types.js.map +0 -1
  150. package/dist/round-commit-store.d.ts +0 -12
  151. package/dist/round-commit-store.d.ts.map +0 -1
  152. package/dist/round-commit-store.js +0 -13
  153. package/dist/round-commit-store.js.map +0 -1
  154. package/dist/round-gate.d.ts +0 -31
  155. package/dist/round-gate.d.ts.map +0 -1
  156. package/dist/round-gate.js +0 -259
  157. package/dist/round-gate.js.map +0 -1
  158. package/dist/round-recovery.d.ts +0 -17
  159. package/dist/round-recovery.d.ts.map +0 -1
  160. package/dist/round-recovery.js +0 -117
  161. package/dist/round-recovery.js.map +0 -1
  162. package/dist/round-report.d.ts +0 -16
  163. package/dist/round-report.d.ts.map +0 -1
  164. package/dist/round-report.js +0 -401
  165. package/dist/round-report.js.map +0 -1
  166. package/dist/runtime-services.d.ts +0 -59
  167. package/dist/runtime-services.d.ts.map +0 -1
  168. package/dist/runtime-services.js +0 -460
  169. package/dist/runtime-services.js.map +0 -1
  170. package/dist/vault-format.d.ts +0 -4
  171. package/dist/vault-format.d.ts.map +0 -1
  172. package/dist/vault-format.js +0 -4
  173. package/dist/vault-format.js.map +0 -1
  174. package/dist/vault-types.d.ts +0 -24
  175. package/dist/vault-types.d.ts.map +0 -1
  176. package/dist/vault-types.js +0 -2
  177. package/dist/vault-types.js.map +0 -1
  178. package/loopforge-policy.json +0 -340
@@ -0,0 +1,971 @@
1
+ /** Verification Gate — Layer 1 cross-round consistency checks.
2
+ *
3
+ * Pure-function module. Validates an agent's SelfEvaluation against the
4
+ * loop's own lineage before it enters the compiler.
5
+ *
6
+ * Verdict semantics:
7
+ * - trusted: all checks passed; flags are informational only.
8
+ * - suspect: one or more warn-level flags; flags become warnings in
9
+ * the next prompt so the agent can clarify.
10
+ * - contradicted: one or more error-level flags; the success flag for
11
+ * this round is excluded from the success trend (NOT
12
+ * modified). Flags become hard constraints — the agent
13
+ * must respond in the next round.
14
+ */
15
+ import { getPolicy } from "./policy.js";
16
+ import { makeVerificationFlag, makeVerificationResult } from "./protocol.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";
48
+ // ═══════════════════════════════════════════════════════════════════════════
49
+ // Helpers
50
+ // ═══════════════════════════════════════════════════════════════════════════
51
+ /** Extract the round number from a vault entry's loop_lineage.
52
+ * Returns 0 if the entry has no lineage or no round field.
53
+ * In practice, persistLoopLineage always writes round ≥ 1, so 0
54
+ * unambiguously means "not a valid round entry" in this context.
55
+ * Exported for reuse by enforcement-gate.ts. */
56
+ export function entryRound(entry) {
57
+ return sharedEntryRound(entry);
58
+ }
59
+ /** Read constraint_violations from a vault entry (entry-level, stored from
60
+ * the previous round's last_round_result at persist time). */
61
+ function entryViolations(entry) {
62
+ const viols = entry.constraint_violations;
63
+ if (Array.isArray(viols))
64
+ return viols.filter((v) => typeof v === "string");
65
+ return [];
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
+ }
151
+ // ═══════════════════════════════════════════════════════════════════════════
152
+ // Individual checks — each returns a VerificationFlag or null
153
+ // ═══════════════════════════════════════════════════════════════════════════
154
+ function checkProgressRegression(selfEval, prevSelfEval) {
155
+ if (!prevSelfEval?.execution_evidence)
156
+ return null;
157
+ if (!selfEval.execution_evidence)
158
+ return null;
159
+ const prevProgress = prevSelfEval.execution_evidence.progress_estimate;
160
+ const currProgress = selfEval.execution_evidence.progress_estimate;
161
+ if (typeof prevProgress !== "number" || typeof currProgress !== "number")
162
+ return null;
163
+ // Use delta with epsilon to avoid IEEE 754 rounding issues (0.8 - 0.2 > 0.6 in float)
164
+ if (prevProgress - currProgress <= 0.2 + 1e-10)
165
+ return null;
166
+ return makeVerificationFlag({
167
+ severity: "warn",
168
+ field: "progress_estimate",
169
+ check: CHECK_PROGRESS_REGRESSION,
170
+ detail: `Progress dropped from ${prevProgress.toFixed(2)} to ` +
171
+ `${currProgress.toFixed(2)} (delta: ${(currProgress - prevProgress).toFixed(2)})`,
172
+ });
173
+ }
174
+ function checkEmptyChangeWithPassing(selfEval) {
175
+ const ev = selfEval.execution_evidence;
176
+ if (!ev)
177
+ return null;
178
+ const filesEmpty = ev.files_changed.length === 0;
179
+ const testsAllPass = ev.test_results !== null &&
180
+ ev.test_results.failed === 0 &&
181
+ ev.test_results.passed > 0;
182
+ if (!filesEmpty || !testsAllPass || !effectiveSuccess(selfEval))
183
+ return null;
184
+ return makeVerificationFlag({
185
+ severity: "warn",
186
+ field: "execution_evidence",
187
+ check: CHECK_EMPTY_CHANGE_WITH_PASSING,
188
+ detail: "Agent claims success with no files changed and all tests passing — " +
189
+ "verify that work was actually performed",
190
+ });
191
+ }
192
+ function checkSuccessWithRemainingCriteria(selfEval) {
193
+ if (!effectiveSuccess(selfEval))
194
+ return null;
195
+ const remaining = selfEval.execution_evidence?.success_criteria_remaining;
196
+ if (!remaining || remaining.length === 0)
197
+ return null;
198
+ return makeVerificationFlag({
199
+ severity: "error",
200
+ field: "success",
201
+ check: CHECK_SUCCESS_WITH_REMAINING_CRITERIA,
202
+ detail: `Agent claims success but ${remaining.length} criteria remain unmet: ` +
203
+ remaining.slice(0, 3).join("; "),
204
+ });
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
+ }
349
+ function checkDuplicateConstraintDiscovery(selfEval, prevSelfEval, olderViolations) {
350
+ const discovered = selfEval.discovered_constraints;
351
+ if (!discovered || discovered.length === 0)
352
+ return null;
353
+ // Collect all previously-known constraints
354
+ const known = new Set();
355
+ for (const v of olderViolations)
356
+ known.add(v.toLowerCase().trim());
357
+ if (prevSelfEval) {
358
+ for (const d of prevSelfEval.discovered_constraints ?? []) {
359
+ known.add(d.toLowerCase().trim());
360
+ }
361
+ // Also treat previous violations as implicitly discovered
362
+ for (const v of prevSelfEval.constraint_violations) {
363
+ known.add(v.toLowerCase().trim());
364
+ }
365
+ }
366
+ for (const d of discovered) {
367
+ if (known.has(d.toLowerCase().trim())) {
368
+ return makeVerificationFlag({
369
+ severity: "warn",
370
+ field: "discovered_constraints",
371
+ check: CHECK_DUPLICATE_CONSTRAINT_DISCOVERY,
372
+ detail: `Constraint "${d}" was already known from a previous round`,
373
+ });
374
+ }
375
+ }
376
+ return null;
377
+ }
378
+ function checkRecurringViolation(selfEval, prevSelfEval, vaultEntries, currentRound) {
379
+ const currViols = selfEval.constraint_violations;
380
+ if (!currViols || currViols.length === 0)
381
+ return null;
382
+ // Build the violation history: [round N-2, round N-1, round N]
383
+ const violationsByRound = [];
384
+ // Round N-2 violations come from the vault entry for round N-1
385
+ // (persistLoopLineage stores the PREVIOUS round's violations on each entry)
386
+ if (currentRound >= 3) {
387
+ const entryNMinus1 = vaultEntries.find((e) => entryRound(e) === currentRound - 1);
388
+ if (entryNMinus1) {
389
+ const viols = entryViolations(entryNMinus1);
390
+ if (viols.length)
391
+ violationsByRound.push(viols.map((v) => v.toLowerCase().trim()));
392
+ }
393
+ }
394
+ // Round N-1 violations from prevSelfEval
395
+ if (prevSelfEval) {
396
+ const prevViols = prevSelfEval.constraint_violations;
397
+ violationsByRound.push(prevViols.map((v) => v.toLowerCase().trim()));
398
+ }
399
+ // Round N violations from current selfEval
400
+ violationsByRound.push(currViols.map((v) => v.toLowerCase().trim()));
401
+ // Need at least 3 rounds of data
402
+ if (violationsByRound.length < 3)
403
+ return null;
404
+ // Check each current violation against the previous 2 rounds
405
+ const [rNMinus2, rNMinus1, rN] = violationsByRound.slice(-3);
406
+ for (const v of rN) {
407
+ if (rNMinus1.includes(v) && rNMinus2.includes(v)) {
408
+ return makeVerificationFlag({
409
+ severity: "error",
410
+ field: "constraint_violations",
411
+ check: CHECK_RECURRING_VIOLATION,
412
+ detail: `Constraint violation "${v}" has appeared in 3 consecutive rounds ` +
413
+ `(rounds ${currentRound - 2}–${currentRound}) without resolution`,
414
+ });
415
+ }
416
+ }
417
+ return null;
418
+ }
419
+ function checkRetractFreshConstraint(selfEval, prevSelfEval, currentRound) {
420
+ const retracted = selfEval.retracted_constraints;
421
+ if (!retracted || retracted.length === 0)
422
+ return null;
423
+ if (!prevSelfEval)
424
+ return null;
425
+ const lastRoundDiscoveries = new Set();
426
+ for (const d of prevSelfEval.discovered_constraints ?? []) {
427
+ lastRoundDiscoveries.add(d.toLowerCase().trim());
428
+ }
429
+ for (const r of retracted) {
430
+ if (lastRoundDiscoveries.has(r.toLowerCase().trim())) {
431
+ return makeVerificationFlag({
432
+ severity: "warn",
433
+ field: "retracted_constraints",
434
+ check: CHECK_RETRACT_FRESH_CONSTRAINT,
435
+ detail: `Retracting constraint "${r}" that was just discovered in round ` +
436
+ `${currentRound - 1} — may indicate rapid flip-flopping`,
437
+ });
438
+ }
439
+ }
440
+ return null;
441
+ }
442
+ /** v1.18: Cross-validate agent-reported files_changed against git evidence
443
+ * from the configured evidence providers (ProviderSnapshot array). */
444
+ function checkEvidenceIntegrity(selfEval, evidenceSnapshots) {
445
+ if (!selfEval.execution_evidence)
446
+ return null;
447
+ if (evidenceSnapshots.length === 0)
448
+ return null;
449
+ const gitSnap = evidenceSnapshots.find((s) => s.provider === "git");
450
+ if (!gitSnap)
451
+ return null;
452
+ const reported = [...selfEval.execution_evidence.files_changed].sort();
453
+ const actual = [...gitSnap.files].sort();
454
+ if (reported.length === 0 && actual.length === 0)
455
+ return null;
456
+ if (reported.length === 0 && actual.length > 0) {
457
+ return makeVerificationFlag({
458
+ severity: "warn",
459
+ field: "files_changed",
460
+ check: CHECK_EVIDENCE_INTEGRITY,
461
+ detail: `Agent reported no files changed but evidence shows: ${actual.join(", ")}`,
462
+ });
463
+ }
464
+ const ghostFiles = reported.filter((f) => !actual.includes(f));
465
+ const missedFiles = actual.filter((f) => !reported.includes(f));
466
+ if (ghostFiles.length > 0 || missedFiles.length > 0) {
467
+ const parts = [];
468
+ if (ghostFiles.length > 0)
469
+ parts.push(`unconfirmed: [${ghostFiles.join(", ")}]`);
470
+ if (missedFiles.length > 0)
471
+ parts.push(`unreported: [${missedFiles.join(", ")}]`);
472
+ return makeVerificationFlag({
473
+ severity: "warn",
474
+ field: "files_changed",
475
+ check: CHECK_EVIDENCE_INTEGRITY,
476
+ detail: `Agent files_changed doesn't match evidence: ${parts.join("; ")}`,
477
+ });
478
+ }
479
+ return null;
480
+ }
481
+ /** A required, explicitly configured verification command is authoritative
482
+ * when the Agent claims success. Optional commands remain observational. */
483
+ function checkRequiredCommandEvidence(selfEval, evidenceSnapshots) {
484
+ if (!effectiveSuccess(selfEval))
485
+ return null;
486
+ for (const snapshot of evidenceSnapshots) {
487
+ if (snapshot.data.kind !== "command")
488
+ continue;
489
+ if (snapshot.data.phase !== "after" || snapshot.data.required !== true)
490
+ continue;
491
+ const status = snapshot.data.status;
492
+ if (status === "passed")
493
+ continue;
494
+ const name = typeof snapshot.data.commandName === "string"
495
+ ? snapshot.data.commandName
496
+ : snapshot.provider;
497
+ const exitCode = typeof snapshot.data.exitCode === "number"
498
+ ? ` (exit ${snapshot.data.exitCode})`
499
+ : "";
500
+ return makeVerificationFlag({
501
+ severity: "error",
502
+ field: "execution_evidence",
503
+ check: CHECK_REQUIRED_COMMAND_FAILED,
504
+ detail: `Agent claims success but required command "${name}" ${String(status)}${exitCode}`,
505
+ });
506
+ }
507
+ return null;
508
+ }
509
+ // ── Test output parsing ────────────────────────────────────────────────────
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
+ }
550
+ /** v2.1 (check 11): Compare the previous round's declared next_action with
551
+ * the current round's actual output_summary. Detect when the agent says it
552
+ * will do X but then does Y — silent drift without explanation.
553
+ *
554
+ * Only fires when both next_action and output_summary are non-empty.
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) {
569
+ if (!prevSelfEval?.next_action?.trim())
570
+ return null;
571
+ if (!selfEval.output_summary.trim())
572
+ return null;
573
+ const intent = prevSelfEval.next_action.trim();
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
613
+ const score = jaccardSimilarity(intent, actual);
614
+ const threshold = policy.evolution.intent_drift_threshold;
615
+ if (score >= threshold)
616
+ return null;
617
+ const idNote = intentIds.length > 0
618
+ ? `; ${intentIds.length} referenced sub-goal ID(s) did not match completed_subtasks`
619
+ : "";
620
+ return makeVerificationFlag({
621
+ severity: "warn",
622
+ field: "output_summary",
623
+ check: CHECK_INTENT_DRIFT,
624
+ detail: `Declared intent was "${intent.slice(0, 120)}" ` +
625
+ `but actual output "${actual.slice(0, 120)}" — ` +
626
+ `similarity ${(score * 100).toFixed(0)}% below threshold ${(threshold * 100).toFixed(0)}%` +
627
+ idNote,
628
+ });
629
+ }
630
+ /** Best-effort parse of test counts from common test-runner output formats.
631
+ *
632
+ * Scans the last 2000 characters of stdout (where summary lines typically
633
+ * appear) and tries patterns in descending specificity order. Returns null
634
+ * when the output format is unrecognized, stdout is empty, or a confident
635
+ * parse cannot be made.
636
+ *
637
+ * Recognized formats: Jest verbose/compact, Mocha, pytest/unittest, Go test,
638
+ * and PHPUnit OK summaries. */
639
+ export function parseTestOutput(stdout) {
640
+ if (!stdout)
641
+ return null;
642
+ const tail = stdout.length > 2000 ? stdout.slice(-2000) : stdout;
643
+ let match;
644
+ // 1. Jest verbose: "Tests: 1 failed, 8 passed, 9 total"
645
+ match = tail.match(/Tests:\s*(\d+)\s+failed,\s*(\d+)\s+passed,\s*(\d+)\s+total/i);
646
+ if (match) {
647
+ return { failed: Number(match[1]), passed: Number(match[2]), total: Number(match[3]), skipped: 0 };
648
+ }
649
+ // 2. Jest compact: "Tests: 8 passed, 9 total"
650
+ match = tail.match(/Tests:\s*(\d+)\s+passed,\s*(\d+)\s+total/i);
651
+ if (match) {
652
+ const passed = Number(match[1]);
653
+ const total = Number(match[2]);
654
+ return { passed, failed: total - passed, total, skipped: 0 };
655
+ }
656
+ // 3. Mocha: "8 passing" + optional "2 failing"
657
+ const mochaPass = tail.match(/(\d+)\s+passing/i);
658
+ if (mochaPass) {
659
+ const mochaFail = tail.match(/(\d+)\s+failing/i);
660
+ const passed = Number(mochaPass[1]);
661
+ const failed = mochaFail ? Number(mochaFail[1]) : 0;
662
+ return { passed, failed, total: passed + failed, skipped: 0 };
663
+ }
664
+ // 4. Go test: count "--- PASS:" and "--- FAIL:" lines
665
+ const goPassCount = (tail.match(/---\s+PASS:/g) || []).length;
666
+ const goFailCount = (tail.match(/---\s+FAIL:/g) || []).length;
667
+ if (goPassCount > 0 || goFailCount > 0) {
668
+ return { passed: goPassCount, failed: goFailCount, total: goPassCount + goFailCount, skipped: 0 };
669
+ }
670
+ // 5. pytest / unittest: "8 passed, 1 failed" or "8 passed"
671
+ match = tail.match(/(\d+)\s+passed[,;\s]+(\d+)\s+failed/i);
672
+ if (match) {
673
+ return { passed: Number(match[1]), failed: Number(match[2]), total: Number(match[1]) + Number(match[2]), skipped: 0 };
674
+ }
675
+ // pytest compact: "8 passed" (with a reasonable context hint)
676
+ match = tail.match(/(?:=\s+test session|\b(?:passed|failed)\b)/i);
677
+ if (match) {
678
+ const pCount = tail.match(/(\d+)\s+passed/i);
679
+ const fCount = tail.match(/(\d+)\s+failed/i);
680
+ if (pCount || fCount) {
681
+ const passed = pCount ? Number(pCount[1]) : 0;
682
+ const failed = fCount ? Number(fCount[1]) : 0;
683
+ return { passed, failed, total: passed + failed, skipped: 0 };
684
+ }
685
+ }
686
+ // 6. PHPUnit: "OK (8 tests, 16 assertions)"
687
+ match = tail.match(/OK\s*\((\d+)\s+tests?/i);
688
+ if (match) {
689
+ const total = Number(match[1]);
690
+ return { passed: total, failed: 0, total, skipped: 0 };
691
+ }
692
+ return null;
693
+ }
694
+ /** Cross-validate agent-reported test_results against command evidence output.
695
+ *
696
+ * For each command provider whose status is "passed" and output is not
697
+ * truncated, attempts to parse test counts from stdout. Mismatched counts
698
+ * produce a warn-level flag; failures hidden by the agent (reported 0 failed
699
+ * when the command shows >0) produce an error-level flag.
700
+ *
701
+ * Non-command evidence providers are skipped (no structural cross-check is
702
+ * defined for them yet). */
703
+ function checkCommandEvidenceIntegrity(selfEval, evidenceSnapshots) {
704
+ const reported = selfEval.execution_evidence?.test_results;
705
+ if (!reported)
706
+ return null;
707
+ for (const snapshot of evidenceSnapshots) {
708
+ if (snapshot.data.kind !== "command")
709
+ continue;
710
+ if (snapshot.data.status !== "passed")
711
+ continue;
712
+ if (snapshot.data.truncated === true)
713
+ continue;
714
+ const stdout = typeof snapshot.data.stdout === "string" ? snapshot.data.stdout : "";
715
+ const parsed = parseTestOutput(stdout);
716
+ if (!parsed)
717
+ continue;
718
+ const mismatches = [];
719
+ if (parsed.passed !== reported.passed) {
720
+ mismatches.push(`passed: reported ${reported.passed}, command shows ${parsed.passed}`);
721
+ }
722
+ if (parsed.failed !== reported.failed) {
723
+ mismatches.push(`failed: reported ${reported.failed}, command shows ${parsed.failed}`);
724
+ }
725
+ if (mismatches.length === 0) {
726
+ // Counts match exactly — evidence supports the agent's claim.
727
+ continue;
728
+ }
729
+ const name = typeof snapshot.data.commandName === "string"
730
+ ? snapshot.data.commandName
731
+ : snapshot.provider;
732
+ // Agent hides failures → error. Other mismatch → warn.
733
+ const severity = parsed.failed > 0 && reported.failed === 0 ? "error" : "warn";
734
+ return makeVerificationFlag({
735
+ severity,
736
+ field: "test_results",
737
+ check: CHECK_COMMAND_EVIDENCE_MISMATCH,
738
+ detail: `Agent test_results don't match "${name}" output: ${mismatches.join("; ")}`,
739
+ });
740
+ }
741
+ return null;
742
+ }
743
+ /** v2.2: Check if the agent's declared next_action aligns with any pending
744
+ * or in_progress sub-goal. When 3+ pending sub-goals exist but the agent
745
+ * plans work unrelated to any of them, it may indicate task drift.
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. */
751
+ function checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound) {
752
+ const nextAction = selfEval.next_action?.trim();
753
+ if (!nextAction)
754
+ return null;
755
+ // Reconstruct pending sub-goal descriptions from vault entries
756
+ const pendingSubGoals = new Set();
757
+ const completedOrCanceled = new Set();
758
+ for (const entry of vaultEntries) {
759
+ const emerged = Array.isArray(entry.emerged_subtasks)
760
+ ? entry.emerged_subtasks.filter((v) => typeof v === "string")
761
+ : [];
762
+ for (const desc of emerged) {
763
+ pendingSubGoals.add(desc.trim());
764
+ }
765
+ const done = Array.isArray(entry.completed_subtasks)
766
+ ? entry.completed_subtasks.filter((v) => typeof v === "string")
767
+ : [];
768
+ for (const desc of done) {
769
+ completedOrCanceled.add(desc.trim());
770
+ }
771
+ const canceled = Array.isArray(entry.canceled_subtasks)
772
+ ? entry.canceled_subtasks.filter((v) => typeof v === "string")
773
+ : [];
774
+ for (const desc of canceled) {
775
+ completedOrCanceled.add(desc.trim());
776
+ }
777
+ }
778
+ // Also process current selfEval
779
+ for (const desc of selfEval.emerged_subtasks ?? []) {
780
+ pendingSubGoals.add(desc.trim());
781
+ }
782
+ for (const desc of selfEval.completed_subtasks ?? []) {
783
+ completedOrCanceled.add(desc.trim());
784
+ }
785
+ for (const desc of selfEval.canceled_subtasks ?? []) {
786
+ completedOrCanceled.add(desc.trim());
787
+ }
788
+ // Remove completed/canceled from pending
789
+ for (const desc of completedOrCanceled) {
790
+ pendingSubGoals.delete(desc);
791
+ }
792
+ // Need at least 3 pending sub-goals for the check to be meaningful
793
+ if (pendingSubGoals.size < 3)
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
+ }
803
+ // Check if next_action aligns with any pending sub-goal
804
+ let aligns = false;
805
+ for (const sg of pendingSubGoals) {
806
+ const score = jaccardSimilarity(nextAction, sg);
807
+ if (score >= getPolicy().evolution.subgoal_drift_alignment_threshold) {
808
+ aligns = true;
809
+ break;
810
+ }
811
+ }
812
+ if (!aligns) {
813
+ const sample = [...pendingSubGoals].slice(0, 3).join(", ");
814
+ return makeVerificationFlag({
815
+ severity: "warn",
816
+ field: "next_action",
817
+ check: CHECK_SUBGOAL_DRIFT,
818
+ detail: `Agent's next_action doesn't align with any of ${pendingSubGoals.size} pending sub-goals. ` +
819
+ `Pending: ${sample}… Consider completing existing sub-goals before starting new work, ` +
820
+ `or cancel outdated sub-goals via canceled_subtasks.`,
821
+ });
822
+ }
823
+ return null;
824
+ }
825
+ /** v2.13: After a backtrack, check that the agent restored the workspace
826
+ * before working. If the agent's files_changed overlaps with files from
827
+ * skipped (failed) rounds, the workspace was likely not clean. */
828
+ function checkBacktrackWorkspaceRestore(selfEval, backtrackSkippedFiles) {
829
+ if (backtrackSkippedFiles.length === 0)
830
+ return null;
831
+ const ev = selfEval.execution_evidence;
832
+ if (!ev || ev.files_changed.length === 0)
833
+ return null;
834
+ // Check overlap: files the agent changed vs. files from skipped rounds
835
+ const overlap = ev.files_changed.filter((f) => backtrackSkippedFiles.some((sf) => sf === f || f.endsWith(sf) || sf.endsWith(f)));
836
+ if (overlap.length === 0)
837
+ return null;
838
+ // Agent is modifying files that were part of failed rounds without
839
+ // having properly restored the workspace first.
840
+ if (overlap.length >= 3 || overlap.length === ev.files_changed.length) {
841
+ return makeVerificationFlag({
842
+ severity: "error",
843
+ field: "files_changed",
844
+ check: CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED,
845
+ detail: `Agent modified ${overlap.length} file(s) that were part of ` +
846
+ `the skipped (failed) rounds: ${overlap.slice(0, 5).join(", ")}. ` +
847
+ `The workspace was likely not restored before working.`,
848
+ });
849
+ }
850
+ // Minor overlap — warn only
851
+ return makeVerificationFlag({
852
+ severity: "warn",
853
+ field: "files_changed",
854
+ check: CHECK_BACKTRACK_WORKSPACE_NOT_RESTORED,
855
+ detail: `Agent modified files that overlap with skipped rounds: ` +
856
+ `${overlap.join(", ")}. Verify the workspace was restored.`,
857
+ });
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
+ }
883
+ // ═══════════════════════════════════════════════════════════════════════════
884
+ // Main entry point
885
+ // ═══════════════════════════════════════════════════════════════════════════
886
+ /** Verify a SelfEvaluation against the loop's cross-round lineage.
887
+ *
888
+ * @param selfEval The agent's self-evaluation for the current round.
889
+ * @param currentRound The current round number (1-based).
890
+ * @param vaultEntries Vault entries for this loop (non-feedback only).
891
+ * @param prevSelfEval The agent's self-evaluation from the previous round
892
+ * (null for round 1).
893
+ * @param evidenceSnapshots v1.18: Evidence snapshots from configured providers.
894
+ * Used by checkEvidenceIntegrity for multi-provider
895
+ * cross-validation. Defaults to empty array. */
896
+ export function verifySelfEvaluation(selfEval, currentRound, vaultEntries, prevSelfEval = null, evidenceSnapshots = [],
897
+ /** v2.13: Files from skipped backtrack rounds. If the agent's
898
+ * files_changed overlaps significantly with these, the workspace
899
+ * was not properly restored before working. */
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) {
905
+ const flags = [];
906
+ // Collect violations from all previous vault entries for duplicate-discovery
907
+ // and other checks that need deeper history.
908
+ const olderViolations = [];
909
+ for (const entry of vaultEntries) {
910
+ for (const v of entryViolations(entry))
911
+ olderViolations.push(v);
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);
919
+ // Run all checks
920
+ const checks = [
921
+ () => checkProgressRegression(selfEval, prevSelfEval),
922
+ () => checkEmptyChangeWithPassing(selfEval),
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),
934
+ () => checkDuplicateConstraintDiscovery(selfEval, prevSelfEval, olderViolations),
935
+ () => checkRecurringViolation(selfEval, prevSelfEval, vaultEntries, currentRound),
936
+ () => checkRetractFreshConstraint(selfEval, prevSelfEval, currentRound),
937
+ // v1.18: Cross-validate agent-reported files_changed against git evidence
938
+ () => checkEvidenceIntegrity(selfEval, evidenceSnapshots),
939
+ () => checkRequiredCommandEvidence(selfEval, evidenceSnapshots),
940
+ // v2.0: Cross-validate agent-reported test_results against command output
941
+ () => checkCommandEvidenceIntegrity(selfEval, evidenceSnapshots),
942
+ // v2.1: Detect intent-action drift — agent said X but did Y
943
+ () => checkIntentDrift(selfEval, prevSelfEval, vaultEntries),
944
+ // v2.2: Detect sub-goal drift — next_action doesn't align with pending sub-goals
945
+ () => checkSubGoalDrift(selfEval, prevSelfEval, vaultEntries, currentRound),
946
+ // v2.13: Post-backtrack workspace restore check
947
+ () => checkBacktrackWorkspaceRestore(selfEval, backtrackSkippedFiles),
948
+ // v2.12: Post-backtrack git HEAD restore check
949
+ () => checkBacktrackGitHeadRestore(evidenceSnapshots, backtrackTargetGitHead),
950
+ ];
951
+ for (const run of checks) {
952
+ const flag = run();
953
+ if (flag)
954
+ flags.push(flag);
955
+ }
956
+ // Determine verdict from the most severe flag present
957
+ const hasError = flags.some((f) => f.severity === "error");
958
+ const hasWarn = flags.some((f) => f.severity === "warn");
959
+ let verdict;
960
+ if (hasError) {
961
+ verdict = "contradicted";
962
+ }
963
+ else if (hasWarn) {
964
+ verdict = "suspect";
965
+ }
966
+ else {
967
+ verdict = "trusted";
968
+ }
969
+ return makeVerificationResult({ verdict, flags });
970
+ }
971
+ //# sourceMappingURL=verification-gate.js.map