loopforge 2.11.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 (166) hide show
  1. package/README.md +145 -128
  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 +170 -61
  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 +23 -21
  26. package/dist/evidence-provider.d.ts.map +1 -1
  27. package/dist/evidence-provider.js +34 -74
  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 +22 -19
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +15 -16
  34. package/dist/index.js.map +1 -1
  35. package/dist/loop-compiler.d.ts +1 -1
  36. package/dist/loop-compiler.d.ts.map +1 -1
  37. package/dist/loop-compiler.js +250 -337
  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 -309
  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 +83 -13
  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 +49 -93
  64. package/dist/mcp/session.d.ts.map +1 -1
  65. package/dist/mcp/session.js +544 -245
  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 +399 -274
  70. package/dist/mcp/tools.js.map +1 -1
  71. package/dist/policy-metrics.d.ts +17 -1
  72. package/dist/policy-metrics.d.ts.map +1 -1
  73. package/dist/policy-metrics.js +164 -1
  74. package/dist/policy-metrics.js.map +1 -1
  75. package/dist/policy.d.ts +34 -18
  76. package/dist/policy.d.ts.map +1 -1
  77. package/dist/policy.js +113 -18
  78. package/dist/policy.js.map +1 -1
  79. package/dist/prompt-assembler.d.ts +5 -3
  80. package/dist/prompt-assembler.d.ts.map +1 -1
  81. package/dist/prompt-assembler.js +126 -77
  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 +232 -192
  88. package/dist/protocol.d.ts.map +1 -1
  89. package/dist/protocol.js +15 -100
  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 +25 -18
  100. package/dist/round-coordinator.d.ts.map +1 -1
  101. package/dist/round-coordinator.js +83 -71
  102. package/dist/round-coordinator.js.map +1 -1
  103. package/dist/round-driver.d.ts +14 -10
  104. package/dist/round-driver.d.ts.map +1 -1
  105. package/dist/round-driver.js +9 -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 +19 -12
  120. package/dist/round-transaction.d.ts.map +1 -1
  121. package/dist/round-transaction.js +102 -88
  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/runtime-services.js +460 -0
  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 +9 -8
  132. package/dist/token-utils.d.ts.map +1 -1
  133. package/dist/token-utils.js +50 -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 +12 -6
  146. package/skills/perception/SKILL.md +199 -164
  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 -71
  152. package/dist/enforcement-gate.d.ts.map +0 -1
  153. package/dist/enforcement-gate.js +0 -744
  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 +0 -596
  158. package/dist/engine.js.map +0 -1
  159. package/dist/self-eval.d.ts +0 -23
  160. package/dist/self-eval.d.ts.map +0 -1
  161. package/dist/self-eval.js +0 -181
  162. package/dist/self-eval.js.map +0 -1
  163. package/dist/verification-gate.d.ts +0 -56
  164. package/dist/verification-gate.d.ts.map +0 -1
  165. package/dist/verification-gate.js +0 -563
  166. package/dist/verification-gate.js.map +0 -1
@@ -2,82 +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
- // v2.8: Drift clarification
68
- drift_clarification: lastEval.drift_clarification,
69
- // v2.9: Model's information needs for the next prompt
70
- prompt_requests: lastEval.prompt_requests,
71
- });
72
- }
73
- return req;
74
- }
75
28
  // ── SessionManager ─────────────────────────────────────────────────────────
