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