loopforge 2.7.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/README.md +136 -182
  2. package/dist/canonical-event-writer.d.ts +20 -0
  3. package/dist/canonical-event-writer.d.ts.map +1 -0
  4. package/dist/canonical-event-writer.js +131 -0
  5. package/dist/canonical-event-writer.js.map +1 -0
  6. package/dist/canonical-state.d.ts +20 -16
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +211 -64
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +11 -10
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +48 -0
  14. package/dist/cognitive-governance.d.ts.map +1 -0
  15. package/dist/cognitive-governance.js +226 -0
  16. package/dist/cognitive-governance.js.map +1 -0
  17. package/dist/compiler-subgoals.d.ts +7 -0
  18. package/dist/compiler-subgoals.d.ts.map +1 -0
  19. package/dist/compiler-subgoals.js +93 -0
  20. package/dist/compiler-subgoals.js.map +1 -0
  21. package/dist/derive-id.d.ts +12 -0
  22. package/dist/derive-id.d.ts.map +1 -0
  23. package/dist/derive-id.js +31 -0
  24. package/dist/derive-id.js.map +1 -0
  25. package/dist/evidence-provider.d.ts +26 -26
  26. package/dist/evidence-provider.d.ts.map +1 -1
  27. package/dist/evidence-provider.js +45 -71
  28. package/dist/evidence-provider.js.map +1 -1
  29. package/dist/generate-schema.js +90 -2
  30. package/dist/generate-schema.js.map +1 -1
  31. package/dist/index.d.ts +23 -29
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +16 -26
  34. package/dist/index.js.map +1 -1
  35. package/dist/loop-compiler.d.ts +2 -2
  36. package/dist/loop-compiler.d.ts.map +1 -1
  37. package/dist/loop-compiler.js +321 -315
  38. package/dist/loop-compiler.js.map +1 -1
  39. package/dist/loop-extras-parser.d.ts +2 -2
  40. package/dist/loop-extras-parser.d.ts.map +1 -1
  41. package/dist/loop-extras-parser.js +2 -2
  42. package/dist/loop-extras-parser.js.map +1 -1
  43. package/dist/loop-store.d.ts +20 -58
  44. package/dist/loop-store.d.ts.map +1 -1
  45. package/dist/loop-store.js +232 -311
  46. package/dist/loop-store.js.map +1 -1
  47. package/dist/mcp/server.d.ts +3 -3
  48. package/dist/mcp/server.d.ts.map +1 -1
  49. package/dist/mcp/server.js +123 -19
  50. package/dist/mcp/server.js.map +1 -1
  51. package/dist/mcp/session-projection.d.ts +5 -0
  52. package/dist/mcp/session-projection.d.ts.map +1 -0
  53. package/dist/mcp/session-projection.js +18 -0
  54. package/dist/mcp/session-projection.js.map +1 -0
  55. package/dist/mcp/session-queue.d.ts +3 -0
  56. package/dist/mcp/session-queue.d.ts.map +1 -0
  57. package/dist/mcp/session-queue.js +18 -0
  58. package/dist/mcp/session-queue.js.map +1 -0
  59. package/dist/mcp/session-types.d.ts +72 -0
  60. package/dist/mcp/session-types.d.ts.map +1 -0
  61. package/dist/mcp/session-types.js +2 -0
  62. package/dist/mcp/session-types.js.map +1 -0
  63. package/dist/mcp/session.d.ts +65 -84
  64. package/dist/mcp/session.d.ts.map +1 -1
  65. package/dist/mcp/session.js +667 -224
  66. package/dist/mcp/session.js.map +1 -1
  67. package/dist/mcp/tools.d.ts +26 -1
  68. package/dist/mcp/tools.d.ts.map +1 -1
  69. package/dist/mcp/tools.js +401 -254
  70. package/dist/mcp/tools.js.map +1 -1
  71. package/dist/policy-metrics.d.ts +24 -0
  72. package/dist/policy-metrics.d.ts.map +1 -1
  73. package/dist/policy-metrics.js +174 -0
  74. package/dist/policy-metrics.js.map +1 -1
  75. package/dist/policy.d.ts +74 -12
  76. package/dist/policy.d.ts.map +1 -1
  77. package/dist/policy.js +124 -19
  78. package/dist/policy.js.map +1 -1
  79. package/dist/prompt-assembler.d.ts +8 -4
  80. package/dist/prompt-assembler.d.ts.map +1 -1
  81. package/dist/prompt-assembler.js +533 -86
  82. package/dist/prompt-assembler.js.map +1 -1
  83. package/dist/prompt-policy.d.ts +8 -1
  84. package/dist/prompt-policy.d.ts.map +1 -1
  85. package/dist/prompt-policy.js +26 -8
  86. package/dist/prompt-policy.js.map +1 -1
  87. package/dist/protocol.d.ts +259 -173
  88. package/dist/protocol.d.ts.map +1 -1
  89. package/dist/protocol.js +17 -96
  90. package/dist/protocol.js.map +1 -1
  91. package/dist/replay.d.ts +11 -3
  92. package/dist/replay.d.ts.map +1 -1
  93. package/dist/replay.js +51 -14
  94. package/dist/replay.js.map +1 -1
  95. package/dist/round-commit-store.d.ts +12 -0
  96. package/dist/round-commit-store.d.ts.map +1 -0
  97. package/dist/round-commit-store.js +13 -0
  98. package/dist/round-commit-store.js.map +1 -0
  99. package/dist/round-coordinator.d.ts +53 -22
  100. package/dist/round-coordinator.d.ts.map +1 -1
  101. package/dist/round-coordinator.js +140 -44
  102. package/dist/round-coordinator.js.map +1 -1
  103. package/dist/round-driver.d.ts +18 -10
  104. package/dist/round-driver.d.ts.map +1 -1
  105. package/dist/round-driver.js +11 -13
  106. package/dist/round-driver.js.map +1 -1
  107. package/dist/round-gate.d.ts +31 -0
  108. package/dist/round-gate.d.ts.map +1 -0
  109. package/dist/round-gate.js +259 -0
  110. package/dist/round-gate.js.map +1 -0
  111. package/dist/round-recovery.d.ts +17 -0
  112. package/dist/round-recovery.d.ts.map +1 -0
  113. package/dist/round-recovery.js +117 -0
  114. package/dist/round-recovery.js.map +1 -0
  115. package/dist/round-report.d.ts +16 -0
  116. package/dist/round-report.d.ts.map +1 -0
  117. package/dist/round-report.js +401 -0
  118. package/dist/round-report.js.map +1 -0
  119. package/dist/round-transaction.d.ts +23 -12
  120. package/dist/round-transaction.d.ts.map +1 -1
  121. package/dist/round-transaction.js +104 -91
  122. package/dist/round-transaction.js.map +1 -1
  123. package/dist/runtime-services.d.ts +59 -0
  124. package/dist/runtime-services.d.ts.map +1 -0
  125. package/dist/{engine.js → runtime-services.js} +157 -275
  126. package/dist/runtime-services.js.map +1 -0
  127. package/dist/storage.d.ts +3 -9
  128. package/dist/storage.d.ts.map +1 -1
  129. package/dist/storage.js +6 -17
  130. package/dist/storage.js.map +1 -1
  131. package/dist/token-utils.d.ts +12 -8
  132. package/dist/token-utils.d.ts.map +1 -1
  133. package/dist/token-utils.js +56 -10
  134. package/dist/token-utils.js.map +1 -1
  135. package/dist/vault-format.d.ts +4 -0
  136. package/dist/vault-format.d.ts.map +1 -0
  137. package/dist/vault-format.js +4 -0
  138. package/dist/vault-format.js.map +1 -0
  139. package/dist/vault-types.d.ts +24 -0
  140. package/dist/vault-types.d.ts.map +1 -0
  141. package/dist/vault-types.js +2 -0
  142. package/dist/vault-types.js.map +1 -0
  143. package/loop_policy.json +10 -12
  144. package/loopforge-policy.json +340 -0
  145. package/package.json +13 -7
  146. package/skills/perception/SKILL.md +200 -90
  147. package/dist/backends/interface.d.ts +0 -39
  148. package/dist/backends/interface.d.ts.map +0 -1
  149. package/dist/backends/interface.js +0 -6
  150. package/dist/backends/interface.js.map +0 -1
  151. package/dist/enforcement-gate.d.ts +0 -50
  152. package/dist/enforcement-gate.d.ts.map +0 -1
  153. package/dist/enforcement-gate.js +0 -501
  154. package/dist/enforcement-gate.js.map +0 -1
  155. package/dist/engine.d.ts +0 -59
  156. package/dist/engine.d.ts.map +0 -1
  157. package/dist/engine.js.map +0 -1
  158. package/dist/interop.d.ts +0 -35
  159. package/dist/interop.d.ts.map +0 -1
  160. package/dist/interop.js +0 -34
  161. package/dist/interop.js.map +0 -1
  162. package/dist/run.d.ts +0 -51
  163. package/dist/run.d.ts.map +0 -1
  164. package/dist/run.js +0 -200
  165. package/dist/run.js.map +0 -1
  166. package/dist/self-eval.d.ts +0 -23
  167. package/dist/self-eval.d.ts.map +0 -1
  168. package/dist/self-eval.js +0 -151
  169. package/dist/self-eval.js.map +0 -1
  170. package/dist/verification-gate.d.ts +0 -52
  171. package/dist/verification-gate.d.ts.map +0 -1
  172. package/dist/verification-gate.js +0 -527
  173. package/dist/verification-gate.js.map +0 -1