76
29
  export class SessionManager {
77
30
  sessions = new Map();
78
31
  /** Serializes state transitions for each session. */
79
32
  sessionQueues = new Map();
80
33
  backend;
34
+ eventWriter;
81
35
  sessionStore;
82
36
  ownerId = `${process.pid}:${randomUUID()}`;
83
37
  leaseMs;
@@ -86,16 +40,13 @@ export class SessionManager {
86
40
  /** Explicit context provider; never auto-discovered. */
87
41
  contextProvider;
88
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();
89
47
  constructor(storeOrBackend, sessionStore) {
90
- const isLoopStore = (v) => "readSession" in v && typeof v.readSession === "function";
91
- const store = storeOrBackend
92
- ? isLoopStore(storeOrBackend)
93
- ? storeOrBackend
94
- : new VaultBackendLoopStore(storeOrBackend)
95
- : new FileLoopStore(getPolicy().backend.root_dir);
96
- this.backend = storeOrBackend && !isLoopStore(storeOrBackend)
97
- ? storeOrBackend
98
- : new LoopStoreBackend(store);
48
+ const store = storeOrBackend ?? new FileLoopStore(getPolicy().backend.root_dir);
49
+ this.backend = store;
99
50
  this.sessionStore = sessionStore ?? new VaultSessionStateStore(store);
100
51
  const mcpPolicy = getPolicy().mcp;
101
52
  this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
@@ -105,6 +56,11 @@ export class SessionManager {
105
56
  this.leaseTimer.unref?.();
106
57
  }
107
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
+ }
108
64
  /** Stable process-local owner token used for cross-process session leases. */
109
65
  getOwnerId() {
110
66
  return this.ownerId;
@@ -157,36 +113,29 @@ export class SessionManager {
157
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.`,
158
114
  };
159
115
  }
160
- async withSessionQueue(sessionId, work) {
161
- const previous = this.sessionQueues.get(sessionId) ?? Promise.resolve();
162
- let release;
163
- const gate = new Promise((resolve) => {
164
- release = resolve;
165
- });
166
- const tail = previous.then(() => gate);
167
- this.sessionQueues.set(sessionId, tail);
168
- await previous;
169
- try {
170
- return await work();
171
- }
172
- finally {
173
- release();
174
- if (this.sessionQueues.get(sessionId) === tail) {
175
- this.sessionQueues.delete(sessionId);
176
- }
177
- }
178
- }
179
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
+ }
180
129
  const sessionId = randomUUID();
181
130
  const loopId = input.loopId ?? randomUUID();
182
- const engine = new LoopForgeEngine(this.backend);
131
+ const engine = new RuntimeServices(this.backend);
183
132
  const maxRounds = input.maxRounds ?? getPolicy().engine.max_rounds;
184
133
  // Populate extra fields for the first round
185
134
  const request = buildLoopRequest({
186
- sessionId, loopId, task: input.task, engine, currentRound: 1,
135
+ sessionId, loopId, task: input.task, runtime: engine, currentRound: 1,
187
136
  maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
188
137
  consecutiveRejections: 0,
189
- lastRejectionCheck: "",
138
+ escalatedRejectionChecks: [],
190
139
  driftClarificationStreak: 0,
191
140
  backtrackSkippedFiles: [],
192
141
  evidenceBaseline: [],
@@ -194,6 +143,21 @@ export class SessionManager {
194
143
  request.domain = input.domain ?? "";
195
144
  request.plan_source = input.planSource ?? null;
196
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
+ }
197
161
  // Explicit context is requested by the MCP embedding; no auto-discovery.
198
162
  if (this.contextProvider) {
199
163
  try {
@@ -218,16 +182,26 @@ export class SessionManager {
218
182
  }
219
183
  }
220
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
+ }
221
195
  const initialPrompt = prepared?.prompt ?? null;
222
196
  const initialLevel = prepared?.level ?? "l2";
223
197
  const evidenceBaseline = prepared?.evidenceBaseline ?? [];
224
198
  const session = {
225
- sessionId, loopId, task: input.task, engine,
199
+ sessionId, loopId, task: input.task, runtime: engine,
226
200
  currentRound: 1, maxRounds, successTrajectory: [],
227
201
  status: "running", createdAt: Date.now(),
228
202
  // v1.13: Enforcement gate state
229
203
  consecutiveRejections: 0,
230
- lastRejectionCheck: "",
204
+ escalatedRejectionChecks: [],
231
205
  driftClarificationStreak: 0,
232
206
  backtrackSkippedFiles: [],
233
207
  evidenceBaseline,
@@ -235,6 +209,9 @@ export class SessionManager {
235
209
  currentPrompt: initialPrompt,
236
210
  currentLevel: initialLevel,
237
211
  currentWarnings: prepared?.warnings ?? [],
212
+ currentCanonicalState: isRecord(prepared?.canonicalState)
213
+ ? prepared.canonicalState
214
+ : undefined,
238
215
  };
239
216
  this.sessions.set(sessionId, session);
240
217
  policyMetrics.recordStrategy(loopId, initialLevel);
@@ -279,12 +256,155 @@ export class SessionManager {
279
256
  : 0,
280
257
  };
281
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
+ }
282
398
  delete(sessionId) {
283
399
  const session = this.sessions.get(sessionId);
284
400
  if (!session)
285
401
  return false;
286
402
  session.status = "stopped";
403
+ session.stopReason = "cancelled";
287
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");
288
408
  void this.notifyTerminal(session, "cancelled");
289
409
  this.sessions.delete(sessionId);
290
410
  this.sessionQueues.delete(sessionId);
@@ -328,31 +448,22 @@ export class SessionManager {
328
448
  warnings: (session.currentWarnings ?? []),
329
449
  };
330
450
  }
331
- /** Reconcile the crash window where feedback committed but session_state
451
+ /** Reconcile the crash window where a round event committed but session_state
332
452
  * still points at the old prompt. Returns null when no commit is pending. */
333
- reconcileCommittedRound(session) {
453
+ async reconcileCommittedRound(session) {
334
454
  if (!session.roundSnapshot)
335
455
  return null;
336
- const recovered = new RoundDriver(session.engine, this.backend).recover(session.roundSnapshot);
456
+ const recovered = new RoundDriver(session.runtime, this.backend).recover(session.roundSnapshot);
337
457
  if (!recovered)
338
458
  return null;
339
459
  const pr = recovered.result;
340
460
  session.roundSnapshot = recovered.snapshot;
341
- // Replay the committed counter — but with per-rule tracking.
342
- if (pr.action === "reject" && pr.rejectionCheck) {
343
- session.consecutiveRejections =
344
- pr.rejectionCheck === session.lastRejectionCheck
345
- ? pr.newConsecutiveRejections
346
- : 1;
347
- session.lastRejectionCheck = pr.rejectionCheck;
348
- }
349
- else {
350
- session.consecutiveRejections = pr.newConsecutiveRejections;
351
- if (pr.action !== "reject")
352
- session.lastRejectionCheck = "";
353
- }
354
- if (pr.newLastSelfEval)
355
- 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;
356
467
  if (pr.shouldPushSuccessTrajectory &&
357
468
  session.successTrajectory.length < session.currentRound) {
358
469
  session.successTrajectory.push(pr.roundSuccess);
@@ -388,9 +499,11 @@ export class SessionManager {
388
499
  const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
389
500
  session.currentRound = restoreTarget + 1;
390
501
  session.consecutiveRejections = 0;
391
- session.lastRejectionCheck = "";
502
+ session.escalatedRejectionChecks = [];
392
503
  session.driftClarificationStreak = 0;
393
- session.lastSelfEval = undefined;
504
+ session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
505
+ session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
506
+ session.lastRoundProjection = undefined;
394
507
  session.currentPrompt = null;
395
508
  this.save(session);
396
509
  // Return null — caller (resume/unpause) will recompile normally
@@ -399,17 +512,16 @@ export class SessionManager {
399
512
  if (pr.action !== "continue")
400
513
  return null;
401
514
  session.currentRound++;
402
- const request = buildLoopRequest(session, pr.newLastSelfEval, pr.verificationFlags);
403
- 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);
404
517
  if (!prepared) {
405
- session.status = "stalled";
406
- this.save(session);
407
518
  return {
408
519
  sessionId: session.sessionId,
409
520
  round: session.currentRound,
410
521
  prompt: null,
411
- stopReason: "stalled",
412
- 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 },
413
525
  };
414
526
  }
415
527
  const prompt = prepared.prompt;
@@ -418,6 +530,9 @@ export class SessionManager {
418
530
  session.currentPrompt = prompt;
419
531
  session.currentLevel = prepared.level;
420
532
  session.currentWarnings = prepared?.warnings ?? [];
533
+ session.currentCanonicalState = isRecord(prepared.canonicalState)
534
+ ? prepared.canonicalState
535
+ : undefined;
421
536
  policyMetrics.recordStrategy(session.loopId, session.currentLevel);
422
537
  this.save(session);
423
538
  return {
@@ -467,7 +582,7 @@ export class SessionManager {
467
582
  catch {
468
583
  // Async evidence is best-effort; fall back to sync baseline.
469
584
  }
470
- const reconciled = this.reconcileCommittedRound(session);
585
+ const reconciled = await this.reconcileCommittedRound(session);
471
586
  if (reconciled)
472
587
  return reconciled;
473
588
  const restored = this.restoredPromptResult(session);
@@ -489,7 +604,6 @@ export class SessionManager {
489
604
  const compileRequest = {
490
605
  task: session.task,
491
606
  mode: Mode.LOOP_COMPILE,
492
- feedback: null,
493
607
  skill_name: null,
494
608
  task_id: null,
495
609
  loop_id: lcr.loop_id,
@@ -501,12 +615,16 @@ export class SessionManager {
501
615
  health_check_interval: lcr.health_check_interval,
502
616
  max_rounds: session.maxRounds,
503
617
  };
504
- 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);
505
619
  if (!prepared) {
506
- session.status = "stopped";
507
- this.save(session);
508
- void this.notifyTerminal(session, "stalled");
509
- 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
+ };
510
628
  }
511
629
  const prompt = prepared.prompt;
512
630
  const level = prepared.level;
@@ -515,6 +633,9 @@ export class SessionManager {
515
633
  session.currentPrompt = prompt;
516
634
  session.currentLevel = level;
517
635
  session.currentWarnings = prepared?.warnings ?? [];
636
+ session.currentCanonicalState = isRecord(prepared.canonicalState)
637
+ ? prepared.canonicalState
638
+ : undefined;
518
639
  policyMetrics.recordStrategy(session.loopId, level);
519
640
  this.save(session);
520
641
  return {
@@ -549,16 +670,19 @@ export class SessionManager {
549
670
  created_at: session.createdAt,
550
671
  // v1.13: Enforcement gate state
551
672
  consecutive_rejections: session.consecutiveRejections,
552
- last_rejection_check: session.lastRejectionCheck,
673
+ // Enforcement escalation is tracked per terminal rule.
674
+ escalated_rejection_checks: session.escalatedRejectionChecks,
553
675
  // v2.12: Clarification streak
554
676
  drift_clarification_streak: session.driftClarificationStreak,
555
677
  // v2.13: Backtrack skipped files for workspace restore check
556
678
  backtrack_skipped_files: session.backtrackSkippedFiles,
679
+ // v2.13: Git HEAD of the active backtrack restore point
680
+ backtrack_target_git_head: session.backtrackTargetGitHead ?? "",
557
681
  // v1.19: durable round transaction state
558
682
  round_snapshot: session.roundSnapshot ?? null,
559
- last_self_eval: session.lastSelfEval ?? null,
560
683
  current_prompt: session.currentPrompt ?? null,
561
684
  current_level: session.currentLevel ?? "",
685
+ stop_reason: session.stopReason ?? "",
562
686
  // v1.20: cross-process single-owner lease
563
687
  lease_owner: leaseActive ? this.ownerId : "",
564
688
  lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
@@ -579,49 +703,61 @@ export class SessionManager {
579
703
  }
580
704
  const loopId = entry.loop_id;
581
705
  const currentRound = lineage.current_round ?? 1;
582
- 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
+ : [];
583
709
  const task = entry.task ?? "";
584
710
  const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
585
711
  const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
586
- const fallbackEvidence = EvidenceCollector.fromProviderNames(getPolicy().evidence.providers).collect();
587
- const persistedEval = lineage.last_self_eval;
588
- const lastSelfEval = persistedEval !== null &&
589
- typeof persistedEval === "object" &&
590
- !Array.isArray(persistedEval) &&
591
- typeof persistedEval.success === "boolean" &&
592
- typeof persistedEval.output_summary === "string"
593
- ? 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)
594
723
  : undefined;
595
- const engine = new LoopForgeEngine(this.backend);
724
+ const engine = new RuntimeServices(this.backend);
596
725
  return {
597
726
  sessionId: typeof lineage.session_id === "string" && lineage.session_id
598
727
  ? lineage.session_id
599
728
  : randomUUID(),
600
- loopId, task, engine,
729
+ loopId, task, runtime: engine,
601
730
  currentRound, maxRounds, successTrajectory,
602
731
  status: status,
603
732
  createdAt: lineage.created_at ?? Date.now(),
604
733
  consecutiveRejections: lineage.consecutive_rejections ?? 0,
605
- lastRejectionCheck: typeof lineage.last_rejection_check === "string"
606
- ? lineage.last_rejection_check
607
- : "",
734
+ escalatedRejectionChecks: Array.isArray(lineage.escalated_rejection_checks)
735
+ ? lineage.escalated_rejection_checks.filter((value) => typeof value === "string")
736
+ : [],
608
737
  driftClarificationStreak: lineage.drift_clarification_streak ?? 0,
609
738
  backtrackSkippedFiles: lineage.backtrack_skipped_files ?? [],
610
- evidenceBaseline: roundSnapshot?.beforeEvidence ?? fallbackEvidence,
611
- roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, fallbackEvidence),
612
- lastSelfEval,
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,
613
746
  currentPrompt: typeof lineage.current_prompt === "string"
614
747
  ? lineage.current_prompt
615
748
  : null,
616
749
  currentLevel: typeof lineage.current_level === "string"
617
750
  ? lineage.current_level
618
751
  : undefined,
752
+ stopReason: typeof lineage.stop_reason === "string" && lineage.stop_reason
753
+ ? lineage.stop_reason
754
+ : undefined,
619
755
  };
620
756
  }
621
757
  /** Resume a loop from vault state.
622
758
  * Reconstructs the session and compiles the prompt for the next round.
623
759
  * Returns null if no session_state entry exists for this loopId. */
624
- resume(loopId) {
760
+ async resume(loopId) {
625
761
  const persistedEntry = this.findSessionEntry(loopId);
626
762
  if (!persistedEntry)
627
763
  return null;
@@ -647,21 +783,36 @@ export class SessionManager {
647
783
  };
648
784
  }
649
785
  this.sessions.set(session.sessionId, session);
650
- const reconciled = this.reconcileCommittedRound(session);
786
+ const reconciled = await this.reconcileCommittedRound(session);
651
787
  if (reconciled)
652
788
  return reconciled;
653
789
  const restored = this.restoredPromptResult(session);
654
790
  if (restored)
655
791
  return restored;
656
- // 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.
657
794
  const request = buildLoopRequest(session);
658
- const prepared = new RoundDriver(session.engine, this.backend).prepareSync(request, session.loopId, session.currentRound);
659
- const prompt = prepared?.prompt ?? null;
660
- session.evidenceBaseline = prepared?.evidenceBaseline ?? [];
661
- 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;
662
810
  session.currentPrompt = prompt;
663
811
  session.currentLevel = prepared?.level ?? "l2";
664
812
  session.currentWarnings = prepared?.warnings ?? [];
813
+ session.currentCanonicalState = isRecord(prepared?.canonicalState)
814
+ ? prepared.canonicalState
815
+ : undefined;
665
816
  policyMetrics.recordStrategy(session.loopId, session.currentLevel);
666
817
  this.save(session);
667
818
  return {
@@ -744,6 +895,56 @@ export class SessionManager {
744
895
  }
745
896
  return result;
746
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
+ }
747
948
  /** Get loop health for a loop (in-memory or vault).
748
949
  * Computes goal alignment, constraint integrity, drift, strategy stability. */
749
950
  getHealth(loopId) {
@@ -753,8 +954,8 @@ export class SessionManager {
753
954
  for (const s of this.sessions.values()) {
754
955
  if (s.loopId === loopId) {
755
956
  task = s.task;
756
- if (s.engine.state?.task_id) {
757
- goalId = s.engine.state.task_id;
957
+ if (s.runtime.state?.task_id) {
958
+ goalId = s.runtime.state.task_id;
758
959
  }
759
960
  break;
760
961
  }
@@ -769,7 +970,7 @@ export class SessionManager {
769
970
  if (!task)
770
971
  return null;
771
972
  // Hydrate vault context
772
- const engine = new LoopForgeEngine(this.backend);
973
+ const engine = new RuntimeServices(this.backend);
773
974
  const vaultContext = engine.hydrateLoopContext(loopId);
774
975
  // Build a minimal request for health check
775
976
  const request = makeLoopCompileRequest({
@@ -786,22 +987,32 @@ export class SessionManager {
786
987
  drift_detected: health.drift_detected,
787
988
  strategy_stability: health.strategy_stability,
788
989
  task_continuity: health.task_continuity,
789
- policy_metrics: policyMetrics.snapshot(loopId),
990
+ policy_metrics: this.getLoopPolicyMetrics(loopId),
790
991
  };
791
992
  }
792
- /** Core cycle: extract self-eval → record feedback → check stop → compile next.
793
- * @param preExtractedEval Optional pre-built SelfEvaluation from MCP tool parameter.
794
- * When provided (MCP path with evaluation parameter), skips regex extraction.
795
- * When undefined (runtime/CLI path), falls back to regex extraction from output. */
796
- async advance(sessionId, output, preExtractedEval) {
797
- 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 () => {
798
997
  const session = this.sessions.get(sessionId);
799
998
  if (session?.status === "running" &&
800
999
  !this.renewSessionLease(session.loopId)) {
801
1000
  return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
802
1001
  }
803
1002
  try {
804
- 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);
805
1016
  }
806
1017
  catch (error) {
807
1018
  if (error instanceof SessionLeaseConflictError) {
@@ -818,36 +1029,33 @@ export class SessionManager {
818
1029
  //
819
1030
  // Pipeline: validate → extract → execute transaction → route disposition
820
1031
  // → advance to next round (continue) or return terminal result.
821
- /** Extract a SelfEvaluation from agent output.
822
- * Structured param preferred → regex extraction → heuristic fallback.
823
- * Returns null only when both regex and heuristic returned null. */
824
- extractEvaluation(output, preExtractedEval) {
825
- if (preExtractedEval) {
826
- return { selfEval: preExtractedEval, extractionFailed: false };
827
- }
828
- const structured = extractSelfEvaluation(output);
829
- if (!structured)
830
- return null;
831
- return { selfEval: structured, extractionFailed: false };
832
- }
833
- /** Execute the round transaction and apply per-rule rejection tracking.
1032
+ /** Execute the round transaction and apply global rejection tracking.
834
1033
  * MUTATES: session.roundSnapshot, session.consecutiveRejections,
835
- * session.lastRejectionCheck, session.lastSelfEval,
1034
+ * session.escalatedRejectionChecks, session.lastRoundProjection,
836
1035
  * session.successTrajectory, session.driftClarificationStreak */
837
- async executeRoundTransaction(session, selfEval, extractionFailed) {
1036
+ async executeRoundTransaction(session, roundReport) {
838
1037
  const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
839
- const completed = await new RoundDriver(session.engine, this.backend).complete({
1038
+ const completed = await new RoundDriver(session.runtime, this.backend).complete({
840
1039
  snapshot,
841
1040
  loopId: session.loopId,
842
1041
  task: session.task,
843
1042
  maxRounds: session.maxRounds,
844
- selfEval,
845
- extractionSucceeded: !extractionFailed,
846
- lastSelfEval: session.lastSelfEval,
1043
+ roundReport,
1044
+ previousProjection: session.lastRoundProjection,
847
1045
  consecutiveRejections: session.consecutiveRejections,
848
1046
  successTrajectory: session.successTrajectory,
849
1047
  driftClarificationStreak: session.driftClarificationStreak,
1048
+ escalatedRejectionChecks: session.escalatedRejectionChecks,
850
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
+ : [],
851
1059
  });
852
1060
  const outcome = completed.outcome;
853
1061
  const actualEvidence = completed.actualEvidence;
@@ -857,25 +1065,23 @@ export class SessionManager {
857
1065
  // v2.12: Clarification streak tracking — independent of rejection tracking.
858
1066
  // Reset on rounds without intent_drift; track substantive vs weak clarifications.
859
1067
  this.updateClarificationStreak(session, pr);
860
- // Per-rule rejection tracking: same-check rejections accumulate;
861
- // a different rejection reason resets the counter.
862
- if (pr.action === "reject" && pr.rejectionCheck) {
863
- session.consecutiveRejections =
864
- pr.rejectionCheck === session.lastRejectionCheck
865
- ? pr.newConsecutiveRejections
866
- : 1;
867
- session.lastRejectionCheck = pr.rejectionCheck;
868
- }
869
- else {
870
- session.consecutiveRejections = pr.newConsecutiveRejections;
871
- if (pr.action !== "reject")
872
- session.lastRejectionCheck = "";
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;
873
1076
  }
874
- if (pr.newLastSelfEval)
875
- session.lastSelfEval = pr.newLastSelfEval;
1077
+ if (pr.newRoundProjection)
1078
+ session.lastRoundProjection = pr.newRoundProjection;
876
1079
  if (pr.shouldPushSuccessTrajectory) {
877
1080
  session.successTrajectory.push(pr.roundSuccess);
878
1081
  }
1082
+ if (pr.action === "continue" || pr.action === "stop") {
1083
+ session.currentCanonicalState = undefined;
1084
+ }
879
1085
  return { pr, verificationFlags: pr.verificationFlags, actualEvidence };
880
1086
  }
881
1087
  /** v2.12: Update the clarification streak based on the round result.
@@ -892,9 +1098,9 @@ export class SessionManager {
892
1098
  session.driftClarificationStreak = 0;
893
1099
  return;
894
1100
  }
895
- // v2.12: Clarification accepted with anchors → substantive pivot.
896
- // Keep streak as-is (don't increment for genuine explanations).
1101
+ // A runtime-verified clarification closes the drift episode.
897
1102
  if (pr.clarificationAccepted) {
1103
+ session.driftClarificationStreak = 0;
898
1104
  return;
899
1105
  }
900
1106
  // R7 rejected (weak or no clarification). The enforcement gate
@@ -902,28 +1108,43 @@ export class SessionManager {
902
1108
  // Increment the persisted counter so crash recovery sees it.
903
1109
  session.driftClarificationStreak += 1;
904
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
+ }
905
1125
  /** Build a rejection result: compile a retry prompt, persist, return.
906
1126
  * MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
907
1127
  async buildRejectionResult(sessionId, session, pr, verificationFlags) {
908
1128
  const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
909
- 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);
910
1130
  if (!preparedRetry) {
911
- session.status = "stalled";
912
- session.currentPrompt = null;
913
- this.save(session);
914
1131
  return {
915
1132
  sessionId,
916
1133
  round: session.currentRound,
917
1134
  roundId: session.roundSnapshot?.roundId,
918
1135
  prompt: null,
919
- stopReason: "stalled",
920
- 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 },
921
1139
  };
922
1140
  }
923
1141
  session.roundSnapshot = preparedRetry.snapshot;
924
1142
  session.currentPrompt = preparedRetry.prompt;
925
1143
  session.currentLevel = preparedRetry.level;
926
1144
  session.currentWarnings = preparedRetry.warnings ?? [];
1145
+ session.currentCanonicalState = isRecord(preparedRetry.canonicalState)
1146
+ ? preparedRetry.canonicalState
1147
+ : undefined;
927
1148
  this.save(session);
928
1149
  return {
929
1150
  sessionId,
@@ -941,14 +1162,15 @@ export class SessionManager {
941
1162
  * backtrack prompt at the top.
942
1163
  * MUTATES: session.currentRound, session.roundSnapshot,
943
1164
  * session.currentPrompt, session.currentLevel,
944
- * session.consecutiveRejections, session.lastSelfEval */
1165
+ * session.consecutiveRejections, session.lastRoundProjection */
945
1166
  async buildBacktrackResult(sessionId, session, pr) {
946
- const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
1167
+ const skippedUntilRound = session.currentRound;
1168
+ const restoreTarget = pr.backtrackTarget ?? (skippedUntilRound - 1);
947
1169
  const newRound = restoreTarget + 1;
948
1170
  logEvent("session_backtrack", {
949
1171
  sessionId,
950
1172
  loopId: session.loopId,
951
- fromRound: session.currentRound,
1173
+ fromRound: skippedUntilRound,
952
1174
  toRound: newRound,
953
1175
  triggerRule: pr.backtrackTriggerRule ?? "",
954
1176
  skippedDiscoveries: (pr.backtrackSkippedDiscoveries ?? []).length,
@@ -956,11 +1178,28 @@ export class SessionManager {
956
1178
  // Reset session to the restored round
957
1179
  session.currentRound = newRound;
958
1180
  session.consecutiveRejections = 0;
959
- session.lastRejectionCheck = "";
1181
+ session.escalatedRejectionChecks = [];
960
1182
  session.driftClarificationStreak = 0;
961
1183
  session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
962
- session.lastSelfEval = undefined; // force L2 recompile from vault state
1184
+ session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
1185
+ session.lastRoundProjection = undefined; // force L2 recompile from vault state
963
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
+ });
964
1203
  // Preserve discoveries from skipped rounds as constraints_from_plan
965
1204
  // so they survive the compilation and re-enter active constraints.
966
1205
  const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
@@ -968,7 +1207,6 @@ export class SessionManager {
968
1207
  const request = {
969
1208
  task: session.task,
970
1209
  mode: Mode.LOOP_COMPILE,
971
- feedback: null,
972
1210
  skill_name: null,
973
1211
  task_id: null,
974
1212
  loop_id: session.loopId,
@@ -977,19 +1215,24 @@ export class SessionManager {
977
1215
  verification_flags: pr.verificationFlags,
978
1216
  constraints_from_plan: preservedDiscoveries,
979
1217
  };
980
- const prepared = await new RoundDriver(session.engine, this.backend).prepare(request, session.loopId, newRound);
1218
+ const prepared = await new RoundDriver(session.runtime, this.backend).prepare(request, session.loopId, newRound);
981
1219
  if (!prepared || !prepared.prompt) {
982
- session.status = "stalled";
983
- session.currentPrompt = null;
984
- this.save(session);
985
1220
  return {
986
1221
  sessionId,
987
1222
  round: newRound,
988
1223
  prompt: null,
989
- stopReason: "stalled",
990
- stopDetail: "Backtrack failed: RoundDriver.prepare returned null — could not compile from restore point.",
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 },
991
1227
  enforcementAction: "backtrack",
992
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
+ ],
993
1236
  };
994
1237
  }
995
1238
  // Inject backtrack prompt at the top of the compiled prompt
@@ -1002,6 +1245,9 @@ export class SessionManager {
1002
1245
  session.currentPrompt = fullPrompt;
1003
1246
  session.currentLevel = prepared.level;
1004
1247
  session.currentWarnings = prepared?.warnings ?? [];
1248
+ session.currentCanonicalState = isRecord(prepared.canonicalState)
1249
+ ? prepared.canonicalState
1250
+ : undefined;
1005
1251
  this.save(session);
1006
1252
  return {
1007
1253
  sessionId,
@@ -1012,6 +1258,13 @@ export class SessionManager {
1012
1258
  enforcementAction: "backtrack",
1013
1259
  enforcementReason: pr.enforcementReason,
1014
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
+ ],
1015
1268
  };
1016
1269
  }
1017
1270
  /** Build a termination result: persist stopped status, notify sinks.
@@ -1020,6 +1273,11 @@ export class SessionManager {
1020
1273
  session.status = "stopped";
1021
1274
  session.currentPrompt = null;
1022
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
+ });
1023
1281
  void this.notifyTerminal(session, "enforcement_terminated");
1024
1282
  logEvent("session_end", {
1025
1283
  sessionId, loopId: session.loopId,
@@ -1043,6 +1301,8 @@ export class SessionManager {
1043
1301
  session.status = reason === "stalled" ? "stalled" : "stopped";
1044
1302
  session.currentPrompt = null;
1045
1303
  this.save(session);
1304
+ // Durable terminal event after the session_state write (A4).
1305
+ this.persistTerminalEvent(session, reason);
1046
1306
  void this.notifyTerminal(session, reason);
1047
1307
  logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
1048
1308
  return {
@@ -1050,7 +1310,7 @@ export class SessionManager {
1050
1310
  roundId: session.roundSnapshot?.roundId,
1051
1311
  prompt: null, stopReason: reason,
1052
1312
  stopDetail: pr.stopReason === "circuit_breaker"
1053
- ? `${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."
1054
1314
  : pr.stopReason === "max_rounds"
1055
1315
  ? "The configured maximum number of rounds has been reached."
1056
1316
  : `Loop stopped: ${reason}.`,
@@ -1061,28 +1321,25 @@ export class SessionManager {
1061
1321
  * Includes the commit fence (pause/delete race guard) and context provider.
1062
1322
  * MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
1063
1323
  * session.evidenceBaseline, session.roundSnapshot */
1064
- async advanceToNextRound(sessionId, session, selfEval, pr, verificationFlags, actualEvidence) {
1324
+ async advanceToNextRound(sessionId, session, pr, verificationFlags, actualEvidence) {
1065
1325
  const roundSuccess = pr.roundSuccess;
1066
- session.currentRound++;
1067
- session.currentPrompt = null;
1068
- // v2.13: Clear backtrack skipped files after advancing past the restore round
1069
- session.backtrackSkippedFiles = [];
1326
+ const nextRound = session.currentRound + 1;
1070
1327
  // v1.8: Memory injection — tier-aware external context.
1071
1328
  let externalCtx = "";
1072
1329
  if (this.contextProvider) {
1073
1330
  try {
1074
1331
  externalCtx = (await this.contextProvider({
1075
1332
  loopId: session.loopId,
1076
- round: session.currentRound,
1333
+ round: nextRound,
1077
1334
  task: session.task,
1078
1335
  domain: "",
1079
- lastEvaluation: selfEval,
1336
+ lastRoundView: session.lastRoundProjection,
1080
1337
  })).trim();
1081
1338
  }
1082
1339
  catch {
1083
1340
  logEvent("context_provider_error", {
1084
1341
  loopId: session.loopId,
1085
- round: session.currentRound,
1342
+ round: nextRound,
1086
1343
  });
1087
1344
  }
1088
1345
  }
@@ -1098,19 +1355,39 @@ export class SessionManager {
1098
1355
  roundSuccess,
1099
1356
  };
1100
1357
  }
1101
- const request = buildLoopRequest(session, selfEval, verificationFlags);
1358
+ const request = buildLoopRequest({ ...session, currentRound: nextRound }, session.lastRoundProjection, verificationFlags);
1102
1359
  if (externalCtx) {
1103
1360
  request.external_context = externalCtx;
1104
1361
  }
1105
- const prepared = await new RoundDriver(session.engine, this.backend).prepare(request, session.loopId, session.currentRound);
1106
- const nextPrompt = prepared?.prompt ?? null;
1107
- const nextLevel = prepared?.level ?? "l2";
1108
- 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;
1109
1383
  session.evidenceBaseline = nextBaseline;
1110
1384
  session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
1111
1385
  session.currentPrompt = nextPrompt;
1112
1386
  session.currentLevel = nextLevel;
1113
1387
  session.currentWarnings = prepared?.warnings ?? [];
1388
+ session.currentCanonicalState = isRecord(prepared?.canonicalState)
1389
+ ? prepared.canonicalState
1390
+ : undefined;
1114
1391
  policyMetrics.recordStrategy(session.loopId, nextLevel);
1115
1392
  this.save(session);
1116
1393
  return {
@@ -1123,7 +1400,7 @@ export class SessionManager {
1123
1400
  warnings: prepared?.warnings ?? [],
1124
1401
  };
1125
1402
  }
1126
- async advanceUnlocked(sessionId, output, preExtractedEval) {
1403
+ async advanceUnlocked(sessionId, output, roundReport) {
1127
1404
  // ── 1. Validate session ──────────────────────────────────────────────
1128
1405
  const session = this.sessions.get(sessionId);
1129
1406
  if (!session)
@@ -1131,18 +1408,25 @@ export class SessionManager {
1131
1408
  if (session.status !== "running") {
1132
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.` };
1133
1410
  }
1134
- // ── 2. Extract self-evaluation ───────────────────────────────────────
1135
- const extracted = this.extractEvaluation(output, preExtractedEval);
1136
- if (!extracted) {
1137
- session.status = "stalled";
1138
- this.save(session);
1139
- void this.notifyTerminal(session, "stalled");
1140
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
1141
- 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
+ };
1142
1427
  }
1143
- const { selfEval, extractionFailed } = extracted;
1144
1428
  // ── 3. Execute round transaction ─────────────────────────────────────
1145
- const tx = await this.executeRoundTransaction(session, selfEval, extractionFailed);
1429
+ const tx = await this.executeRoundTransaction(session, roundReport);
1146
1430
  // ── 4. Route disposition ─────────────────────────────────────────────
1147
1431
  if (tx.pr.action === "reject") {
1148
1432
  return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags);
@@ -1161,7 +1445,7 @@ export class SessionManager {
1161
1445
  return this.buildStopResult(sessionId, session, tx.pr);
1162
1446
  }
1163
1447
  // ── 5. Continue — compile next round ─────────────────────────────────
1164
- return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.verificationFlags, tx.actualEvidence);
1448
+ return this.advanceToNextRound(sessionId, session, tx.pr, tx.verificationFlags, tx.actualEvidence);
1165
1449
  }
1166
1450
  /** Write back loop knowledge to long-term memory.
1167
1451
  * Uses shared base builder from policy.ts. Called when a loop terminates. */
@@ -1175,7 +1459,7 @@ export class SessionManager {
1175
1459
  stopReason: stopReason,
1176
1460
  roundsCompleted: session.currentRound,
1177
1461
  successTrajectory: [...session.successTrajectory],
1178
- lastEvaluation: session.lastSelfEval,
1462
+ lastRoundView: session.lastRoundProjection,
1179
1463
  };
1180
1464
  await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
1181
1465
  }
@@ -1185,7 +1469,22 @@ export class SessionManager {
1185
1469
  if (!session)
1186
1470
  return null;
1187
1471
  const replay = new ReplayBackend(this.backend);
1188
- 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 };
1189
1488
  }
1190
1489
  }
1191
1490
  //# sourceMappingURL=session.js.map