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
@@ -0,0 +1,926 @@
1
+ /** LoopForge MCP — Round lifecycle.
2
+ *
3
+ * The round state machine split out of SessionManager: crash recovery,
4
+ * transaction execution, disposition result building, and the advance
5
+ * pipeline. SessionManager owns "who may touch a session" (registry, queue,
6
+ * leases); this class owns "what happens to a session" (state machine,
7
+ * recovery, result building).
8
+ *
9
+ * Every method was moved verbatim from SessionManager. Only reference paths
10
+ * changed (this.sessions.* → registry.*, this.ownerId / this.leaseMs →
11
+ * constructor deps, this.contextProvider → getContext()).
12
+ */
13
+ import { randomUUID } from "node:crypto";
14
+ import { LoopForgeEngine, extractSelfEvaluation } from "../engine.js";
15
+ import { extractSelfEvaluationWithDiagnostics, inferOutcomeFromText, } from "../self-eval.js";
16
+ import { deriveGate } from "../cognitive-governance.js";
17
+ import { Mode, makeLoopCompileRequest, makeLoopRoundResult, } from "../protocol.js";
18
+ import { EvidenceCollector } from "../evidence-provider.js";
19
+ import { makeRoundId, parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
20
+ import { RoundDriver } from "../round-driver.js";
21
+ import { getPolicy } from "../policy.js";
22
+ import { checkRoundSequence, queryLoopEntries } from "../loop-store.js";
23
+ import { logEvent } from "../observability.js";
24
+ import { policyMetrics } from "../policy-metrics.js";
25
+ // ── Helpers ────────────────────────────────────────────────────────────────
26
+ export function buildLoopRequest(session, lastEval, verificationFlags) {
27
+ const req = {
28
+ task: session.task,
29
+ mode: Mode.LOOP_COMPILE,
30
+ feedback: null,
31
+ skill_name: null,
32
+ task_id: null,
33
+ loop_id: session.loopId,
34
+ round: session.currentRound,
35
+ max_rounds: session.maxRounds,
36
+ verification_flags: verificationFlags ?? [],
37
+ };
38
+ if (lastEval) {
39
+ req.last_round_result = makeLoopRoundResult({
40
+ round: session.currentRound - 1,
41
+ success: lastEval.success,
42
+ output_summary: lastEval.output_summary,
43
+ constraint_violations: lastEval.constraint_violations,
44
+ manual_fixes_needed: "",
45
+ // P0–P2: Forward evolution fields to next compile
46
+ // Merge sub-agent discovered constraints into the active set
47
+ discovered_constraints: [
48
+ ...new Set([
49
+ ...(lastEval.discovered_constraints ?? []),
50
+ ...(lastEval.worker_results ?? []).flatMap((w) => w.discoveredConstraints ?? []).filter((c) => c.length > 0),
51
+ ]),
52
+ ],
53
+ objective_refinement: lastEval.objective_refinement ?? "",
54
+ emerged_subtasks: lastEval.emerged_subtasks ?? [],
55
+ // P4: Execution evidence
56
+ execution_evidence: lastEval.execution_evidence ?? undefined,
57
+ // P5: Self-correction
58
+ retracted_constraints: lastEval.retracted_constraints ?? [],
59
+ revised_success_criteria: lastEval.revised_success_criteria ?? [],
60
+ wrong_assumptions: lastEval.wrong_assumptions ?? [],
61
+ // Multi-agent: Forward delegation results to next compile
62
+ worker_results: lastEval.worker_results ?? [],
63
+ // v1.10: Checkpoint boundary
64
+ compression_checkpoint: lastEval.compression_checkpoint ?? false,
65
+ checkpoint_label: lastEval.checkpoint_label ?? "",
66
+ // v1.16: Agent's declared next action
67
+ next_action: lastEval.next_action,
68
+ // v2.2: Sub-goal lifecycle
69
+ completed_subtasks: lastEval.completed_subtasks ?? [],
70
+ blocked_subtasks: lastEval.blocked_subtasks ?? [],
71
+ canceled_subtasks: lastEval.canceled_subtasks ?? [],
72
+ // v2.8: Drift clarification
73
+ drift_clarification: lastEval.drift_clarification,
74
+ // v2.9: Model's information needs for the next prompt
75
+ prompt_requests: lastEval.prompt_requests,
76
+ // v2.12: Tri-state outcome + blocker + retroactive claims
77
+ outcome: lastEval.outcome,
78
+ blocker: lastEval.blocker,
79
+ retroactiveClaims: lastEval.retroactiveClaims ?? [],
80
+ });
81
+ }
82
+ return req;
83
+ }
84
+ // ── RoundLifecycle ─────────────────────────────────────────────────────────
85
+ export class RoundLifecycle {
86
+ store;
87
+ sessionStore;
88
+ registry;
89
+ terminalSinks;
90
+ ownerId;
91
+ leaseMs;
92
+ getContext;
93
+ constructor(deps) {
94
+ this.store = deps.store;
95
+ this.sessionStore = deps.sessionStore;
96
+ this.registry = deps.registry;
97
+ this.terminalSinks = deps.terminalSinks;
98
+ this.ownerId = deps.ownerId;
99
+ this.leaseMs = deps.leaseMs;
100
+ this.getContext = deps.getContext;
101
+ }
102
+ /** Persist session state to vault for cross-process recovery.
103
+ * The filtered vault and replacement entry are written once under the
104
+ * backend lock, so recovery never observes the old two-write gap. */
105
+ save(session) {
106
+ if (!this.sessionStore)
107
+ return;
108
+ const leaseActive = session.status === "running";
109
+ const sessionEntry = {
110
+ task_id: `loop:${session.loopId}:session`,
111
+ task_type: "session_state",
112
+ timestamp: new Date().toISOString(),
113
+ loop_id: session.loopId,
114
+ task: session.task,
115
+ loop_lineage: {
116
+ session_id: session.sessionId,
117
+ current_round: session.currentRound,
118
+ max_rounds: session.maxRounds,
119
+ success_trajectory: session.successTrajectory,
120
+ status: session.status,
121
+ created_at: session.createdAt,
122
+ // v1.13: Enforcement gate state
123
+ consecutive_rejections: session.consecutiveRejections,
124
+ last_rejection_check: session.lastRejectionCheck,
125
+ // v2.12: Clarification streak
126
+ drift_clarification_streak: session.driftClarificationStreak,
127
+ // v2.13: Backtrack skipped files for workspace restore check
128
+ backtrack_skipped_files: session.backtrackSkippedFiles,
129
+ // v2.12: Backtrack restore-point git HEAD (crash recovery)
130
+ backtrack_target_git_head: session.backtrackTargetGitHead ?? null,
131
+ // v1.19: durable round transaction state
132
+ round_snapshot: session.roundSnapshot ?? null,
133
+ last_self_eval: session.lastSelfEval ?? null,
134
+ current_prompt: session.currentPrompt ?? null,
135
+ current_level: session.currentLevel ?? "",
136
+ // v1.20: cross-process single-owner lease
137
+ lease_owner: leaseActive ? this.ownerId : "",
138
+ lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
139
+ },
140
+ };
141
+ this.sessionStore.save(sessionEntry, {
142
+ expectedLeaseOwner: this.ownerId,
143
+ });
144
+ }
145
+ /** Reconstruct a McpSession from a vault session_state entry.
146
+ * Returns null if the entry is not "running" status.
147
+ * Shared by resume() and autoResumeAll(). */
148
+ reconstructSession(entry, allowPaused = false) {
149
+ const lineage = (entry.loop_lineage ?? {});
150
+ const status = lineage.status ?? "running";
151
+ if (status !== "running" && !(allowPaused && status === "paused")) {
152
+ return null;
153
+ }
154
+ const loopId = entry.loop_id;
155
+ // v2.14: load-time gap detection — a runtime loop whose round sequence
156
+ // has holes (deleted/tampered round documents) must not be resumed
157
+ // silently. Legacy loops without stamps are exempt; gaps and corruption
158
+ // surface as StorageCorruptionError. Previously checkRoundSequence was
159
+ // only reachable through the read-only audit.
160
+ checkRoundSequence(this.store, loopId);
161
+ const currentRound = lineage.current_round ?? 1;
162
+ const successTrajectory = lineage.success_trajectory ?? lineage.quality_trajectory ?? [];
163
+ const task = entry.task ?? "";
164
+ const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
165
+ const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
166
+ const fallbackEvidence = EvidenceCollector.fromProviderNames(getPolicy().evidence.providers).collect();
167
+ const persistedEval = lineage.last_self_eval;
168
+ const lastSelfEval = persistedEval !== null &&
169
+ typeof persistedEval === "object" &&
170
+ !Array.isArray(persistedEval) &&
171
+ typeof persistedEval.success === "boolean" &&
172
+ typeof persistedEval.output_summary === "string"
173
+ ? persistedEval
174
+ : undefined;
175
+ const engine = new LoopForgeEngine(this.store);
176
+ return {
177
+ sessionId: typeof lineage.session_id === "string" && lineage.session_id
178
+ ? lineage.session_id
179
+ : randomUUID(),
180
+ loopId, task, engine,
181
+ currentRound, maxRounds, successTrajectory,
182
+ status: status,
183
+ createdAt: lineage.created_at ?? Date.now(),
184
+ consecutiveRejections: lineage.consecutive_rejections ?? 0,
185
+ lastRejectionCheck: typeof lineage.last_rejection_check === "string"
186
+ ? lineage.last_rejection_check
187
+ : "",
188
+ driftClarificationStreak: lineage.drift_clarification_streak ?? 0,
189
+ backtrackSkippedFiles: lineage.backtrack_skipped_files ?? [],
190
+ backtrackTargetGitHead: typeof lineage.backtrack_target_git_head === "string" &&
191
+ lineage.backtrack_target_git_head.length > 0
192
+ ? lineage.backtrack_target_git_head
193
+ : undefined,
194
+ evidenceBaseline: roundSnapshot?.beforeEvidence ?? fallbackEvidence,
195
+ roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, fallbackEvidence),
196
+ lastSelfEval,
197
+ currentPrompt: typeof lineage.current_prompt === "string"
198
+ ? lineage.current_prompt
199
+ : null,
200
+ currentLevel: typeof lineage.current_level === "string"
201
+ ? lineage.current_level
202
+ : undefined,
203
+ };
204
+ }
205
+ /** Apply a prepared round to the session, persist, and build the result.
206
+ * Shared by reconcileCommittedRound, resume, and unpause — the three
207
+ * compile-then-persist tails were previously copy-pasted. */
208
+ persistPrepared(session, prepared, roundSuccess, warningsOverride) {
209
+ session.evidenceBaseline = prepared.baseline;
210
+ session.roundSnapshot = prepared.snapshot;
211
+ session.currentPrompt = prepared.prompt;
212
+ session.currentLevel = prepared.level;
213
+ session.currentWarnings = prepared.warnings;
214
+ session.lastCompileResponse = prepared.compileResponse ?? null;
215
+ policyMetrics.recordStrategy(session.loopId, prepared.level);
216
+ this.save(session);
217
+ return {
218
+ sessionId: session.sessionId,
219
+ round: session.currentRound,
220
+ roundId: session.roundSnapshot.roundId,
221
+ prompt: prepared.prompt,
222
+ level: prepared.level,
223
+ roundSuccess,
224
+ warnings: warningsOverride ?? session.currentWarnings ?? [],
225
+ };
226
+ }
227
+ restoredPromptResult(session) {
228
+ if (!session.currentPrompt)
229
+ return null;
230
+ return {
231
+ sessionId: session.sessionId,
232
+ round: session.currentRound,
233
+ roundId: session.roundSnapshot?.roundId,
234
+ prompt: session.currentPrompt,
235
+ level: session.currentLevel ?? "l2",
236
+ roundSuccess: undefined,
237
+ warnings: (session.currentWarnings ?? []),
238
+ };
239
+ }
240
+ /** Reconcile the crash window where feedback committed but session_state
241
+ * still points at the old prompt. Returns null when no commit is pending. */
242
+ reconcileCommittedRound(session) {
243
+ if (!session.roundSnapshot)
244
+ return null;
245
+ const recovered = new RoundDriver(session.engine, this.store).recover(session.roundSnapshot);
246
+ if (!recovered)
247
+ return null;
248
+ const pr = recovered.result;
249
+ session.roundSnapshot = recovered.snapshot;
250
+ // Replay the committed counter — but with per-rule tracking.
251
+ if (pr.action === "reject" && pr.rejectionCheck) {
252
+ session.consecutiveRejections =
253
+ pr.rejectionCheck === session.lastRejectionCheck
254
+ ? pr.newConsecutiveRejections
255
+ : 1;
256
+ session.lastRejectionCheck = pr.rejectionCheck;
257
+ }
258
+ else {
259
+ session.consecutiveRejections = pr.newConsecutiveRejections;
260
+ if (pr.action !== "reject")
261
+ session.lastRejectionCheck = "";
262
+ }
263
+ if (pr.newLastSelfEval)
264
+ session.lastSelfEval = pr.newLastSelfEval;
265
+ if (pr.shouldPushSuccessTrajectory &&
266
+ session.successTrajectory.length < session.currentRound) {
267
+ session.successTrajectory.push(pr.roundSuccess);
268
+ }
269
+ session.currentPrompt = null;
270
+ if (pr.action === "stop" || pr.action === "terminate") {
271
+ const reason = pr.action === "terminate"
272
+ ? "enforcement_terminated"
273
+ : pr.stopReason ?? "stalled";
274
+ session.status = reason === "stalled" ? "stalled" : "stopped";
275
+ this.save(session);
276
+ void this.notifyTerminal(session, reason);
277
+ return {
278
+ sessionId: session.sessionId,
279
+ round: session.currentRound,
280
+ roundId: recovered.snapshot.roundId,
281
+ prompt: null,
282
+ stopReason: reason,
283
+ stopDetail: reason === "completed"
284
+ ? "Task completed successfully — the agent declared should_continue=false with success=true."
285
+ : reason === "blocked"
286
+ ? "Agent cannot proceed under current constraints. The task may need revised constraints or human intervention."
287
+ : reason === "failed"
288
+ ? "Agent declared should_continue=false with success=false — the task was not achieved."
289
+ : `Loop stopped: ${reason}.`,
290
+ roundSuccess: pr.roundSuccess,
291
+ };
292
+ }
293
+ if (pr.action === "backtrack") {
294
+ // Backtrack committed but prompt not delivered (crash window).
295
+ // Reset round counter to the restore target + 1 so the next
296
+ // resume / unpause compiles from the correct restored state.
297
+ const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
298
+ session.currentRound = restoreTarget + 1;
299
+ session.consecutiveRejections = 0;
300
+ session.lastRejectionCheck = "";
301
+ session.driftClarificationStreak = 0;
302
+ session.lastSelfEval = undefined;
303
+ session.currentPrompt = null;
304
+ this.save(session);
305
+ // Return null — caller (resume/unpause) will recompile normally
306
+ return null;
307
+ }
308
+ if (pr.action !== "continue")
309
+ return null;
310
+ // v2.14: only advance when the persisted counter has not already moved
311
+ // past the committed round. The pause/delete race leaves currentRound
312
+ // incremented (the commit fence returns before compiling, and pause()
313
+ // persisted the incremented value) — replaying must compile the NEXT
314
+ // round, not skip it by incrementing again.
315
+ if (session.currentRound <= recovered.snapshot.round) {
316
+ session.currentRound = recovered.snapshot.round + 1;
317
+ }
318
+ const request = buildLoopRequest(session, pr.newLastSelfEval, pr.verificationFlags);
319
+ const prepared = new RoundDriver(session.engine, this.store).prepareSync(request, session.loopId, session.currentRound);
320
+ if (!prepared) {
321
+ session.status = "stalled";
322
+ this.save(session);
323
+ return {
324
+ sessionId: session.sessionId,
325
+ round: session.currentRound,
326
+ prompt: null,
327
+ stopReason: "stalled",
328
+ stopDetail: "RoundDriver.prepareSync returned null — prompt compilation failed during crash recovery. The session state may be inconsistent.",
329
+ };
330
+ }
331
+ return this.persistPrepared(session, {
332
+ prompt: prepared.prompt,
333
+ level: prepared.level,
334
+ baseline: prepared.evidenceBaseline,
335
+ snapshot: prepared.snapshot,
336
+ warnings: prepared?.warnings ?? [],
337
+ }, pr.roundSuccess);
338
+ }
339
+ /** Resume tail: the session has been reconstructed and registered. Reconcile
340
+ * a committed-but-undelivered round, restore the held prompt, or compile
341
+ * the next prompt from current round state (legacy path). */
342
+ resume(session) {
343
+ const reconciled = this.reconcileCommittedRound(session);
344
+ if (reconciled)
345
+ return reconciled;
346
+ const restored = this.restoredPromptResult(session);
347
+ if (restored)
348
+ return restored;
349
+ // Legacy session without a stored prompt: compile once, then persist it.
350
+ const request = buildLoopRequest(session);
351
+ const prepared = new RoundDriver(session.engine, this.store).prepareSync(request, session.loopId, session.currentRound);
352
+ const prompt = prepared?.prompt ?? null;
353
+ const baseline = prepared?.evidenceBaseline ?? [];
354
+ const snapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, []);
355
+ return this.persistPrepared(session, {
356
+ prompt,
357
+ level: prepared?.level ?? "l2",
358
+ baseline,
359
+ snapshot,
360
+ warnings: prepared?.warnings ?? [],
361
+ }, false);
362
+ }
363
+ /** Unpause tail: the session has been reconstructed and registered with
364
+ * status "running". Refresh async evidence, reconcile a committed round,
365
+ * restore the held prompt, or compile the next prompt. */
366
+ async unpause(session) {
367
+ // Replace the sync-fallback evidence baseline with async evidence
368
+ // so resumed sessions don't silently drop async provider data.
369
+ try {
370
+ const asyncEvidence = await EvidenceCollector.fromPolicy().collectAsync({
371
+ loopId: session.loopId,
372
+ phase: "before",
373
+ });
374
+ if (asyncEvidence.length > 0) {
375
+ session.evidenceBaseline = asyncEvidence;
376
+ if (!session.roundSnapshot?.beforeEvidence?.length) {
377
+ session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
378
+ }
379
+ }
380
+ }
381
+ catch {
382
+ // Async evidence is best-effort; fall back to sync baseline.
383
+ }
384
+ const reconciled = this.reconcileCommittedRound(session);
385
+ if (reconciled)
386
+ return reconciled;
387
+ const restored = this.restoredPromptResult(session);
388
+ if (restored) {
389
+ this.save(session);
390
+ return restored;
391
+ }
392
+ // Compile the next prompt from the current round state
393
+ const lcr = makeLoopCompileRequest({
394
+ loop_id: session.loopId,
395
+ round: session.currentRound,
396
+ goal_id: "",
397
+ task: session.task,
398
+ domain: undefined,
399
+ plan_source: null,
400
+ constraints_from_plan: [],
401
+ health_check_interval: 1,
402
+ });
403
+ const compileRequest = {
404
+ task: session.task,
405
+ mode: Mode.LOOP_COMPILE,
406
+ feedback: null,
407
+ skill_name: null,
408
+ task_id: null,
409
+ loop_id: lcr.loop_id,
410
+ round: lcr.round,
411
+ goal_id: lcr.goal_id,
412
+ domain: lcr.domain ?? "",
413
+ plan_source: lcr.plan_source ?? null,
414
+ constraints_from_plan: lcr.constraints_from_plan ?? [],
415
+ health_check_interval: lcr.health_check_interval,
416
+ max_rounds: session.maxRounds,
417
+ };
418
+ const prepared = await new RoundDriver(session.engine, this.store).prepare(compileRequest, session.loopId, session.currentRound);
419
+ if (!prepared) {
420
+ session.status = "stopped";
421
+ this.save(session);
422
+ void this.notifyTerminal(session, "stalled");
423
+ return { sessionId: session.sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", stopDetail: "RoundDriver.prepare returned null during unpause — the compiler could not produce a prompt." };
424
+ }
425
+ return this.persistPrepared(session, {
426
+ prompt: prepared.prompt,
427
+ level: prepared.level,
428
+ baseline: prepared.evidenceBaseline,
429
+ snapshot: prepared.snapshot,
430
+ warnings: prepared?.warnings ?? [],
431
+ }, undefined,
432
+ // Historical contract: unpause returns an empty warnings array even
433
+ // though the session carries the compiled warnings.
434
+ []);
435
+ }
436
+ /** Record a gate_opened entry when an accepted round reports a high-risk
437
+ * blocker (USER_RISK hit). Idempotent per gate. The gate is a record
438
+ * layer — it never blocks the round decision flow. */
439
+ recordGateFromBlocked(session, blocker) {
440
+ if (!getPolicy().gate.enabled)
441
+ return;
442
+ const { id, gate } = deriveGate(blocker);
443
+ if (gate.kind !== "user")
444
+ return;
445
+ const taskId = `loop:${session.loopId}:gate:${id}`;
446
+ const existing = queryLoopEntries(this.store, session.loopId, { prefix: taskId })
447
+ .some((entry) => entry.task_id === taskId);
448
+ if (existing)
449
+ return;
450
+ const entry = {
451
+ id: randomUUID(),
452
+ task_id: taskId,
453
+ task_type: "gate_opened",
454
+ loop_id: session.loopId,
455
+ timestamp: new Date().toISOString(),
456
+ gate_id: id,
457
+ gate_action: blocker,
458
+ loop_lineage: {
459
+ round: session.currentRound,
460
+ gate_id: id,
461
+ },
462
+ };
463
+ this.store.appendEntry(entry);
464
+ logEvent("gate_opened", { loopId: session.loopId, gateId: id, round: session.currentRound });
465
+ }
466
+ /** Undecided user gate descriptions for a loop (recorded but unresolved). */
467
+ listOpenGateDescriptions(loopId) {
468
+ const entries = queryLoopEntries(this.store, loopId, { prefix: `loop:${loopId}:gate:` });
469
+ const decided = new Set(entries.filter((entry) => entry.task_type === "gate_decision")
470
+ .map((entry) => String(entry.gate_id ?? "")));
471
+ return entries
472
+ .filter((entry) => entry.task_type === "gate_opened" &&
473
+ !decided.has(String(entry.gate_id ?? "")))
474
+ .map((entry) => typeof entry.gate_action === "string"
475
+ ? entry.gate_action
476
+ : String(entry.gate_id ?? ""));
477
+ }
478
+ /** Extract a SelfEvaluation from agent output.
479
+ * Structured param preferred → regex extraction → heuristic fallback.
480
+ * Returns null only when both regex and heuristic returned null. */
481
+ extractEvaluation(output, preExtractedEval) {
482
+ if (preExtractedEval) {
483
+ return { selfEval: preExtractedEval, extractionFailed: false };
484
+ }
485
+ const structured = extractSelfEvaluation(output);
486
+ if (!structured)
487
+ return null;
488
+ return { selfEval: structured, extractionFailed: false };
489
+ }
490
+ /** Execute the round transaction and apply per-rule rejection tracking.
491
+ * MUTATES: session.roundSnapshot, session.consecutiveRejections,
492
+ * session.lastRejectionCheck, session.lastSelfEval,
493
+ * session.successTrajectory, session.driftClarificationStreak */
494
+ async executeRoundTransaction(session, selfEval, extractionFailed) {
495
+ const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
496
+ const completed = await new RoundDriver(session.engine, this.store).complete({
497
+ snapshot,
498
+ loopId: session.loopId,
499
+ task: session.task,
500
+ maxRounds: session.maxRounds,
501
+ selfEval,
502
+ extractionSucceeded: !extractionFailed,
503
+ lastSelfEval: session.lastSelfEval,
504
+ consecutiveRejections: session.consecutiveRejections,
505
+ successTrajectory: session.successTrajectory,
506
+ driftClarificationStreak: session.driftClarificationStreak,
507
+ backtrackSkippedFiles: session.backtrackSkippedFiles,
508
+ backtrackTargetGitHead: session.backtrackTargetGitHead,
509
+ });
510
+ const outcome = completed.outcome;
511
+ const actualEvidence = completed.actualEvidence;
512
+ session.roundSnapshot = outcome.snapshot;
513
+ const pr = outcome.result;
514
+ policyMetrics.recordStrategyOutcome(session.loopId, session.currentLevel, pr, outcome.replayed);
515
+ // v2.12: Clarification streak tracking — independent of rejection tracking.
516
+ // Reset on rounds without intent_drift; track substantive vs weak clarifications.
517
+ this.updateClarificationStreak(session, pr);
518
+ // Per-rule rejection tracking: same-check rejections accumulate;
519
+ // a different rejection reason resets the counter.
520
+ if (pr.action === "reject" && pr.rejectionCheck) {
521
+ session.consecutiveRejections =
522
+ pr.rejectionCheck === session.lastRejectionCheck
523
+ ? pr.newConsecutiveRejections
524
+ : 1;
525
+ session.lastRejectionCheck = pr.rejectionCheck;
526
+ }
527
+ else {
528
+ session.consecutiveRejections = pr.newConsecutiveRejections;
529
+ if (pr.action !== "reject")
530
+ session.lastRejectionCheck = "";
531
+ }
532
+ if (pr.newLastSelfEval)
533
+ session.lastSelfEval = pr.newLastSelfEval;
534
+ if (pr.shouldPushSuccessTrajectory) {
535
+ session.successTrajectory.push(pr.roundSuccess);
536
+ }
537
+ return { pr, verificationFlags: pr.verificationFlags, actualEvidence };
538
+ }
539
+ /** v2.12: Update the clarification streak based on the round result.
540
+ * - Substantive clarification (anchors present): keep streak — genuine pivot.
541
+ * - No intent_drift this round: reset streak to 0.
542
+ * - Weak clarification rejected by R7: streak was already consumed by
543
+ * enforceIntentDrift to decide reject vs terminate; staleness is handled
544
+ * on the enforcement side. We sync the persisted counter for crash recovery.
545
+ * v3.2.1: when a HIGHER-PRIORITY rule (R1–R6/R8/R9/R-EVID) rejected the
546
+ * round, R7 never participated — clarificationAccepted is undefined (it
547
+ * is only set on the continue path). Touching the streak there would
548
+ * pollute it with rejections unrelated to drift and terminate the loop
549
+ * one weak clarification early.
550
+ * MUTATES: session.driftClarificationStreak */
551
+ updateClarificationStreak(session, pr) {
552
+ const hasIntentDrift = pr.verificationFlags.some((f) => f.check === "intent_drift");
553
+ if (!hasIntentDrift) {
554
+ // No drift this round — reset streak
555
+ session.driftClarificationStreak = 0;
556
+ return;
557
+ }
558
+ // R7 did not participate in this round's decision (a higher-priority
559
+ // rule rejected/accepted first) — leave the streak untouched.
560
+ if (pr.clarificationAccepted === undefined)
561
+ return;
562
+ // v2.12: Clarification accepted with anchors → substantive pivot.
563
+ // Keep streak as-is (don't increment for genuine explanations).
564
+ if (pr.clarificationAccepted) {
565
+ return;
566
+ }
567
+ // R7 rejected (weak or no clarification). The enforcement gate
568
+ // already used the streak to decide reject vs terminate.
569
+ // Increment the persisted counter so crash recovery sees it.
570
+ session.driftClarificationStreak += 1;
571
+ }
572
+ /** Build a rejection result: compile a retry prompt, persist, return.
573
+ * MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
574
+ async buildRejectionResult(sessionId, session, pr, verificationFlags) {
575
+ const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
576
+ const preparedRetry = await new RoundDriver(session.engine, this.store).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
577
+ if (!preparedRetry) {
578
+ session.status = "stalled";
579
+ session.currentPrompt = null;
580
+ this.save(session);
581
+ return {
582
+ sessionId,
583
+ round: session.currentRound,
584
+ roundId: session.roundSnapshot?.roundId,
585
+ prompt: null,
586
+ stopReason: "stalled",
587
+ stopDetail: "RoundDriver.prepareRetry returned null — the retry prompt could not be compiled.",
588
+ };
589
+ }
590
+ session.roundSnapshot = preparedRetry.snapshot;
591
+ session.currentPrompt = preparedRetry.prompt;
592
+ session.currentLevel = preparedRetry.level;
593
+ session.currentWarnings = preparedRetry.warnings ?? [];
594
+ session.lastCompileResponse = preparedRetry.compileResponse ?? null;
595
+ this.save(session);
596
+ return {
597
+ sessionId,
598
+ round: session.currentRound,
599
+ roundId: session.roundSnapshot.roundId,
600
+ prompt: preparedRetry.prompt,
601
+ level: preparedRetry.level,
602
+ enforcementAction: "reject",
603
+ enforcementReason: pr.enforcementReason,
604
+ };
605
+ }
606
+ /** v2.10: Build a backtrack result — roll back to last clean round.
607
+ * Resets the round counter to the restore target + 1, merges preserved
608
+ * discoveries, compiles from the restored state, and injects the
609
+ * backtrack prompt at the top.
610
+ * MUTATES: session.currentRound, session.roundSnapshot,
611
+ * session.currentPrompt, session.currentLevel,
612
+ * session.consecutiveRejections, session.lastSelfEval */
613
+ async buildBacktrackResult(sessionId, session, pr) {
614
+ const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
615
+ const newRound = restoreTarget + 1;
616
+ logEvent("session_backtrack", {
617
+ sessionId,
618
+ loopId: session.loopId,
619
+ fromRound: session.currentRound,
620
+ toRound: newRound,
621
+ triggerRule: pr.backtrackTriggerRule ?? "",
622
+ skippedDiscoveries: (pr.backtrackSkippedDiscoveries ?? []).length,
623
+ });
624
+ // Reset session to the restored round
625
+ session.currentRound = newRound;
626
+ session.consecutiveRejections = 0;
627
+ session.lastRejectionCheck = "";
628
+ session.driftClarificationStreak = 0;
629
+ session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
630
+ // v2.12: Remember the restore point's git HEAD — the next round's
631
+ // verification gate confirms the workspace returned to this commit.
632
+ session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
633
+ session.lastSelfEval = undefined; // force L2 recompile from vault state
634
+ session.currentPrompt = null;
635
+ // Preserve discoveries from skipped rounds as constraints_from_plan
636
+ // so they survive the compilation and re-enter active constraints.
637
+ const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
638
+ // Compile the restored prompt — use a fresh request with the restored round
639
+ const request = {
640
+ task: session.task,
641
+ mode: Mode.LOOP_COMPILE,
642
+ feedback: null,
643
+ skill_name: null,
644
+ task_id: null,
645
+ loop_id: session.loopId,
646
+ round: newRound,
647
+ max_rounds: session.maxRounds,
648
+ verification_flags: pr.verificationFlags,
649
+ constraints_from_plan: preservedDiscoveries,
650
+ };
651
+ const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, newRound);
652
+ if (!prepared || !prepared.prompt) {
653
+ session.status = "stalled";
654
+ session.currentPrompt = null;
655
+ this.save(session);
656
+ return {
657
+ sessionId,
658
+ round: newRound,
659
+ prompt: null,
660
+ stopReason: "stalled",
661
+ stopDetail: "Backtrack failed: RoundDriver.prepare returned null — could not compile from restore point.",
662
+ enforcementAction: "backtrack",
663
+ enforcementReason: pr.enforcementReason,
664
+ };
665
+ }
666
+ // Inject backtrack prompt at the top of the compiled prompt
667
+ const backtrackHeader = pr.backtrackPrompt ?? "";
668
+ const fullPrompt = backtrackHeader
669
+ ? backtrackHeader + "\n" + (prepared.prompt ?? "")
670
+ : prepared.prompt ?? "";
671
+ session.evidenceBaseline = prepared.evidenceBaseline;
672
+ session.roundSnapshot = prepared.snapshot;
673
+ session.currentPrompt = fullPrompt;
674
+ session.currentLevel = prepared.level;
675
+ session.currentWarnings = prepared?.warnings ?? [];
676
+ session.lastCompileResponse = prepared?.compileResponse ?? null;
677
+ this.save(session);
678
+ return {
679
+ sessionId,
680
+ round: newRound,
681
+ roundId: session.roundSnapshot?.roundId,
682
+ prompt: fullPrompt,
683
+ level: prepared.level,
684
+ enforcementAction: "backtrack",
685
+ enforcementReason: pr.enforcementReason,
686
+ warnings: prepared?.warnings ?? [],
687
+ };
688
+ }
689
+ /** Build a termination result: persist stopped status, notify sinks.
690
+ * MUTATES: session.status, session.currentPrompt */
691
+ buildTerminationResult(sessionId, session, pr) {
692
+ session.status = "stopped";
693
+ session.currentPrompt = null;
694
+ this.save(session);
695
+ void this.notifyTerminal(session, "enforcement_terminated");
696
+ logEvent("session_end", {
697
+ sessionId, loopId: session.loopId,
698
+ stopReason: "enforcement_terminated", round: session.currentRound,
699
+ });
700
+ return {
701
+ sessionId,
702
+ round: session.currentRound,
703
+ roundId: session.roundSnapshot?.roundId,
704
+ prompt: null,
705
+ stopReason: "enforcement_terminated",
706
+ stopDetail: pr.enforcementReason ?? "The enforcement gate terminated the loop.",
707
+ enforcementAction: "terminate",
708
+ enforcementReason: pr.enforcementReason,
709
+ };
710
+ }
711
+ /** Build a stop result: persist stopped/stalled status, notify sinks.
712
+ * MUTATES: session.status, session.currentPrompt */
713
+ buildStopResult(sessionId, session, pr) {
714
+ const reason = pr.stopReason ?? "stalled";
715
+ session.status = reason === "stalled" ? "stalled" : "stopped";
716
+ session.currentPrompt = null;
717
+ this.save(session);
718
+ void this.notifyTerminal(session, reason);
719
+ logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
720
+ return {
721
+ sessionId, round: session.currentRound,
722
+ roundId: session.roundSnapshot?.roundId,
723
+ prompt: null, stopReason: reason,
724
+ stopDetail: pr.stopReason === "circuit_breaker"
725
+ ? `${pr.roundSuccess ? "No" : "All"} recent rounds failed, triggering the circuit breaker.`
726
+ : pr.stopReason === "max_rounds"
727
+ ? "The configured maximum number of rounds has been reached."
728
+ : `Loop stopped: ${reason}.`,
729
+ roundSuccess: pr.roundSuccess,
730
+ };
731
+ }
732
+ /** Compile the next round's prompt and advance the session.
733
+ * Includes the commit fence (pause/delete race guard) and context provider.
734
+ * MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
735
+ * session.evidenceBaseline, session.roundSnapshot */
736
+ async advanceToNextRound(sessionId, session, selfEval, pr, verificationFlags, actualEvidence) {
737
+ const roundSuccess = pr.roundSuccess;
738
+ session.currentRound++;
739
+ session.currentPrompt = null;
740
+ // v2.13: Clear backtrack skipped files after advancing past the restore round
741
+ session.backtrackSkippedFiles = [];
742
+ // v2.12: The restore-point git HEAD check only applies to the first
743
+ // post-backtrack round — clear it once we advance past it.
744
+ session.backtrackTargetGitHead = undefined;
745
+ // v1.8: Memory injection — tier-aware external context.
746
+ const contextProvider = this.getContext();
747
+ let externalCtx = "";
748
+ if (contextProvider) {
749
+ try {
750
+ externalCtx = (await contextProvider({
751
+ loopId: session.loopId,
752
+ round: session.currentRound,
753
+ task: session.task,
754
+ domain: "",
755
+ lastEvaluation: selfEval,
756
+ })).trim();
757
+ }
758
+ catch {
759
+ logEvent("context_provider_error", {
760
+ loopId: session.loopId,
761
+ round: session.currentRound,
762
+ });
763
+ }
764
+ }
765
+ // Commit fence: pause()/delete() may have run while the context provider
766
+ // was awaited. Do not compile if the session is no longer running.
767
+ if (this.registry.get(sessionId) !== session || session.status !== "running") {
768
+ return {
769
+ sessionId,
770
+ round: session.currentRound,
771
+ prompt: null,
772
+ stopReason: session.status,
773
+ stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the context provider was running.`,
774
+ roundSuccess,
775
+ };
776
+ }
777
+ const request = buildLoopRequest(session, selfEval, verificationFlags);
778
+ if (externalCtx) {
779
+ request.external_context = externalCtx;
780
+ }
781
+ const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, session.currentRound);
782
+ const nextPrompt = prepared?.prompt ?? null;
783
+ const nextLevel = prepared?.level ?? "l2";
784
+ const nextBaseline = prepared?.evidenceBaseline ?? actualEvidence;
785
+ session.evidenceBaseline = nextBaseline;
786
+ session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
787
+ session.currentPrompt = nextPrompt;
788
+ session.currentLevel = nextLevel;
789
+ session.currentWarnings = prepared?.warnings ?? [];
790
+ session.lastCompileResponse = prepared?.compileResponse ?? null;
791
+ policyMetrics.recordStrategy(session.loopId, nextLevel);
792
+ this.save(session);
793
+ return {
794
+ sessionId,
795
+ round: session.currentRound,
796
+ roundId: session.roundSnapshot.roundId,
797
+ prompt: nextPrompt,
798
+ level: nextLevel,
799
+ roundSuccess,
800
+ warnings: prepared?.warnings ?? [],
801
+ };
802
+ }
803
+ async advanceUnlocked(sessionId, output, preExtractedEval, roundId) {
804
+ // ── 1. Validate session ──────────────────────────────────────────────
805
+ const session = this.registry.get(sessionId);
806
+ if (!session)
807
+ return { sessionId, round: 0, prompt: null, stopReason: "session_not_found", stopDetail: "No session exists with this ID. It may have expired, been deleted, or never existed." };
808
+ if (session.status !== "running") {
809
+ return { sessionId, round: session.currentRound, prompt: null, stopReason: session.status, stopDetail: `Session is ${session.status}. Use loopforge_resume to restart a paused session, or loopforge_start for a new task.` };
810
+ }
811
+ // ── 1.5 Anchor the submission to the round it reports on ─────────────
812
+ // v3.0.1: a submission whose roundId no longer matches the current round
813
+ // is stale — the round it reported on already committed (lost response,
814
+ // duplicate call). Never process it against the current round; return the
815
+ // held prompt so the agent recovers the response it missed. The reject
816
+ // path keeps the same roundId (prepareRejectedAttempt), so retries of a
817
+ // rejected round match normally.
818
+ const expectedRoundId = session.roundSnapshot?.roundId
819
+ ?? makeRoundId(session.loopId, session.currentRound);
820
+ if (roundId !== undefined && roundId !== expectedRoundId) {
821
+ logEvent("stale_round_id", {
822
+ sessionId,
823
+ loopId: session.loopId,
824
+ submitted: roundId,
825
+ expected: expectedRoundId,
826
+ });
827
+ const restored = this.restoredPromptResult(session);
828
+ if (restored) {
829
+ return {
830
+ ...restored,
831
+ warnings: [
832
+ ...(restored.warnings ?? []),
833
+ `Submitted roundId ${roundId} does not match the current round (${expectedRoundId}) — the round it reported on has already committed. Reusing the held prompt for round ${session.currentRound}; resubmit with its roundId when the work is done.`,
834
+ ],
835
+ };
836
+ }
837
+ return {
838
+ sessionId,
839
+ round: session.currentRound,
840
+ roundId: expectedRoundId,
841
+ prompt: null,
842
+ stopReason: "stale_round_id",
843
+ stopDetail: `Submitted roundId ${roundId} does not match the current round ${expectedRoundId}, and no prompt is currently held. Call loopforge_status or loopforge_resume to re-anchor.`,
844
+ };
845
+ }
846
+ // ── 2. Extract self-evaluation ───────────────────────────────────────
847
+ const extracted = this.extractEvaluation(output, preExtractedEval);
848
+ if (!extracted) {
849
+ session.status = "stalled";
850
+ this.save(session);
851
+ void this.notifyTerminal(session, "stalled");
852
+ logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
853
+ // v2.12: Actionable diagnostics — the state machine still stalls (the
854
+ // runtime never guesses state from text), but the agent learns exactly
855
+ // what was wrong and what was recognizable.
856
+ let stopDetail = "Self-evaluation could not be extracted from agent output.";
857
+ const diag = extractSelfEvaluationWithDiagnostics(output);
858
+ if (diag.reason)
859
+ stopDetail += ` Reason: ${diag.reason}.`;
860
+ const inferred = inferOutcomeFromText(output);
861
+ if (inferred) {
862
+ stopDetail += ` From your output text I can see outcome=${inferred.outcome}` +
863
+ ` (summary preview: "${inferred.summary.slice(0, 80)}..."). ` +
864
+ "Please resubmit a properly structured SelfEvaluation via the " +
865
+ "evaluation parameter of loopforge_next, or embed the " +
866
+ "---loopforge-eval block in your output.";
867
+ }
868
+ return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", stopDetail, roundSuccess: false };
869
+ }
870
+ const { selfEval, extractionFailed } = extracted;
871
+ // ── 3. Execute round transaction ─────────────────────────────────────
872
+ const tx = await this.executeRoundTransaction(session, selfEval, extractionFailed);
873
+ // v2.12: Record a user gate when an accepted round reports a high-risk
874
+ // blocker. Record layer only — never blocks the round decision flow.
875
+ if (tx.pr.action !== "reject" && tx.pr.action !== "terminate" &&
876
+ selfEval.outcome === "blocked" && typeof selfEval.blocker === "string") {
877
+ this.recordGateFromBlocked(session, selfEval.blocker);
878
+ }
879
+ // ── 4. Route disposition ─────────────────────────────────────────────
880
+ if (tx.pr.action === "reject") {
881
+ return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags);
882
+ }
883
+ // ── v2.10: Backtrack — roll back to last clean round ──────────────────
884
+ if (tx.pr.action === "backtrack") {
885
+ return this.buildBacktrackResult(sessionId, session, tx.pr);
886
+ }
887
+ // Accepted rounds advance the before-snapshot baseline. Rejected rounds
888
+ // retain the original baseline so their retry remains zero-commit.
889
+ session.evidenceBaseline = tx.actualEvidence;
890
+ if (tx.pr.action === "terminate") {
891
+ return this.buildTerminationResult(sessionId, session, tx.pr);
892
+ }
893
+ if (tx.pr.action === "stop") {
894
+ return this.buildStopResult(sessionId, session, tx.pr);
895
+ }
896
+ // ── 5. Continue — compile next round ─────────────────────────────────
897
+ return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.verificationFlags, tx.actualEvidence);
898
+ }
899
+ /** Core cycle entry used by SessionManager.advance after the queue + lease
900
+ * dance: validate → extract → execute transaction → route disposition
901
+ * → advance to next round (continue) or return terminal result.
902
+ * @param roundId v3.0.1: the roundId of the round this submission reports
903
+ * on (from the last start/next/resume response). Anchors the submission;
904
+ * a stale or duplicate submission returns the held prompt instead of
905
+ * being processed. Optional — when absent the anchor check is skipped. */
906
+ async advance(sessionId, output, preExtractedEval, roundId) {
907
+ return this.advanceUnlocked(sessionId, output, preExtractedEval, roundId);
908
+ }
909
+ /** Write back loop knowledge to long-term memory.
910
+ * Uses shared base builder from policy.ts. Called when a loop terminates. */
911
+ async notifyTerminal(session, stopReason) {
912
+ if (this.terminalSinks.size === 0)
913
+ return;
914
+ const event = {
915
+ loopId: session.loopId,
916
+ task: session.task,
917
+ success: stopReason === "completed",
918
+ stopReason: stopReason,
919
+ roundsCompleted: session.currentRound,
920
+ successTrajectory: [...session.successTrajectory],
921
+ lastEvaluation: session.lastSelfEval,
922
+ };
923
+ await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
924
+ }
925
+ }
926
+ //# sourceMappingURL=round-lifecycle.js.map