@@ -2,78 +2,36 @@
2
2
  *
3
3
  * Each McpSession = one complete multi-round loop.
4
4
  * SessionManager holds Map<sessionId, McpSession> and drives
5
- * the advance() cycle: extract → feedback → check stop → compile next.
5
+ * the advance() cycle: validate report → commit round event → check stop → compile next.
6
6
  */
7
- import { randomUUID } from "node:crypto";
8
- import { LoopForgeEngine, extractSelfEvaluation } from "../engine.js";
7
+ import { createHash, randomUUID } from "node:crypto";
8
+ import { RuntimeServices } from "../runtime-services.js";
9
9
  import { checkLoopHealth } from "../loop-compiler.js";
10
10
  import { getPolicy } from "../policy.js";
11
- import { Mode, makeLoopCompileRequest, makeLoopRoundResult } from "../protocol.js";
11
+ import { Mode, makeLoopCompileRequest } from "../protocol.js";
12
12
  import { ReplayBackend } from "../replay.js";
13
- import { FileLoopStore, LoopStoreBackend, VaultBackendLoopStore } from "../loop-store.js";
13
+ import { FileLoopStore } from "../loop-store.js";
14
14
  import { EvidenceCollector } from "../evidence-provider.js";
15
15
  import { parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
16
16
  import { RoundDriver } from "../round-driver.js";
17
17
  import { logEvent } from "../observability.js";
18
- import { policyMetrics } from "../policy-metrics.js";
18
+ import { derivePolicyMetrics, mergePolicyMetrics, policyMetrics, } from "../policy-metrics.js";
19
19
  import { SessionLeaseConflictError, VaultSessionStateStore, } from "../storage.js";
20
+ import { auditEntries, deriveStructuredGate } from "../cognitive-governance.js";
21
+ import { isRecord } from "../token-utils.js";
22
+ import { parseRoundReport, deriveCompilerProjection } from "../round-report.js";
23
+ import { collectSkippedRoundFiles, findSafeRestorePoint, findBacktrackTargetGitHead, } from "../round-recovery.js";
24
+ import { CanonicalEventWriter } from "../canonical-event-writer.js";
25
+ import { buildLoopRequest } from "./session-projection.js";
26
+ import { withSessionQueue } from "./session-queue.js";
20
27
  // ── Helpers ────────────────────────────────────────────────────────────────
21
- function buildLoopRequest(session, lastEval, verificationFlags) {
22
- const req = {
23
- task: session.task,
24
- mode: Mode.LOOP_COMPILE,
25
- feedback: null,
26
- skill_name: null,
27
- task_id: null,
28
- loop_id: session.loopId,
29
- round: session.currentRound,
30
- max_rounds: session.maxRounds,
31
- verification_flags: verificationFlags ?? [],
32
- };
33
- if (lastEval) {
34
- req.last_round_result = makeLoopRoundResult({
35
- round: session.currentRound - 1,
36
- success: lastEval.success,
37
- output_summary: lastEval.output_summary,
38
- constraint_violations: lastEval.constraint_violations,
39
- manual_fixes_needed: "",
40
- // P0–P2: Forward evolution fields to next compile
41
- // Merge sub-agent discovered constraints into the active set
42
- discovered_constraints: [
43
- ...new Set([
44
- ...(lastEval.discovered_constraints ?? []),
45
- ...(lastEval.worker_results ?? []).flatMap((w) => w.discoveredConstraints ?? []).filter((c) => c.length > 0),
46
- ]),
47
- ],
48
- objective_refinement: lastEval.objective_refinement ?? "",
49
- emerged_subtasks: lastEval.emerged_subtasks ?? [],
50
- // P4: Execution evidence
51
- execution_evidence: lastEval.execution_evidence ?? undefined,
52
- // P5: Self-correction
53
- retracted_constraints: lastEval.retracted_constraints ?? [],
54
- revised_success_criteria: lastEval.revised_success_criteria ?? [],
55
- wrong_assumptions: lastEval.wrong_assumptions ?? [],
56
- // Multi-agent: Forward delegation results to next compile
57
- worker_results: lastEval.worker_results ?? [],
58
- // v1.10: Checkpoint boundary
59
- compression_checkpoint: lastEval.compression_checkpoint ?? false,
60
- checkpoint_label: lastEval.checkpoint_label ?? "",
61
- // v1.16: Agent's declared next action
62
- next_action: lastEval.next_action,
63
- // v2.2: Sub-goal lifecycle
64
- completed_subtasks: lastEval.completed_subtasks ?? [],
65
- blocked_subtasks: lastEval.blocked_subtasks ?? [],
66
- canceled_subtasks: lastEval.canceled_subtasks ?? [],
67
- });
68
- }
69
- return req;
70
- }
71
28
  // ── SessionManager ─────────────────────────────────────────────────────────
72
29
  export class SessionManager {
73
30
  sessions = new Map();
74
31
  /** Serializes state transitions for each session. */
75
32
  sessionQueues = new Map();
76
33
  backend;
34
+ eventWriter;
77
35
  sessionStore;
78
36
  ownerId = `${process.pid}:${randomUUID()}`;
79
37
  leaseMs;
@@ -82,16 +40,13 @@ export class SessionManager {
82
40
  /** Explicit context provider; never auto-discovered. */
83
41
  contextProvider;
84
42
  terminalSinks = new Set();
43
+ /** Vault-replayed policy metrics, keyed by loop. The signature covers every
44
+ * entry's task_id/timestamp/sequence so both appends and idempotent
45
+ * replacements (same task_id) invalidate the cache. */
46
+ derivedMetricsCache = new Map();
85
47
  constructor(storeOrBackend, sessionStore) {
86
- const isLoopStore = (v) => "readSession" in v && typeof v.readSession === "function";
87
- const store = storeOrBackend
88
- ? isLoopStore(storeOrBackend)
89
- ? storeOrBackend
90
- : new VaultBackendLoopStore(storeOrBackend)
91
- : new FileLoopStore(getPolicy().backend.root_dir);
92
- this.backend = storeOrBackend && !isLoopStore(storeOrBackend)
93
- ? storeOrBackend
94
- : new LoopStoreBackend(store);
48
+ const store = storeOrBackend ?? new FileLoopStore(getPolicy().backend.root_dir);
49
+ this.backend = store;
95
50
  this.sessionStore = sessionStore ?? new VaultSessionStateStore(store);
96
51
  const mcpPolicy = getPolicy().mcp;
97
52
  this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
@@ -101,6 +56,11 @@ export class SessionManager {
101
56
  this.leaseTimer.unref?.();
102
57
  }
103
58
  }
59
+ appendCanonicalEvent(entry) {
60
+ if (!this.backend)
61
+ throw new Error("Vault backend is not initialized");
62
+ (this.eventWriter ??= new CanonicalEventWriter(this.backend)).append(entry);
63
+ }
104
64
  /** Stable process-local owner token used for cross-process session leases. */
105
65
  getOwnerId() {
106
66
  return this.ownerId;
@@ -153,41 +113,51 @@ export class SessionManager {
153
113
  stopDetail: `Another process (PID ${entry?.loop_lineage ? entry.loop_lineage.lease_owner ?? "unknown" : "unknown"}) holds the lease for loop "${loopId}". Wait for the lease to expire or stop the other process.`,
154
114
  };
155
115
  }
156
- async withSessionQueue(sessionId, work) {
157
- const previous = this.sessionQueues.get(sessionId) ?? Promise.resolve();
158
- let release;
159
- const gate = new Promise((resolve) => {
160
- release = resolve;
161
- });
162
- const tail = previous.then(() => gate);
163
- this.sessionQueues.set(sessionId, tail);
164
- await previous;
165
- try {
166
- return await work();
167
- }
168
- finally {
169
- release();
170
- if (this.sessionQueues.get(sessionId) === tail) {
171
- this.sessionQueues.delete(sessionId);
172
- }
173
- }
174
- }
175
116
  async create(input) {
117
+ const policyMaxRounds = getPolicy().engine.max_rounds;
118
+ if (input.maxRounds !== undefined &&
119
+ (!Number.isInteger(input.maxRounds) || input.maxRounds < 1 || input.maxRounds > policyMaxRounds)) {
120
+ return {
121
+ sessionId: "",
122
+ round: 0,
123
+ prompt: null,
124
+ stopReason: "invalid_input",
125
+ stopDetail: `maxRounds must be an integer between 1 and ${policyMaxRounds}.`,
126
+ error: { code: "invalid_max_rounds", message: `maxRounds must be an integer between 1 and ${policyMaxRounds}.` },
127
+ };
128
+ }
176
129
  const sessionId = randomUUID();
177
130
  const loopId = input.loopId ?? randomUUID();
178
- const engine = new LoopForgeEngine(this.backend);
131
+ const engine = new RuntimeServices(this.backend);
179
132
  const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
180
133
  // Populate extra fields for the first round
181
134
  const request = buildLoopRequest({
182
- sessionId, loopId, task: input.task, engine, currentRound: 1,
135
+ sessionId, loopId, task: input.task, runtime: engine, currentRound: 1,
183
136
  maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
184
137
  consecutiveRejections: 0,
185
- lastRejectionCheck: "",
138
+ escalatedRejectionChecks: [],
139
+ driftClarificationStreak: 0,
140
+ backtrackSkippedFiles: [],
186
141
  evidenceBaseline: [],
187
142
  });
188
143
  request.domain = input.domain ?? "";
189
144
  request.plan_source = input.planSource ?? null;
190
145
  request.constraints_from_plan = input.constraints ?? [];
146
+ const successCriteria = (input.successCriteria ?? [])
147
+ .filter((criterion) => typeof criterion === "string")
148
+ .map((criterion) => criterion.trim())
149
+ .filter(Boolean);
150
+ if (successCriteria.length > 0) {
151
+ request.loop_objective = {
152
+ objective: input.task,
153
+ success_criteria: successCriteria,
154
+ hard_constraints: input.constraints ?? [],
155
+ created_at_round: 1,
156
+ loop_id: loopId,
157
+ version: 1,
158
+ refinement_history: [],
159
+ };
160
+ }
191
161
  // Explicit context is requested by the MCP embedding; no auto-discovery.
192
162
  if (this.contextProvider) {
193
163
  try {
@@ -212,21 +182,36 @@ export class SessionManager {
212
182
  }
213
183
  }
214
184
  const prepared = await new RoundDriver(engine, this.backend).prepare(request, loopId, 1);
185
+ if (!prepared) {
186
+ return {
187
+ sessionId,
188
+ round: 1,
189
+ prompt: null,
190
+ stopReason: "prompt_compile_failed",
191
+ stopDetail: "Prompt compilation failed; no session state was persisted.",
192
+ error: { code: "prompt_compile_failed", message: "Prompt compilation failed while starting.", retryable: true },
193
+ };
194
+ }
215
195
  const initialPrompt = prepared?.prompt ?? null;
216
196
  const initialLevel = prepared?.level ?? "l2";
217
197
  const evidenceBaseline = prepared?.evidenceBaseline ?? [];
218
198
  const session = {
219
- sessionId, loopId, task: input.task, engine,
199
+ sessionId, loopId, task: input.task, runtime: engine,
220
200
  currentRound: 1, maxRounds, successTrajectory: [],
221
201
  status: "running", createdAt: Date.now(),
222
202
  // v1.13: Enforcement gate state
223
203
  consecutiveRejections: 0,
224
- lastRejectionCheck: "",
204
+ escalatedRejectionChecks: [],
205
+ driftClarificationStreak: 0,
206
+ backtrackSkippedFiles: [],
225
207
  evidenceBaseline,
226
208
  roundSnapshot: prepared?.snapshot ?? prepareRoundTransaction(loopId, 1, evidenceBaseline),
227
209
  currentPrompt: initialPrompt,
228
210
  currentLevel: initialLevel,
229
211
  currentWarnings: prepared?.warnings ?? [],
212
+ currentCanonicalState: isRecord(prepared?.canonicalState)
213
+ ? prepared.canonicalState
214
+ : undefined,
230
215
  };
231
216
  this.sessions.set(sessionId, session);
232
217
  policyMetrics.recordStrategy(loopId, initialLevel);
@@ -271,14 +256,158 @@ export class SessionManager {
271
256
  : 0,
272
257
  };
273
258
  }
259
+ audit(loopId) {
260
+ return { ...auditEntries(loopId, this.backend.queryLoopEvents(loopId)) };
261
+ }
262
+ inspect(loopId, round) {
263
+ const taskId = `loop:${loopId}:r${round}`;
264
+ const loopEntries = this.backend.queryLoopEvents(loopId);
265
+ const lineage = loopEntries
266
+ .find((entry) => entry.task_id === taskId);
267
+ const roundEvent = loopEntries.find((entry) => entry.task_id === `${taskId}:event`);
268
+ if (!lineage && !roundEvent)
269
+ return null;
270
+ const report = parseRoundReport(roundEvent?.round_report);
271
+ return {
272
+ format: "loopforge.inspect",
273
+ loopId,
274
+ round,
275
+ lineage: lineage ?? null,
276
+ roundEvent: roundEvent ? {
277
+ taskId: roundEvent.task_id,
278
+ timestamp: roundEvent.timestamp,
279
+ success: roundEvent.success === true,
280
+ report,
281
+ evidence: report?.evidence ?? null,
282
+ transaction: roundEvent.loop_lineage?.round_transaction ?? null,
283
+ } : null,
284
+ };
285
+ }
286
+ async retry(sessionId, report) {
287
+ const session = this.sessions.get(sessionId);
288
+ if (!session)
289
+ return { sessionId, round: 0, prompt: null, stopReason: "session_not_found" };
290
+ if ((session.roundSnapshot?.attempt ?? 1) <= 1) {
291
+ return {
292
+ sessionId,
293
+ round: session.currentRound,
294
+ prompt: session.currentPrompt ?? null,
295
+ stopReason: "retry_not_pending",
296
+ stopDetail: "The current round has not been rejected; submit it with loopforge_next.",
297
+ };
298
+ }
299
+ return this.advance(sessionId, report);
300
+ }
301
+ async backtrack(sessionId) {
302
+ return withSessionQueue(this.sessionQueues, sessionId, async () => {
303
+ const session = this.sessions.get(sessionId);
304
+ if (!session)
305
+ return { sessionId, round: 0, prompt: null, stopReason: "session_not_found" };
306
+ if (session.status !== "running") {
307
+ return { sessionId, round: session.currentRound, prompt: null, stopReason: session.status };
308
+ }
309
+ const entries = this.backend
310
+ ? this.backend.queryLoopEvents(session.loopId)
311
+ .filter((entry) => String(entry.task_id ?? "").startsWith(`loop:${session.loopId}:r`))
312
+ : [];
313
+ const restore = findSafeRestorePoint(session.currentRound, entries, getPolicy().engine.backtrack_max_depth);
314
+ if (!restore) {
315
+ return {
316
+ sessionId,
317
+ round: session.currentRound,
318
+ prompt: session.currentPrompt ?? null,
319
+ stopReason: "no_clean_restore_point",
320
+ stopDetail: "No clean round exists within the configured backtrack depth.",
321
+ };
322
+ }
323
+ const skippedFiles = collectSkippedRoundFiles(restore.round, session.currentRound, entries);
324
+ return this.buildBacktrackResult(sessionId, session, {
325
+ action: "backtrack",
326
+ roundSuccess: false,
327
+ shouldPushSuccessTrajectory: false,
328
+ newConsecutiveRejections: 0,
329
+ verificationFlags: [],
330
+ gateContradicted: false,
331
+ backtrackTarget: restore.round,
332
+ backtrackTargetGitHead: findBacktrackTargetGitHead(restore.round, entries),
333
+ backtrackSkippedDiscoveries: restore.skippedDiscoveries,
334
+ backtrackSkippedFiles: skippedFiles,
335
+ backtrackTriggerRule: "manual",
336
+ backtrackPrompt: `# Manual Backtrack\n\nRestore the workspace to the state after Round ${restore.round}, ` +
337
+ "then verify the affected files before continuing. LoopForge does not execute restore commands.",
338
+ enforcementReason: `Manual backtrack to clean Round ${restore.round}`,
339
+ });
340
+ });
341
+ }
342
+ cognitiveProjection(sessionId) {
343
+ const session = this.sessions.get(sessionId);
344
+ if (!session)
345
+ return null;
346
+ if (session.currentCanonicalState)
347
+ return session.currentCanonicalState;
348
+ const request = buildLoopRequest(session, session.lastRoundProjection);
349
+ const context = session.runtime.hydrateLoopContext(session.loopId);
350
+ const result = session.runtime.invokeLoopCompile(request, context, {
351
+ persistLineage: false,
352
+ });
353
+ return isRecord(result.response?.canonical_state)
354
+ ? result.response.canonical_state
355
+ : null;
356
+ }
357
+ checkGate(action) {
358
+ return deriveStructuredGate(action);
359
+ }
360
+ resolveGate(sessionId, gateId, action, approved, note) {
361
+ const session = this.sessions.get(sessionId);
362
+ if (!session)
363
+ return { error: `session not found: ${sessionId}` };
364
+ const descriptor = action;
365
+ const classified = deriveStructuredGate(descriptor);
366
+ if (classified.id !== gateId) {
367
+ return { error: "gateId does not match the supplied action and scope" };
368
+ }
369
+ if (classified.gate.kind !== "user") {
370
+ return { error: "agent gates are resolved by submitting the required evidence" };
371
+ }
372
+ const decidedAt = new Date().toISOString();
373
+ const decision = {
374
+ gateId,
375
+ kind: classified.gate.kind,
376
+ approved,
377
+ scope: descriptor.scope,
378
+ note,
379
+ decidedAt,
380
+ actionHash: classified.actionHash,
381
+ };
382
+ this.appendCanonicalEvent({
383
+ task_id: `loop:${session.loopId}:gate:${gateId}`,
384
+ task_type: "gate_decision",
385
+ loop_id: session.loopId,
386
+ gate_id: gateId,
387
+ approved,
388
+ decision_note: note,
389
+ gate_decision: decision,
390
+ timestamp: decidedAt,
391
+ loop_lineage: { round: session.currentRound, gate_id: gateId, action_hash: classified.actionHash },
392
+ });
393
+ // Gate decisions change the derived state immediately. Do not let the
394
+ // status view serve a pre-decision canonical-state cache.
395
+ session.currentCanonicalState = undefined;
396
+ return { sessionId, loopId: session.loopId, gateId, approved };
397
+ }
274
398
  delete(sessionId) {
275
399
  const session = this.sessions.get(sessionId);
276
400
  if (!session)
277
401
  return false;
278
402
  session.status = "stopped";
403
+ session.stopReason = "cancelled";
279
404
  this.save(session);
405
+ // Durable terminal event after the session_state write, before the
406
+ // in-memory session is dropped (A4).
407
+ this.persistTerminalEvent(session, "cancelled");
280
408
  void this.notifyTerminal(session, "cancelled");
281
409
  this.sessions.delete(sessionId);
410
+ this.sessionQueues.delete(sessionId);
282
411
  logEvent("session_end", {
283
412
  sessionId,
284
413
  loopId: session.loopId,
@@ -319,31 +448,22 @@ export class SessionManager {
319
448
  warnings: (session.currentWarnings ?? []),
320
449
  };
321
450
  }
322
- /** Reconcile the crash window where feedback committed but session_state
451
+ /** Reconcile the crash window where a round event committed but session_state
323
452
  * still points at the old prompt. Returns null when no commit is pending. */
324
- reconcileCommittedRound(session) {
453
+ async reconcileCommittedRound(session) {
325
454
  if (!session.roundSnapshot)
326
455
  return null;
327
- const recovered = new RoundDriver(session.engine, this.backend).recover(session.roundSnapshot);
456
+ const recovered = new RoundDriver(session.runtime, this.backend).recover(session.roundSnapshot);
328
457
  if (!recovered)
329
458
  return null;
330
459
  const pr = recovered.result;
331
460
  session.roundSnapshot = recovered.snapshot;
332
- // Replay the committed counter — but with per-rule tracking.
333
- if (pr.action === "reject" && pr.rejectionCheck) {
334
- session.consecutiveRejections =
335
- pr.rejectionCheck === session.lastRejectionCheck
336
- ? pr.newConsecutiveRejections
337
- : 1;
338
- session.lastRejectionCheck = pr.rejectionCheck;
339
- }
340
- else {
341
- session.consecutiveRejections = pr.newConsecutiveRejections;
342
- if (pr.action !== "reject")
343
- session.lastRejectionCheck = "";
344
- }
345
- if (pr.newLastSelfEval)
346
- session.lastSelfEval = pr.newLastSelfEval;
461
+ // Replay the committed counter. Rejections accumulate globally across
462
+ // all checks (v2.14) so alternating rejection reasons cannot dodge R6.
463
+ session.consecutiveRejections = pr.newConsecutiveRejections;
464
+ this.updateEscalationChecks(session, pr);
465
+ if (pr.newRoundProjection)
466
+ session.lastRoundProjection = pr.newRoundProjection;
347
467
  if (pr.shouldPushSuccessTrajectory &&
348
468
  session.successTrajectory.length < session.currentRound) {
349
469
  session.successTrajectory.push(pr.roundSuccess);
@@ -372,20 +492,36 @@ export class SessionManager {
372
492
  roundSuccess: pr.roundSuccess,
373
493
  };
374
494
  }
495
+ if (pr.action === "backtrack") {
496
+ // Backtrack committed but prompt not delivered (crash window).
497
+ // Reset round counter to the restore target + 1 so the next
498
+ // resume / unpause compiles from the correct restored state.
499
+ const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
500
+ session.currentRound = restoreTarget + 1;
501
+ session.consecutiveRejections = 0;
502
+ session.escalatedRejectionChecks = [];
503
+ session.driftClarificationStreak = 0;
504
+ session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
505
+ session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
506
+ session.lastRoundProjection = undefined;
507
+ session.currentPrompt = null;
508
+ this.save(session);
509
+ // Return null — caller (resume/unpause) will recompile normally
510
+ return null;
511
+ }
375
512
  if (pr.action !== "continue")
376
513
  return null;
377
514
  session.currentRound++;
378
- const request = buildLoopRequest(session, pr.newLastSelfEval, pr.verificationFlags);
379
- const prepared = new RoundDriver(session.engine, this.backend).prepareSync(request, session.loopId, session.currentRound);
515
+ const request = buildLoopRequest(session, session.lastRoundProjection, pr.verificationFlags);
516
+ const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, session.currentRound);
380
517
  if (!prepared) {
381
- session.status = "stalled";
382
- this.save(session);
383
518
  return {
384
519
  sessionId: session.sessionId,
385
520
  round: session.currentRound,
386
521
  prompt: null,
387
- stopReason: "stalled",
388
- stopDetail: "RoundDriver.prepareSync returned null — prompt compilation failed during crash recovery. The session state may be inconsistent.",
522
+ stopReason: "prompt_compile_failed",
523
+ stopDetail: "Prompt compilation failed during crash recovery; the persisted round remains unchanged and can be retried.",
524
+ error: { code: "prompt_compile_failed", message: "Prompt compilation failed during crash recovery.", retryable: true },
389
525
  };
390
526
  }
391
527
  const prompt = prepared.prompt;
@@ -394,6 +530,9 @@ export class SessionManager {
394
530
  session.currentPrompt = prompt;
395
531
  session.currentLevel = prepared.level;
396
532
  session.currentWarnings = prepared?.warnings ?? [];
533
+ session.currentCanonicalState = isRecord(prepared.canonicalState)
534
+ ? prepared.canonicalState
535
+ : undefined;
397
536
  policyMetrics.recordStrategy(session.loopId, session.currentLevel);
398
537
  this.save(session);
399
538
  return {
@@ -443,7 +582,7 @@ export class SessionManager {
443
582
  catch {
444
583
  // Async evidence is best-effort; fall back to sync baseline.
445
584
  }
446
- const reconciled = this.reconcileCommittedRound(session);
585
+ const reconciled = await this.reconcileCommittedRound(session);
447
586
  if (reconciled)
448
587
  return reconciled;
449
588
  const restored = this.restoredPromptResult(session);
@@ -465,7 +604,6 @@ export class SessionManager {
465
604
  const compileRequest = {
466
605
  task: session.task,
467
606
  mode: Mode.LOOP_COMPILE,
468
- feedback: null,
469
607
  skill_name: null,
470
608
  task_id: null,
471
609
  loop_id: lcr.loop_id,
@@ -477,12 +615,16 @@ export class SessionManager {
477
615
  health_check_interval: lcr.health_check_interval,
478
616
  max_rounds: session.maxRounds,
479
617
  };
480
- const prepared = await new RoundDriver(session.engine, this.backend).prepare(compileRequest, session.loopId, session.currentRound);
618
+ const prepared = await new RoundDriver(session.runtime, this.backend).prepare(compileRequest, session.loopId, session.currentRound);
481
619
  if (!prepared) {
482
- session.status = "stopped";
483
- this.save(session);
484
- void this.notifyTerminal(session, "stalled");
485
- 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." };
620
+ return {
621
+ sessionId: session.sessionId,
622
+ round: session.currentRound,
623
+ prompt: null,
624
+ stopReason: "prompt_compile_failed",
625
+ stopDetail: "Prompt compilation failed while resuming; the paused session remains recoverable.",
626
+ error: { code: "prompt_compile_failed", message: "Prompt compilation failed during resume.", retryable: true },
627
+ };
486
628
  }
487
629
  const prompt = prepared.prompt;
488
630
  const level = prepared.level;
@@ -491,6 +633,9 @@ export class SessionManager {
491
633
  session.currentPrompt = prompt;
492
634
  session.currentLevel = level;
493
635
  session.currentWarnings = prepared?.warnings ?? [];
636
+ session.currentCanonicalState = isRecord(prepared.canonicalState)
637
+ ? prepared.canonicalState
638
+ : undefined;
494
639
  policyMetrics.recordStrategy(session.loopId, level);
495
640
  this.save(session);
496
641
  return {
@@ -525,12 +670,19 @@ export class SessionManager {
525
670
  created_at: session.createdAt,
526
671
  // v1.13: Enforcement gate state
527
672
  consecutive_rejections: session.consecutiveRejections,
528
- last_rejection_check: session.lastRejectionCheck,
673
+ // Enforcement escalation is tracked per terminal rule.
674
+ escalated_rejection_checks: session.escalatedRejectionChecks,
675
+ // v2.12: Clarification streak
676
+ drift_clarification_streak: session.driftClarificationStreak,
677
+ // v2.13: Backtrack skipped files for workspace restore check
678
+ backtrack_skipped_files: session.backtrackSkippedFiles,
679
+ // v2.13: Git HEAD of the active backtrack restore point
680
+ backtrack_target_git_head: session.backtrackTargetGitHead ?? "",
529
681
  // v1.19: durable round transaction state
530
682
  round_snapshot: session.roundSnapshot ?? null,
531
- last_self_eval: session.lastSelfEval ?? null,
532
683
  current_prompt: session.currentPrompt ?? null,
533
684
  current_level: session.currentLevel ?? "",
685
+ stop_reason: session.stopReason ?? "",
534
686
  // v1.20: cross-process single-owner lease
535
687
  lease_owner: leaseActive ? this.ownerId : "",
536
688
  lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
@@ -551,47 +703,61 @@ export class SessionManager {
551
703
  }
552
704
  const loopId = entry.loop_id;
553
705
  const currentRound = lineage.current_round ?? 1;
554
- const successTrajectory = lineage.success_trajectory ?? lineage.quality_trajectory ?? [];
706
+ const successTrajectory = Array.isArray(lineage.success_trajectory)
707
+ ? lineage.success_trajectory.filter((value) => typeof value === "boolean")
708
+ : [];
555
709
  const task = entry.task ?? "";
556
710
  const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
557
711
  const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
558
- const fallbackEvidence = EvidenceCollector.fromProviderNames(getPolicy().evidence.providers).collect();
559
- const persistedEval = lineage.last_self_eval;
560
- const lastSelfEval = persistedEval !== null &&
561
- typeof persistedEval === "object" &&
562
- !Array.isArray(persistedEval) &&
563
- typeof persistedEval.success === "boolean" &&
564
- typeof persistedEval.output_summary === "string"
565
- ? persistedEval
712
+ const latestReport = this.backend.queryLoopEvents(loopId)
713
+ .filter((entry) => String(entry.task_id ?? "").startsWith(`loop:${loopId}:r`) && String(entry.task_id ?? "").endsWith(":event"))
714
+ .map((roundEvent) => ({
715
+ round: typeof roundEvent.loop_lineage?.round === "number"
716
+ ? roundEvent.loop_lineage.round
717
+ : 0,
718
+ report: parseRoundReport(roundEvent.round_report),
719
+ })).filter((candidate) => candidate.report !== null)
720
+ .sort((a, b) => b.round - a.round)[0]?.report;
721
+ const lastRoundProjection = latestReport
722
+ ? deriveCompilerProjection(latestReport, currentRound - 1)
566
723
  : undefined;
567
- const engine = new LoopForgeEngine(this.backend);
724
+ const engine = new RuntimeServices(this.backend);
568
725
  return {
569
726
  sessionId: typeof lineage.session_id === "string" && lineage.session_id
570
727
  ? lineage.session_id
571
728
  : randomUUID(),
572
- loopId, task, engine,
729
+ loopId, task, runtime: engine,
573
730
  currentRound, maxRounds, successTrajectory,
574
731
  status: status,
575
732
  createdAt: lineage.created_at ?? Date.now(),
576
733
  consecutiveRejections: lineage.consecutive_rejections ?? 0,
577
- lastRejectionCheck: typeof lineage.last_rejection_check === "string"
578
- ? lineage.last_rejection_check
579
- : "",
580
- evidenceBaseline: roundSnapshot?.beforeEvidence ?? fallbackEvidence,
581
- roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, fallbackEvidence),
582
- lastSelfEval,
734
+ escalatedRejectionChecks: Array.isArray(lineage.escalated_rejection_checks)
735
+ ? lineage.escalated_rejection_checks.filter((value) => typeof value === "string")
736
+ : [],
737
+ driftClarificationStreak: lineage.drift_clarification_streak ?? 0,
738
+ backtrackSkippedFiles: lineage.backtrack_skipped_files ?? [],
739
+ backtrackTargetGitHead: typeof lineage.backtrack_target_git_head === "string" &&
740
+ lineage.backtrack_target_git_head
741
+ ? lineage.backtrack_target_git_head
742
+ : undefined,
743
+ evidenceBaseline: roundSnapshot?.beforeEvidence ?? [],
744
+ roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, []),
745
+ lastRoundProjection,
583
746
  currentPrompt: typeof lineage.current_prompt === "string"
584
747
  ? lineage.current_prompt
585
748
  : null,
586
749
  currentLevel: typeof lineage.current_level === "string"
587
750
  ? lineage.current_level
588
751
  : undefined,
752
+ stopReason: typeof lineage.stop_reason === "string" && lineage.stop_reason
753
+ ? lineage.stop_reason
754
+ : undefined,
589
755
  };
590
756
  }
591
757
  /** Resume a loop from vault state.
592
758
  * Reconstructs the session and compiles the prompt for the next round.
593
759
  * Returns null if no session_state entry exists for this loopId. */
594
- resume(loopId) {
760
+ async resume(loopId) {
595
761
  const persistedEntry = this.findSessionEntry(loopId);
596
762
  if (!persistedEntry)
597
763
  return null;
@@ -617,21 +783,36 @@ export class SessionManager {
617
783
  };
618
784
  }
619
785
  this.sessions.set(session.sessionId, session);
620
- const reconciled = this.reconcileCommittedRound(session);
786
+ const reconciled = await this.reconcileCommittedRound(session);
621
787
  if (reconciled)
622
788
  return reconciled;
623
789
  const restored = this.restoredPromptResult(session);
624
790
  if (restored)
625
791
  return restored;
626
- // Legacy session without a stored prompt: compile once, then persist it.
792
+ // A persisted session may lack a prompt after an interrupted handoff;
793
+ // compile the current canonical state once and persist the replacement.
627
794
  const request = buildLoopRequest(session);
628
- const prepared = new RoundDriver(session.engine, this.backend).prepareSync(request, session.loopId, session.currentRound);
629
- const prompt = prepared?.prompt ?? null;
630
- session.evidenceBaseline = prepared?.evidenceBaseline ?? [];
631
- session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, []);
795
+ const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, session.currentRound);
796
+ if (!prepared) {
797
+ return {
798
+ sessionId: session.sessionId,
799
+ round: session.currentRound,
800
+ roundId: session.roundSnapshot?.roundId,
801
+ prompt: null,
802
+ stopReason: "prompt_compile_failed",
803
+ stopDetail: "Prompt compilation failed; the persisted session remains unchanged and can be retried.",
804
+ error: { code: "prompt_compile_failed", message: "Prompt compilation failed while resuming.", retryable: true },
805
+ };
806
+ }
807
+ const prompt = prepared.prompt;
808
+ session.evidenceBaseline = prepared.evidenceBaseline;
809
+ session.roundSnapshot = prepared.snapshot;
632
810
  session.currentPrompt = prompt;
633
811
  session.currentLevel = prepared?.level ?? "l2";
634
812
  session.currentWarnings = prepared?.warnings ?? [];
813
+ session.currentCanonicalState = isRecord(prepared?.canonicalState)
814
+ ? prepared.canonicalState
815
+ : undefined;
635
816
  policyMetrics.recordStrategy(session.loopId, session.currentLevel);
636
817
  this.save(session);
637
818
  return {
@@ -714,6 +895,56 @@ export class SessionManager {
714
895
  }
715
896
  return result;
716
897
  }
898
+ /** Stable signature over the vault event list. A changed signature means the
899
+ * vault has grown or an idempotent replacement landed, so the cached
900
+ * snapshot must be recomputed. Physical order is stable: writeEntry appends,
901
+ * and replacements update in place. */
902
+ metricsSignature(entries) {
903
+ const hash = createHash("sha256");
904
+ for (const entry of entries) {
905
+ hash.update(entry.task_id ?? "");
906
+ hash.update("\\u0000");
907
+ hash.update(entry.timestamp ?? "");
908
+ hash.update("\\u0000");
909
+ hash.update(String(entry.event_sequence ?? ""));
910
+ hash.update("\\u0000");
911
+ hash.update(entry.task_type ?? "");
912
+ }
913
+ return hash.digest("hex");
914
+ }
915
+ /** Policy metrics that survive process restarts: durable events are
916
+ * replayed through derivePolicyMetrics and merged with live in-process
917
+ * observations (rejections never reach the vault). */
918
+ getLoopPolicyMetrics(loopId) {
919
+ const entries = this.backend.queryLoopEvents(loopId);
920
+ const signature = this.metricsSignature(entries);
921
+ const cached = this.derivedMetricsCache.get(loopId);
922
+ if (cached && cached.signature === signature)
923
+ return cached.snapshot;
924
+ const snapshot = mergePolicyMetrics(derivePolicyMetrics(loopId, entries), policyMetrics.snapshot(loopId));
925
+ this.derivedMetricsCache.set(loopId, { signature, snapshot });
926
+ return snapshot;
927
+ }
928
+ /** Append a durable session_end event for a terminal transition. Call after
929
+ * the session_state write so a failure here only loses a metric, never the
930
+ * stopped status. The fixed task_id makes repeated terminals idempotent. */
931
+ persistTerminalEvent(session, stopReason, extra) {
932
+ this.appendCanonicalEvent({
933
+ task_id: `loop:${session.loopId}:end`,
934
+ task_type: "session_end",
935
+ loop_id: session.loopId,
936
+ task: session.task,
937
+ timestamp: new Date().toISOString(),
938
+ loop_lineage: {
939
+ round: session.currentRound,
940
+ session_id: session.sessionId,
941
+ stop_reason: stopReason,
942
+ success_trajectory: session.successTrajectory,
943
+ consecutive_rejections: session.consecutiveRejections,
944
+ ...extra,
945
+ },
946
+ });
947
+ }
717
948
  /** Get loop health for a loop (in-memory or vault).
718
949
  * Computes goal alignment, constraint integrity, drift, strategy stability. */
719
950
  getHealth(loopId) {
@@ -723,8 +954,8 @@ export class SessionManager {
723
954
  for (const s of this.sessions.values()) {
724
955
  if (s.loopId === loopId) {
725
956
  task = s.task;
726
- if (s.engine.state?.task_id) {
727
- goalId = s.engine.state.task_id;
957
+ if (s.runtime.state?.task_id) {
958
+ goalId = s.runtime.state.task_id;
728
959
  }
729
960
  break;
730
961
  }
@@ -739,7 +970,7 @@ export class SessionManager {
739
970
  if (!task)
740
971
  return null;
741
972
  // Hydrate vault context
742
- const engine = new LoopForgeEngine(this.backend);
973
+ const engine = new RuntimeServices(this.backend);
743
974
  const vaultContext = engine.hydrateLoopContext(loopId);
744
975
  // Build a minimal request for health check
745
976
  const request = makeLoopCompileRequest({
@@ -756,22 +987,32 @@ export class SessionManager {
756
987
  drift_detected: health.drift_detected,
757
988
  strategy_stability: health.strategy_stability,
758
989
  task_continuity: health.task_continuity,
759
- policy_metrics: policyMetrics.snapshot(loopId),
990
+ policy_metrics: this.getLoopPolicyMetrics(loopId),
760
991
  };
761
992
  }
762
- /** Core cycle: extract self-eval → record feedback → check stop → compile next.
763
- * @param preExtractedEval Optional pre-built SelfEvaluation from MCP tool parameter.
764
- * When provided (MCP path with evaluation parameter), skips regex extraction.
765
- * When undefined (runtime/CLI path), falls back to regex extraction from output. */
766
- async advance(sessionId, output, preExtractedEval) {
767
- return this.withSessionQueue(sessionId, async () => {
993
+ /** Core cycle for a canonical structured round report. Free-form output is
994
+ * intentionally ignored; the runtime never guesses state from text. */
995
+ async advance(sessionId, report) {
996
+ return withSessionQueue(this.sessionQueues, sessionId, async () => {
768
997
  const session = this.sessions.get(sessionId);
769
998
  if (session?.status === "running" &&
770
999
  !this.renewSessionLease(session.loopId)) {
771
1000
  return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
772
1001
  }
773
1002
  try {
774
- return await this.advanceUnlocked(sessionId, output, preExtractedEval);
1003
+ const normalized = parseRoundReport(report);
1004
+ if (!normalized) {
1005
+ return {
1006
+ sessionId,
1007
+ round: session?.currentRound ?? 0,
1008
+ roundId: session?.roundSnapshot?.roundId,
1009
+ prompt: session?.currentPrompt ?? null,
1010
+ stopReason: "invalid_round_report",
1011
+ stopDetail: "The submitted report is not a valid canonical RoundReport. The current round remains unchanged and can be retried.",
1012
+ roundSuccess: false,
1013
+ };
1014
+ }
1015
+ return await this.advanceUnlocked(sessionId, normalized.summary, normalized);
775
1016
  }
776
1017
  catch (error) {
777
1018
  if (error instanceof SessionLeaseConflictError) {
@@ -788,83 +1029,122 @@ export class SessionManager {
788
1029
  //
789
1030
  // Pipeline: validate → extract → execute transaction → route disposition
790
1031
  // → advance to next round (continue) or return terminal result.
791
- /** Extract a SelfEvaluation from agent output.
792
- * Structured param preferred → regex extraction → heuristic fallback.
793
- * Returns null only when both regex and heuristic returned null. */
794
- extractEvaluation(output, preExtractedEval) {
795
- if (preExtractedEval) {
796
- return { selfEval: preExtractedEval, extractionFailed: false };
797
- }
798
- const structured = extractSelfEvaluation(output);
799
- if (!structured)
800
- return null;
801
- return { selfEval: structured, extractionFailed: false };
802
- }
803
- /** Execute the round transaction and apply per-rule rejection tracking.
1032
+ /** Execute the round transaction and apply global rejection tracking.
804
1033
  * MUTATES: session.roundSnapshot, session.consecutiveRejections,
805
- * session.lastRejectionCheck, session.lastSelfEval,
806
- * session.successTrajectory */
807
- async executeRoundTransaction(session, selfEval, extractionFailed) {
1034
+ * session.escalatedRejectionChecks, session.lastRoundProjection,
1035
+ * session.successTrajectory, session.driftClarificationStreak */
1036
+ async executeRoundTransaction(session, roundReport) {
808
1037
  const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
809
- const completed = await new RoundDriver(session.engine, this.backend).complete({
1038
+ const completed = await new RoundDriver(session.runtime, this.backend).complete({
810
1039
  snapshot,
811
1040
  loopId: session.loopId,
812
1041
  task: session.task,
813
1042
  maxRounds: session.maxRounds,
814
- selfEval,
815
- extractionSucceeded: !extractionFailed,
816
- lastSelfEval: session.lastSelfEval,
1043
+ roundReport,
1044
+ previousProjection: session.lastRoundProjection,
817
1045
  consecutiveRejections: session.consecutiveRejections,
818
1046
  successTrajectory: session.successTrajectory,
1047
+ driftClarificationStreak: session.driftClarificationStreak,
1048
+ escalatedRejectionChecks: session.escalatedRejectionChecks,
1049
+ backtrackSkippedFiles: session.backtrackSkippedFiles,
1050
+ backtrackTargetGitHead: session.backtrackTargetGitHead,
1051
+ expectedAction: isRecord(session.currentCanonicalState?.todoProjection) &&
1052
+ session.currentCanonicalState.todoProjection.sourceId !== "objective" &&
1053
+ typeof session.currentCanonicalState.suggestedNextAction === "string"
1054
+ ? session.currentCanonicalState.suggestedNextAction
1055
+ : undefined,
1056
+ remainingCriteria: Array.isArray(session.currentCanonicalState?.remainingCriteria)
1057
+ ? session.currentCanonicalState.remainingCriteria.filter((value) => typeof value === "string")
1058
+ : [],
819
1059
  });
820
1060
  const outcome = completed.outcome;
821
1061
  const actualEvidence = completed.actualEvidence;
822
1062
  session.roundSnapshot = outcome.snapshot;
823
1063
  const pr = outcome.result;
824
1064
  policyMetrics.recordStrategyOutcome(session.loopId, session.currentLevel, pr, outcome.replayed);
825
- // Per-rule rejection tracking: same-check rejections accumulate;
826
- // a different rejection reason resets the counter.
827
- if (pr.action === "reject" && pr.rejectionCheck) {
828
- session.consecutiveRejections =
829
- pr.rejectionCheck === session.lastRejectionCheck
830
- ? pr.newConsecutiveRejections
831
- : 1;
832
- session.lastRejectionCheck = pr.rejectionCheck;
833
- }
834
- else {
835
- session.consecutiveRejections = pr.newConsecutiveRejections;
836
- if (pr.action !== "reject")
837
- session.lastRejectionCheck = "";
1065
+ // v2.12: Clarification streak tracking — independent of rejection tracking.
1066
+ // Reset on rounds without intent_drift; track substantive vs weak clarifications.
1067
+ this.updateClarificationStreak(session, pr);
1068
+ // v2.14: Global rejection tracking — rejections accumulate across ALL
1069
+ // checks. The coordinator increments unconditionally; alternating
1070
+ // rejection reasons can no longer reset the R6 counter.
1071
+ session.consecutiveRejections = pr.newConsecutiveRejections;
1072
+ this.updateEscalationChecks(session, pr);
1073
+ // v2.13: A new backtrack decision supersedes the previous restore point.
1074
+ if (pr.action === "backtrack") {
1075
+ session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
838
1076
  }
839
- if (pr.newLastSelfEval)
840
- session.lastSelfEval = pr.newLastSelfEval;
1077
+ if (pr.newRoundProjection)
1078
+ session.lastRoundProjection = pr.newRoundProjection;
841
1079
  if (pr.shouldPushSuccessTrajectory) {
842
1080
  session.successTrajectory.push(pr.roundSuccess);
843
1081
  }
1082
+ if (pr.action === "continue" || pr.action === "stop") {
1083
+ session.currentCanonicalState = undefined;
1084
+ }
844
1085
  return { pr, verificationFlags: pr.verificationFlags, actualEvidence };
845
1086
  }
1087
+ /** v2.12: Update the clarification streak based on the round result.
1088
+ * - Substantive clarification (anchors present): keep streak — genuine pivot.
1089
+ * - No intent_drift this round: reset streak to 0.
1090
+ * - Weak clarification rejected by R7: streak was already consumed by
1091
+ * enforceIntentDrift to decide reject vs terminate; staleness is handled
1092
+ * on the enforcement side. We sync the persisted counter for crash recovery.
1093
+ * MUTATES: session.driftClarificationStreak */
1094
+ updateClarificationStreak(session, pr) {
1095
+ const hasIntentDrift = pr.verificationFlags.some((f) => f.check === "intent_drift");
1096
+ if (!hasIntentDrift) {
1097
+ // No drift this round — reset streak
1098
+ session.driftClarificationStreak = 0;
1099
+ return;
1100
+ }
1101
+ // A runtime-verified clarification closes the drift episode.
1102
+ if (pr.clarificationAccepted) {
1103
+ session.driftClarificationStreak = 0;
1104
+ return;
1105
+ }
1106
+ // R7 rejected (weak or no clarification). The enforcement gate
1107
+ // already used the streak to decide reject vs terminate.
1108
+ // Increment the persisted counter so crash recovery sees it.
1109
+ session.driftClarificationStreak += 1;
1110
+ }
1111
+ /** Persist terminal-rule escalation independently for each enforcement
1112
+ * check. A non-rejection ends the current rejection episode and clears the
1113
+ * set; rejected rules retain their marker until the episode resolves. */
1114
+ updateEscalationChecks(session, pr) {
1115
+ if (pr.action !== "reject") {
1116
+ session.escalatedRejectionChecks = [];
1117
+ return;
1118
+ }
1119
+ if (pr.escalationIssued && pr.rejectionCheck) {
1120
+ const checks = new Set(session.escalatedRejectionChecks);
1121
+ checks.add(pr.rejectionCheck);
1122
+ session.escalatedRejectionChecks = [...checks].sort();
1123
+ }
1124
+ }
846
1125
  /** Build a rejection result: compile a retry prompt, persist, return.
847
1126
  * MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
848
1127
  async buildRejectionResult(sessionId, session, pr, verificationFlags) {
849
1128
  const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
850
- const preparedRetry = await new RoundDriver(session.engine, this.backend).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
1129
+ const preparedRetry = await new RoundDriver(session.runtime, this.backend).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
851
1130
  if (!preparedRetry) {
852
- session.status = "stalled";
853
- session.currentPrompt = null;
854
- this.save(session);
855
1131
  return {
856
1132
  sessionId,
857
1133
  round: session.currentRound,
858
1134
  roundId: session.roundSnapshot?.roundId,
859
1135
  prompt: null,
860
- stopReason: "stalled",
861
- stopDetail: "RoundDriver.prepareRetry returned null — the retry prompt could not be compiled.",
1136
+ stopReason: "prompt_compile_failed",
1137
+ stopDetail: "Retry prompt compilation failed; the rejected attempt remains retryable.",
1138
+ error: { code: "prompt_compile_failed", message: "Retry prompt compilation failed.", retryable: true },
862
1139
  };
863
1140
  }
864
1141
  session.roundSnapshot = preparedRetry.snapshot;
865
1142
  session.currentPrompt = preparedRetry.prompt;
866
1143
  session.currentLevel = preparedRetry.level;
867
1144
  session.currentWarnings = preparedRetry.warnings ?? [];
1145
+ session.currentCanonicalState = isRecord(preparedRetry.canonicalState)
1146
+ ? preparedRetry.canonicalState
1147
+ : undefined;
868
1148
  this.save(session);
869
1149
  return {
870
1150
  sessionId,
@@ -876,12 +1156,128 @@ export class SessionManager {
876
1156
  enforcementReason: pr.enforcementReason,
877
1157
  };
878
1158
  }
1159
+ /** v2.10: Build a backtrack result — roll back to last clean round.
1160
+ * Resets the round counter to the restore target + 1, merges preserved
1161
+ * discoveries, compiles from the restored state, and injects the
1162
+ * backtrack prompt at the top.
1163
+ * MUTATES: session.currentRound, session.roundSnapshot,
1164
+ * session.currentPrompt, session.currentLevel,
1165
+ * session.consecutiveRejections, session.lastRoundProjection */
1166
+ async buildBacktrackResult(sessionId, session, pr) {
1167
+ const skippedUntilRound = session.currentRound;
1168
+ const restoreTarget = pr.backtrackTarget ?? (skippedUntilRound - 1);
1169
+ const newRound = restoreTarget + 1;
1170
+ logEvent("session_backtrack", {
1171
+ sessionId,
1172
+ loopId: session.loopId,
1173
+ fromRound: skippedUntilRound,
1174
+ toRound: newRound,
1175
+ triggerRule: pr.backtrackTriggerRule ?? "",
1176
+ skippedDiscoveries: (pr.backtrackSkippedDiscoveries ?? []).length,
1177
+ });
1178
+ // Reset session to the restored round
1179
+ session.currentRound = newRound;
1180
+ session.consecutiveRejections = 0;
1181
+ session.escalatedRejectionChecks = [];
1182
+ session.driftClarificationStreak = 0;
1183
+ session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
1184
+ session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
1185
+ session.lastRoundProjection = undefined; // force L2 recompile from vault state
1186
+ session.currentPrompt = null;
1187
+ const skippedRounds = Array.from({ length: Math.max(0, skippedUntilRound - restoreTarget - 1) }, (_, index) => restoreTarget + 1 + index);
1188
+ this.appendCanonicalEvent({
1189
+ task_id: `loop:${session.loopId}:backtrack:${randomUUID()}`,
1190
+ task_type: "backtrack",
1191
+ loop_id: session.loopId,
1192
+ format: "loopforge.event",
1193
+ timestamp: new Date().toISOString(),
1194
+ loop_lineage: {
1195
+ round: newRound,
1196
+ restore_round: restoreTarget,
1197
+ skipped_rounds: skippedRounds,
1198
+ skipped_files: session.backtrackSkippedFiles,
1199
+ trigger_rule: pr.backtrackTriggerRule ?? "",
1200
+ reason: pr.enforcementReason ?? "",
1201
+ },
1202
+ });
1203
+ // Preserve discoveries from skipped rounds as constraints_from_plan
1204
+ // so they survive the compilation and re-enter active constraints.
1205
+ const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
1206
+ // Compile the restored prompt — use a fresh request with the restored round
1207
+ const request = {
1208
+ task: session.task,
1209
+ mode: Mode.LOOP_COMPILE,
1210
+ skill_name: null,
1211
+ task_id: null,
1212
+ loop_id: session.loopId,
1213
+ round: newRound,
1214
+ max_rounds: session.maxRounds,
1215
+ verification_flags: pr.verificationFlags,
1216
+ constraints_from_plan: preservedDiscoveries,
1217
+ };
1218
+ const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, newRound);
1219
+ if (!prepared || !prepared.prompt) {
1220
+ return {
1221
+ sessionId,
1222
+ round: newRound,
1223
+ prompt: null,
1224
+ stopReason: "prompt_compile_failed",
1225
+ stopDetail: "Backtrack prompt compilation failed; the restore decision remains persisted and retryable.",
1226
+ error: { code: "prompt_compile_failed", message: "Backtrack prompt compilation failed.", retryable: true },
1227
+ enforcementAction: "backtrack",
1228
+ enforcementReason: pr.enforcementReason,
1229
+ restoreRound: restoreTarget,
1230
+ skippedRounds,
1231
+ skippedFiles: session.backtrackSkippedFiles,
1232
+ recoveryRequirements: [
1233
+ "Restore the workspace to the selected clean round.",
1234
+ "Verify every skipped file before continuing.",
1235
+ ],
1236
+ };
1237
+ }
1238
+ // Inject backtrack prompt at the top of the compiled prompt
1239
+ const backtrackHeader = pr.backtrackPrompt ?? "";
1240
+ const fullPrompt = backtrackHeader
1241
+ ? backtrackHeader + "\n" + (prepared.prompt ?? "")
1242
+ : prepared.prompt ?? "";
1243
+ session.evidenceBaseline = prepared.evidenceBaseline;
1244
+ session.roundSnapshot = prepared.snapshot;
1245
+ session.currentPrompt = fullPrompt;
1246
+ session.currentLevel = prepared.level;
1247
+ session.currentWarnings = prepared?.warnings ?? [];
1248
+ session.currentCanonicalState = isRecord(prepared.canonicalState)
1249
+ ? prepared.canonicalState
1250
+ : undefined;
1251
+ this.save(session);
1252
+ return {
1253
+ sessionId,
1254
+ round: newRound,
1255
+ roundId: session.roundSnapshot?.roundId,
1256
+ prompt: fullPrompt,
1257
+ level: prepared.level,
1258
+ enforcementAction: "backtrack",
1259
+ enforcementReason: pr.enforcementReason,
1260
+ warnings: prepared?.warnings ?? [],
1261
+ restoreRound: restoreTarget,
1262
+ skippedRounds,
1263
+ skippedFiles: session.backtrackSkippedFiles,
1264
+ recoveryRequirements: [
1265
+ "Restore the workspace to the selected clean round.",
1266
+ "Verify every skipped file before continuing.",
1267
+ ],
1268
+ };
1269
+ }
879
1270
  /** Build a termination result: persist stopped status, notify sinks.
880
1271
  * MUTATES: session.status, session.currentPrompt */
881
1272
  buildTerminationResult(sessionId, session, pr) {
882
1273
  session.status = "stopped";
883
1274
  session.currentPrompt = null;
884
1275
  this.save(session);
1276
+ // Durable terminal event after the session_state write (A4): a failure
1277
+ // here only loses a metric, never the stopped status.
1278
+ this.persistTerminalEvent(session, "enforcement_terminated", {
1279
+ enforcement_reason: pr.enforcementReason,
1280
+ });
885
1281
  void this.notifyTerminal(session, "enforcement_terminated");
886
1282
  logEvent("session_end", {
887
1283
  sessionId, loopId: session.loopId,
@@ -905,6 +1301,8 @@ export class SessionManager {
905
1301
  session.status = reason === "stalled" ? "stalled" : "stopped";
906
1302
  session.currentPrompt = null;
907
1303
  this.save(session);
1304
+ // Durable terminal event after the session_state write (A4).
1305
+ this.persistTerminalEvent(session, reason);
908
1306
  void this.notifyTerminal(session, reason);
909
1307
  logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
910
1308
  return {
@@ -912,7 +1310,7 @@ export class SessionManager {
912
1310
  roundId: session.roundSnapshot?.roundId,
913
1311
  prompt: null, stopReason: reason,
914
1312
  stopDetail: pr.stopReason === "circuit_breaker"
915
- ? `${pr.roundSuccess ? "No" : "All"} recent rounds failed, triggering the circuit breaker.`
1313
+ ? "The last consecutive accepted rounds all failed without recovery, triggering the circuit breaker."
916
1314
  : pr.stopReason === "max_rounds"
917
1315
  ? "The configured maximum number of rounds has been reached."
918
1316
  : `Loop stopped: ${reason}.`,
@@ -923,26 +1321,25 @@ export class SessionManager {
923
1321
  * Includes the commit fence (pause/delete race guard) and context provider.
924
1322
  * MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
925
1323
  * session.evidenceBaseline, session.roundSnapshot */
926
- async advanceToNextRound(sessionId, session, selfEval, pr, verificationFlags, actualEvidence) {
1324
+ async advanceToNextRound(sessionId, session, pr, verificationFlags, actualEvidence) {
927
1325
  const roundSuccess = pr.roundSuccess;
928
- session.currentRound++;
929
- session.currentPrompt = null;
1326
+ const nextRound = session.currentRound + 1;
930
1327
  // v1.8: Memory injection — tier-aware external context.
931
1328
  let externalCtx = "";
932
1329
  if (this.contextProvider) {
933
1330
  try {
934
1331
  externalCtx = (await this.contextProvider({
935
1332
  loopId: session.loopId,
936
- round: session.currentRound,
1333
+ round: nextRound,
937
1334
  task: session.task,
938
1335
  domain: "",
939
- lastEvaluation: selfEval,
1336
+ lastRoundView: session.lastRoundProjection,
940
1337
  })).trim();
941
1338
  }
942
1339
  catch {
943
1340
  logEvent("context_provider_error", {
944
1341
  loopId: session.loopId,
945
- round: session.currentRound,
1342
+ round: nextRound,
946
1343
  });
947
1344
  }
948
1345
  }
@@ -958,19 +1355,39 @@ export class SessionManager {
958
1355
  roundSuccess,
959
1356
  };
960
1357
  }
961
- const request = buildLoopRequest(session, selfEval, verificationFlags);
1358
+ const request = buildLoopRequest({ ...session, currentRound: nextRound }, session.lastRoundProjection, verificationFlags);
962
1359
  if (externalCtx) {
963
1360
  request.external_context = externalCtx;
964
1361
  }
965
- const prepared = await new RoundDriver(session.engine, this.backend).prepare(request, session.loopId, session.currentRound);
966
- const nextPrompt = prepared?.prompt ?? null;
967
- const nextLevel = prepared?.level ?? "l2";
968
- const nextBaseline = prepared?.evidenceBaseline ?? actualEvidence;
1362
+ const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, nextRound);
1363
+ if (!prepared) {
1364
+ return {
1365
+ sessionId,
1366
+ round: session.currentRound,
1367
+ roundId: session.roundSnapshot?.roundId,
1368
+ prompt: null,
1369
+ stopReason: "prompt_compile_failed",
1370
+ stopDetail: "Prompt compilation failed; the current round remains unchanged and can be retried.",
1371
+ roundSuccess,
1372
+ error: { code: "prompt_compile_failed", message: "Prompt compilation failed while advancing.", retryable: true },
1373
+ };
1374
+ }
1375
+ const nextPrompt = prepared.prompt;
1376
+ const nextLevel = prepared.level;
1377
+ const nextBaseline = prepared.evidenceBaseline ?? actualEvidence;
1378
+ // Clear backtrack restore state only after the next prompt is ready.
1379
+ session.backtrackSkippedFiles = [];
1380
+ session.backtrackTargetGitHead = undefined;
1381
+ session.currentRound = nextRound;
1382
+ session.currentPrompt = null;
969
1383
  session.evidenceBaseline = nextBaseline;
970
1384
  session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
971
1385
  session.currentPrompt = nextPrompt;
972
1386
  session.currentLevel = nextLevel;
973
1387
  session.currentWarnings = prepared?.warnings ?? [];
1388
+ session.currentCanonicalState = isRecord(prepared?.canonicalState)
1389
+ ? prepared.canonicalState
1390
+ : undefined;
974
1391
  policyMetrics.recordStrategy(session.loopId, nextLevel);
975
1392
  this.save(session);
976
1393
  return {
@@ -983,7 +1400,7 @@ export class SessionManager {
983
1400
  warnings: prepared?.warnings ?? [],
984
1401
  };
985
1402
  }
986
- async advanceUnlocked(sessionId, output, preExtractedEval) {
1403
+ async advanceUnlocked(sessionId, output, roundReport) {
987
1404
  // ── 1. Validate session ──────────────────────────────────────────────
988
1405
  const session = this.sessions.get(sessionId);
989
1406
  if (!session)
@@ -991,22 +1408,33 @@ export class SessionManager {
991
1408
  if (session.status !== "running") {
992
1409
  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.` };
993
1410
  }
994
- // ── 2. Extract self-evaluation ───────────────────────────────────────
995
- const extracted = this.extractEvaluation(output, preExtractedEval);
996
- if (!extracted) {
997
- session.status = "stalled";
998
- this.save(session);
999
- void this.notifyTerminal(session, "stalled");
1000
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
1001
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", stopDetail: "Self-evaluation could not be extracted from agent output. Both structured regex and heuristic fallback returned null.", roundSuccess: false };
1411
+ // ── 2. Require canonical report ───────────────────────────────────────
1412
+ if (!roundReport) {
1413
+ logEvent("invalid_round_report", {
1414
+ sessionId,
1415
+ loopId: session.loopId,
1416
+ round: session.currentRound,
1417
+ });
1418
+ return {
1419
+ sessionId,
1420
+ round: session.currentRound,
1421
+ roundId: session.roundSnapshot?.roundId,
1422
+ prompt: session.currentPrompt ?? null,
1423
+ stopReason: "invalid_round_report",
1424
+ stopDetail: "A canonical RoundReport is required; the current round remains unchanged and can be retried.",
1425
+ roundSuccess: false,
1426
+ };
1002
1427
  }
1003
- const { selfEval, extractionFailed } = extracted;
1004
1428
  // ── 3. Execute round transaction ─────────────────────────────────────
1005
- const tx = await this.executeRoundTransaction(session, selfEval, extractionFailed);
1429
+ const tx = await this.executeRoundTransaction(session, roundReport);
1006
1430
  // ── 4. Route disposition ─────────────────────────────────────────────
1007
1431
  if (tx.pr.action === "reject") {
1008
1432
  return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags);
1009
1433
  }
1434
+ // ── v2.10: Backtrack — roll back to last clean round ──────────────────
1435
+ if (tx.pr.action === "backtrack") {
1436
+ return this.buildBacktrackResult(sessionId, session, tx.pr);
1437
+ }
1010
1438
  // Accepted rounds advance the before-snapshot baseline. Rejected rounds
1011
1439
  // retain the original baseline so their retry remains zero-commit.
1012
1440
  session.evidenceBaseline = tx.actualEvidence;
@@ -1017,7 +1445,7 @@ export class SessionManager {
1017
1445
  return this.buildStopResult(sessionId, session, tx.pr);
1018
1446
  }
1019
1447
  // ── 5. Continue — compile next round ─────────────────────────────────
1020
- return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.verificationFlags, tx.actualEvidence);
1448
+ return this.advanceToNextRound(sessionId, session, tx.pr, tx.verificationFlags, tx.actualEvidence);
1021
1449
  }
1022
1450
  /** Write back loop knowledge to long-term memory.
1023
1451
  * Uses shared base builder from policy.ts. Called when a loop terminates. */
@@ -1031,7 +1459,7 @@ export class SessionManager {
1031
1459
  stopReason: stopReason,
1032
1460
  roundsCompleted: session.currentRound,
1033
1461
  successTrajectory: [...session.successTrajectory],
1034
- lastEvaluation: session.lastSelfEval,
1462
+ lastRoundView: session.lastRoundProjection,
1035
1463
  };
1036
1464
  await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
1037
1465
  }
@@ -1041,7 +1469,22 @@ export class SessionManager {
1041
1469
  if (!session)
1042
1470
  return null;
1043
1471
  const replay = new ReplayBackend(this.backend);
1044
- return replay.timeline(session.loopId);
1472
+ return replay.eventTimeline(session.loopId);
1473
+ }
1474
+ replayTimelineByLoop(loopId, fromSequence, toSequence, limit = 50) {
1475
+ const replay = new ReplayBackend(this.backend);
1476
+ const timeline = replay.eventTimeline(loopId)
1477
+ .filter((entry) => {
1478
+ const sequence = typeof entry.event_sequence === "number" ? entry.event_sequence : null;
1479
+ if (sequence === null)
1480
+ return fromSequence === undefined && toSequence === undefined;
1481
+ return (fromSequence === undefined || sequence >= fromSequence) &&
1482
+ (toSequence === undefined || sequence <= toSequence);
1483
+ })
1484
+ .slice(0, Math.max(1, Math.min(200, limit)));
1485
+ const last = timeline.at(-1);
1486
+ const lastSequence = last && typeof last.event_sequence === "number" ? last.event_sequence : null;
1487
+ return { timeline, nextSequence: lastSequence === null ? null : lastSequence + 1 };
1045
1488
  }
1046
1489
  }
1047
1490
  //# sourceMappingURL=session.js.map