loopforge 1.12.0 → 2.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 (182) hide show
  1. package/README.md +172 -95
  2. package/dist/backends/interface.d.ts +3 -9
  3. package/dist/backends/interface.d.ts.map +1 -1
  4. package/dist/backends/interface.js +1 -5
  5. package/dist/backends/interface.js.map +1 -1
  6. package/dist/canonical-state.d.ts +53 -0
  7. package/dist/canonical-state.d.ts.map +1 -0
  8. package/dist/canonical-state.js +161 -0
  9. package/dist/canonical-state.js.map +1 -0
  10. package/dist/cli.d.ts +4 -0
  11. package/dist/cli.d.ts.map +1 -0
  12. package/dist/cli.js +227 -0
  13. package/dist/cli.js.map +1 -0
  14. package/dist/enforcement-gate.d.ts +44 -0
  15. package/dist/enforcement-gate.d.ts.map +1 -0
  16. package/dist/enforcement-gate.js +271 -0
  17. package/dist/enforcement-gate.js.map +1 -0
  18. package/dist/engine.d.ts +17 -7
  19. package/dist/engine.d.ts.map +1 -1
  20. package/dist/engine.js +158 -249
  21. package/dist/engine.js.map +1 -1
  22. package/dist/evidence-provider.d.ts +113 -0
  23. package/dist/evidence-provider.d.ts.map +1 -0
  24. package/dist/evidence-provider.js +419 -0
  25. package/dist/evidence-provider.js.map +1 -0
  26. package/dist/index.d.ts +19 -11
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +16 -10
  29. package/dist/index.js.map +1 -1
  30. package/dist/init.d.ts +15 -0
  31. package/dist/init.d.ts.map +1 -0
  32. package/dist/init.js +49 -0
  33. package/dist/init.js.map +1 -0
  34. package/dist/interop.d.ts +30 -0
  35. package/dist/interop.d.ts.map +1 -0
  36. package/dist/interop.js +29 -0
  37. package/dist/interop.js.map +1 -0
  38. package/dist/loop-compiler.d.ts +14 -64
  39. package/dist/loop-compiler.d.ts.map +1 -1
  40. package/dist/loop-compiler.js +307 -2001
  41. package/dist/loop-compiler.js.map +1 -1
  42. package/dist/loop-store.d.ts +82 -0
  43. package/dist/loop-store.d.ts.map +1 -0
  44. package/dist/loop-store.js +332 -0
  45. package/dist/loop-store.js.map +1 -0
  46. package/dist/mcp/server.d.ts +9 -6
  47. package/dist/mcp/server.d.ts.map +1 -1
  48. package/dist/mcp/server.js +109 -73
  49. package/dist/mcp/server.js.map +1 -1
  50. package/dist/mcp/session.d.ts +84 -20
  51. package/dist/mcp/session.d.ts.map +1 -1
  52. package/dist/mcp/session.js +718 -316
  53. package/dist/mcp/session.js.map +1 -1
  54. package/dist/mcp/tools.d.ts +18 -268
  55. package/dist/mcp/tools.d.ts.map +1 -1
  56. package/dist/mcp/tools.js +225 -12
  57. package/dist/mcp/tools.js.map +1 -1
  58. package/dist/observability.d.ts +36 -16
  59. package/dist/observability.d.ts.map +1 -1
  60. package/dist/observability.js +82 -23
  61. package/dist/observability.js.map +1 -1
  62. package/dist/policy-metrics.d.ts +46 -0
  63. package/dist/policy-metrics.d.ts.map +1 -0
  64. package/dist/policy-metrics.js +146 -0
  65. package/dist/policy-metrics.js.map +1 -0
  66. package/dist/policy.d.ts +58 -75
  67. package/dist/policy.d.ts.map +1 -1
  68. package/dist/policy.js +122 -93
  69. package/dist/policy.js.map +1 -1
  70. package/dist/prompt-assembler.d.ts +30 -0
  71. package/dist/prompt-assembler.d.ts.map +1 -0
  72. package/dist/prompt-assembler.js +257 -0
  73. package/dist/prompt-assembler.js.map +1 -0
  74. package/dist/prompt-policy.d.ts +29 -0
  75. package/dist/prompt-policy.d.ts.map +1 -0
  76. package/dist/prompt-policy.js +51 -0
  77. package/dist/prompt-policy.js.map +1 -0
  78. package/dist/protocol.d.ts +117 -91
  79. package/dist/protocol.d.ts.map +1 -1
  80. package/dist/protocol.js +40 -36
  81. package/dist/protocol.js.map +1 -1
  82. package/dist/replay.d.ts.map +1 -1
  83. package/dist/replay.js +1 -27
  84. package/dist/replay.js.map +1 -1
  85. package/dist/round-coordinator.d.ts +90 -0
  86. package/dist/round-coordinator.d.ts.map +1 -0
  87. package/dist/round-coordinator.js +186 -0
  88. package/dist/round-coordinator.js.map +1 -0
  89. package/dist/round-driver.d.ts +53 -0
  90. package/dist/round-driver.d.ts.map +1 -0
  91. package/dist/round-driver.js +101 -0
  92. package/dist/round-driver.js.map +1 -0
  93. package/dist/round-transaction.d.ts +70 -0
  94. package/dist/round-transaction.d.ts.map +1 -0
  95. package/dist/round-transaction.js +290 -0
  96. package/dist/round-transaction.js.map +1 -0
  97. package/dist/runtime.d.ts +35 -17
  98. package/dist/runtime.d.ts.map +1 -1
  99. package/dist/runtime.js +357 -344
  100. package/dist/runtime.js.map +1 -1
  101. package/dist/storage.d.ts +38 -0
  102. package/dist/storage.d.ts.map +1 -0
  103. package/dist/storage.js +201 -0
  104. package/dist/storage.js.map +1 -0
  105. package/dist/verification-gate.d.ts +39 -7
  106. package/dist/verification-gate.d.ts.map +1 -1
  107. package/dist/verification-gate.js +198 -9
  108. package/dist/verification-gate.js.map +1 -1
  109. package/loop_policy.json +26 -47
  110. package/package.json +13 -10
  111. package/skills/perception/SKILL.md +80 -249
  112. package/dist/backends/fs.d.ts +0 -36
  113. package/dist/backends/fs.d.ts.map +0 -1
  114. package/dist/backends/fs.js +0 -358
  115. package/dist/backends/fs.js.map +0 -1
  116. package/dist/builder.d.ts +0 -12
  117. package/dist/builder.d.ts.map +0 -1
  118. package/dist/builder.js +0 -259
  119. package/dist/builder.js.map +0 -1
  120. package/dist/mcp-server.d.ts +0 -12
  121. package/dist/mcp-server.d.ts.map +0 -1
  122. package/dist/mcp-server.js +0 -14
  123. package/dist/mcp-server.js.map +0 -1
  124. package/dist/memory-bridge.d.ts +0 -47
  125. package/dist/memory-bridge.d.ts.map +0 -1
  126. package/dist/memory-bridge.js +0 -290
  127. package/dist/memory-bridge.js.map +0 -1
  128. package/dist/tests/_helpers.d.ts +0 -25
  129. package/dist/tests/_helpers.d.ts.map +0 -1
  130. package/dist/tests/_helpers.js +0 -47
  131. package/dist/tests/_helpers.js.map +0 -1
  132. package/dist/tests/builder.test.d.ts +0 -2
  133. package/dist/tests/builder.test.d.ts.map +0 -1
  134. package/dist/tests/builder.test.js +0 -110
  135. package/dist/tests/builder.test.js.map +0 -1
  136. package/dist/tests/engine.test.d.ts +0 -2
  137. package/dist/tests/engine.test.d.ts.map +0 -1
  138. package/dist/tests/engine.test.js +0 -371
  139. package/dist/tests/engine.test.js.map +0 -1
  140. package/dist/tests/generate-schema.test.d.ts +0 -8
  141. package/dist/tests/generate-schema.test.d.ts.map +0 -1
  142. package/dist/tests/generate-schema.test.js +0 -199
  143. package/dist/tests/generate-schema.test.js.map +0 -1
  144. package/dist/tests/loop-compiler.test.d.ts +0 -2
  145. package/dist/tests/loop-compiler.test.d.ts.map +0 -1
  146. package/dist/tests/loop-compiler.test.js +0 -1051
  147. package/dist/tests/loop-compiler.test.js.map +0 -1
  148. package/dist/tests/mcp.test.d.ts +0 -7
  149. package/dist/tests/mcp.test.d.ts.map +0 -1
  150. package/dist/tests/mcp.test.js +0 -485
  151. package/dist/tests/mcp.test.js.map +0 -1
  152. package/dist/tests/memory-bridge.test.d.ts +0 -8
  153. package/dist/tests/memory-bridge.test.d.ts.map +0 -1
  154. package/dist/tests/memory-bridge.test.js +0 -383
  155. package/dist/tests/memory-bridge.test.js.map +0 -1
  156. package/dist/tests/policy.test.d.ts +0 -2
  157. package/dist/tests/policy.test.d.ts.map +0 -1
  158. package/dist/tests/policy.test.js +0 -49
  159. package/dist/tests/policy.test.js.map +0 -1
  160. package/dist/tests/protocol.test.d.ts +0 -2
  161. package/dist/tests/protocol.test.d.ts.map +0 -1
  162. package/dist/tests/protocol.test.js +0 -73
  163. package/dist/tests/protocol.test.js.map +0 -1
  164. package/dist/tests/replay.test.d.ts +0 -2
  165. package/dist/tests/replay.test.d.ts.map +0 -1
  166. package/dist/tests/replay.test.js +0 -222
  167. package/dist/tests/replay.test.js.map +0 -1
  168. package/dist/tests/runtime.test.d.ts +0 -7
  169. package/dist/tests/runtime.test.d.ts.map +0 -1
  170. package/dist/tests/runtime.test.js +0 -323
  171. package/dist/tests/runtime.test.js.map +0 -1
  172. package/dist/tests/verification-gate.test.d.ts +0 -2
  173. package/dist/tests/verification-gate.test.d.ts.map +0 -1
  174. package/dist/tests/verification-gate.test.js +0 -407
  175. package/dist/tests/verification-gate.test.js.map +0 -1
  176. package/skills/prompt-techniques/SKILL.md +0 -52
  177. package/skills/prompt-techniques/references/chain-of-thought.md +0 -207
  178. package/skills/prompt-techniques/references/few-shot.md +0 -174
  179. package/skills/prompt-techniques/references/least-to-most.md +0 -182
  180. package/skills/prompt-techniques/references/step-back.md +0 -160
  181. package/skills/prompt-techniques/references/tree-of-thought.md +0 -191
  182. package/skills/prompt-techniques/references/zero-shot.md +0 -97
@@ -6,25 +6,30 @@
6
6
  */
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { LoopForgeEngine, extractSelfEvaluation, heuristicSelfEvaluation } from "../engine.js";
9
- import { checkLoopHealth, tokenize, jaccard } from "../loop-compiler.js";
10
- import { getPolicy, resolveAllowedPhases } from "../policy.js";
11
- import { Mode, makeLoopCompileRequest, makeVaultConfig } from "../protocol.js";
9
+ import { checkLoopHealth } from "../loop-compiler.js";
10
+ import { getPolicy } from "../policy.js";
11
+ import { Mode, makeLoopCompileRequest } from "../protocol.js";
12
12
  import { ReplayBackend } from "../replay.js";
13
- import { FSBackend } from "../backends/fs.js";
14
- import { verifySelfEvaluation } from "../verification-gate.js";
13
+ import { FileLoopStore, LoopStoreBackend } from "../loop-store.js";
14
+ import { EvidenceCollector } from "../evidence-provider.js";
15
+ import { parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
16
+ import { RoundDriver } from "../round-driver.js";
15
17
  import { logEvent } from "../observability.js";
18
+ import { policyMetrics } from "../policy-metrics.js";
19
+ import { SessionLeaseConflictError, VaultSessionStateStore, } from "../storage.js";
20
+ import { createCognitiveCheckpoint } from "../interop.js";
16
21
  // ── Helpers ────────────────────────────────────────────────────────────────
17
22
  function buildLoopRequest(session, lastEval, _lastQuality, // deprecated — kept for backward compat
18
23
  verificationFlags) {
19
24
  const req = {
20
25
  task: session.task,
21
26
  mode: Mode.LOOP_COMPILE,
22
- vault_config: makeVaultConfig(),
23
27
  feedback: null,
24
28
  skill_name: null,
25
29
  task_id: null,
26
30
  loop_id: session.loopId,
27
31
  round: session.currentRound,
32
+ max_rounds: session.maxRounds,
28
33
  verification_flags: verificationFlags ?? [],
29
34
  };
30
35
  if (lastEval) {
@@ -55,19 +60,12 @@ verificationFlags) {
55
60
  // v1.10: Checkpoint boundary
56
61
  compression_checkpoint: lastEval.compression_checkpoint ?? false,
57
62
  checkpoint_label: lastEval.checkpoint_label ?? "",
63
+ // v1.16: Agent's declared next action
64
+ next_action: lastEval.next_action,
58
65
  };
59
66
  }
60
67
  return req;
61
68
  }
62
- function parseLevel(rationale) {
63
- if (!rationale)
64
- return "l2";
65
- if (rationale.includes("l0"))
66
- return "l0";
67
- if (rationale.includes("l1"))
68
- return "l1";
69
- return "l2";
70
- }
71
69
  function parseWarnings(prompt) {
72
70
  if (!prompt)
73
71
  return [];
@@ -85,76 +83,166 @@ function parseWarnings(prompt) {
85
83
  // ── SessionManager ─────────────────────────────────────────────────────────
86
84
  export class SessionManager {
87
85
  sessions = new Map();
86
+ /** Serializes state transitions for each session. */
87
+ sessionQueues = new Map();
88
88
  backend;
89
- /** Optional provider for long-term memory context retrieval. */
90
- memoryProvider;
91
- /** Optional writer for persisting loop knowledge back to long-term memory. */
92
- memoryWriter;
93
- constructor(backend) {
94
- this.backend = backend;
89
+ sessionStore;
90
+ ownerId = `${process.pid}:${randomUUID()}`;
91
+ leaseMs;
92
+ leaseRenewIntervalMs;
93
+ leaseTimer = null;
94
+ checkpointSinks = new Set();
95
+ /** Explicit context provider; never auto-discovered. */
96
+ contextProvider;
97
+ terminalSinks = new Set();
98
+ constructor(storeOrBackend, sessionStore) {
99
+ const resolvedBackend = storeOrBackend
100
+ ? "readSession" in storeOrBackend
101
+ ? new LoopStoreBackend(storeOrBackend)
102
+ : storeOrBackend
103
+ : new LoopStoreBackend(new FileLoopStore(getPolicy().backend.root_dir));
104
+ this.backend = resolvedBackend;
105
+ this.sessionStore = sessionStore ?? new VaultSessionStateStore(resolvedBackend);
106
+ const mcpPolicy = getPolicy().mcp;
107
+ this.leaseMs = Math.max(1, mcpPolicy.session_lease_ms);
108
+ this.leaseRenewIntervalMs = Math.max(1, Math.min(mcpPolicy.session_lease_renew_interval_ms, this.leaseMs));
109
+ if (this.sessionStore?.renewLease) {
110
+ this.leaseTimer = setInterval(() => this.renewOwnedLeases(), this.leaseRenewIntervalMs);
111
+ this.leaseTimer.unref?.();
112
+ }
113
+ }
114
+ /** Stable process-local owner token used for cross-process session leases. */
115
+ getOwnerId() {
116
+ return this.ownerId;
117
+ }
118
+ /** Subscribe an external checkpointer; sink failures are isolated. */
119
+ addCheckpointSink(sink) {
120
+ this.checkpointSinks.add(sink);
121
+ return () => this.checkpointSinks.delete(sink);
122
+ }
123
+ addTerminalSink(sink) {
124
+ this.terminalSinks.add(sink);
125
+ return () => this.terminalSinks.delete(sink);
126
+ }
127
+ /** Release owned sessions and stop lease maintenance. */
128
+ close() {
129
+ if (this.leaseTimer)
130
+ clearInterval(this.leaseTimer);
131
+ this.leaseTimer = null;
132
+ for (const session of this.sessions.values()) {
133
+ this.sessionStore?.releaseLease?.(session.loopId, this.ownerId);
134
+ }
135
+ }
136
+ findSessionEntry(loopId) {
137
+ return this.sessionStore?.load(loopId);
138
+ }
139
+ claimSessionEntry(loopId) {
140
+ if (this.sessionStore?.acquireLease) {
141
+ return this.sessionStore.acquireLease(loopId, this.ownerId, this.leaseMs);
142
+ }
143
+ return this.findSessionEntry(loopId);
144
+ }
145
+ renewSessionLease(loopId) {
146
+ if (!this.sessionStore?.renewLease)
147
+ return true;
148
+ try {
149
+ return this.sessionStore.renewLease(loopId, this.ownerId, this.leaseMs);
150
+ }
151
+ catch {
152
+ return false;
153
+ }
154
+ }
155
+ renewOwnedLeases() {
156
+ for (const session of this.sessions.values()) {
157
+ if (session.status === "running")
158
+ this.renewSessionLease(session.loopId);
159
+ }
160
+ }
161
+ leaseConflictResult(loopId, entry) {
162
+ const lineage = (entry?.loop_lineage ?? {});
163
+ return {
164
+ sessionId: "",
165
+ round: typeof lineage.current_round === "number" ? lineage.current_round : 0,
166
+ prompt: null,
167
+ stopReason: `session_owned_elsewhere:${loopId}`,
168
+ };
169
+ }
170
+ async withSessionQueue(sessionId, work) {
171
+ const previous = this.sessionQueues.get(sessionId) ?? Promise.resolve();
172
+ let release;
173
+ const gate = new Promise((resolve) => {
174
+ release = resolve;
175
+ });
176
+ const tail = previous.then(() => gate);
177
+ this.sessionQueues.set(sessionId, tail);
178
+ await previous;
179
+ try {
180
+ return await work();
181
+ }
182
+ finally {
183
+ release();
184
+ if (this.sessionQueues.get(sessionId) === tail) {
185
+ this.sessionQueues.delete(sessionId);
186
+ }
187
+ }
95
188
  }
96
189
  async create(input) {
97
190
  const sessionId = randomUUID();
98
191
  const loopId = input.loopId ?? randomUUID();
99
- const engine = new LoopForgeEngine("skills", this.backend);
192
+ const engine = new LoopForgeEngine(this.backend);
100
193
  const maxRounds = input.maxRounds ?? getPolicy().runtime.max_rounds;
101
194
  // Populate extra fields for the first round
102
195
  const request = buildLoopRequest({
103
196
  sessionId, loopId, task: input.task, engine, currentRound: 1,
104
197
  maxRounds, successTrajectory: [], status: "running", createdAt: Date.now(),
105
- injectionCount: 0, lastInjectionRound: 0, injectedContexts: [],
106
- phase2Triggered: false, phase3Triggered: false,
198
+ consecutiveRejections: 0,
199
+ lastRejectionCheck: "",
200
+ evidenceBaseline: [],
107
201
  });
108
202
  request.domain = input.domain ?? "";
109
203
  request.plan_source = input.planSource ?? null;
110
204
  request.constraints_from_plan = input.constraints ?? [];
111
- // v1.8: Phase 1 memory injection (Round 1) — only if allowed by tier
112
- const miPolicy = getPolicy().memory_injection;
113
- const allowedPhases = new Set(resolveAllowedPhases(maxRounds, miPolicy.round_tiers));
114
- if (miPolicy.enabled && this.memoryProvider && allowedPhases.has(1)) {
205
+ // Explicit context is requested by the MCP embedding; no auto-discovery.
206
+ if (this.contextProvider) {
115
207
  try {
116
208
  const ctx = {
117
209
  loopId,
118
210
  round: 1,
119
211
  task: input.task,
120
212
  domain: input.domain ?? "",
121
- phase: 1,
122
- progressEstimate: 0,
123
- accumulatedContext: {
124
- recurringIssues: [],
125
- failedPatterns: [],
126
- keyLessons: [],
127
- remainingCriteria: [],
128
- },
129
213
  };
130
- const rawContext = await this.memoryProvider(ctx);
214
+ const rawContext = await this.contextProvider(ctx);
131
215
  if (rawContext?.trim()) {
132
- request.external_context = rawContext.trim().slice(0, miPolicy.max_context_length);
133
- logEvent("memory_injected", {
134
- loopId, round: 1, phase: 1, injectionCount: 1,
216
+ request.external_context = rawContext.trim();
217
+ logEvent("external_context_loaded", {
218
+ loopId, round: 1,
135
219
  contextLength: request.external_context.length,
136
220
  });
137
221
  }
138
222
  }
139
223
  catch {
140
- // memoryProvider failed — degrade gracefully
141
- logEvent("memory_provider_error", { loopId, round: 1 });
224
+ // Provider failures are isolated from session creation.
225
+ logEvent("context_provider_error", { loopId, round: 1 });
142
226
  }
143
227
  }
144
- const result = engine.invokeLoopCompile(request);
145
- const injectedCtx = request.external_context;
228
+ const prepared = await new RoundDriver(engine, this.backend).prepare(request, loopId, 1);
229
+ const initialPrompt = prepared?.prompt ?? null;
230
+ const initialLevel = prepared?.level ?? "l2";
231
+ const evidenceBaseline = prepared?.evidenceBaseline ?? [];
146
232
  const session = {
147
233
  sessionId, loopId, task: input.task, engine,
148
234
  currentRound: 1, maxRounds, successTrajectory: [],
149
235
  status: "running", createdAt: Date.now(),
150
- // v1.7: Memory integration state
151
- injectionCount: injectedCtx ? 1 : 0,
152
- lastInjectionRound: injectedCtx ? 1 : 0,
153
- injectedContexts: injectedCtx ? [injectedCtx] : [],
154
- phase2Triggered: false,
155
- phase3Triggered: false,
236
+ // v1.13: Enforcement gate state
237
+ consecutiveRejections: 0,
238
+ lastRejectionCheck: "",
239
+ evidenceBaseline,
240
+ roundSnapshot: prepared?.snapshot ?? prepareRoundTransaction(loopId, 1, evidenceBaseline),
241
+ currentPrompt: initialPrompt,
242
+ currentLevel: initialLevel,
156
243
  };
157
244
  this.sessions.set(sessionId, session);
245
+ policyMetrics.recordStrategy(loopId, initialLevel);
158
246
  // Persist to vault for cross-process recovery
159
247
  this.save(session);
160
248
  logEvent("session_start", {
@@ -166,97 +254,382 @@ export class SessionManager {
166
254
  return {
167
255
  sessionId,
168
256
  round: 1,
169
- prompt: result.response?.prompt ?? null,
170
- technique: result.response?.analysis?.technique ?? "zero-shot",
171
- level: parseLevel(result.response?.analysis?.rationale),
257
+ roundId: session.roundSnapshot?.roundId,
258
+ prompt: initialPrompt,
259
+ level: initialLevel,
172
260
  roundSuccess: false,
173
261
  quality: 0,
174
- warnings: parseWarnings(result.response?.prompt ?? null),
262
+ warnings: parseWarnings(initialPrompt),
175
263
  };
176
264
  }
177
265
  get(sessionId) {
178
266
  return this.sessions.get(sessionId);
179
267
  }
268
+ getLeaseStatus(loopId) {
269
+ const entry = this.findSessionEntry(loopId);
270
+ if (!entry)
271
+ return null;
272
+ const lineage = (entry.loop_lineage ?? {});
273
+ const owner = typeof lineage.lease_owner === "string"
274
+ ? lineage.lease_owner
275
+ : "";
276
+ const ownerPid = owner.match(/^(\d+):/)?.[1];
277
+ return {
278
+ ownedByThisProcess: owner === this.ownerId,
279
+ ownerPid: ownerPid ? Number(ownerPid) : null,
280
+ expiresAt: typeof lineage.lease_expires_at === "number"
281
+ ? new Date(lineage.lease_expires_at).toISOString()
282
+ : null,
283
+ epoch: typeof lineage.lease_epoch === "number"
284
+ ? lineage.lease_epoch
285
+ : 0,
286
+ };
287
+ }
180
288
  delete(sessionId) {
181
289
  const session = this.sessions.get(sessionId);
182
290
  if (!session)
183
291
  return false;
184
292
  session.status = "stopped";
185
- void this.doWriteback(session, "stopped");
293
+ this.save(session); // Persist stopped status to vault so autoResumeAll won't resurrect
294
+ void this.notifyTerminal(session, "cancelled");
186
295
  this.sessions.delete(sessionId);
187
296
  logEvent("session_end", {
188
297
  sessionId,
189
298
  loopId: session.loopId,
190
- stopReason: "stopped",
299
+ stopReason: "cancelled",
191
300
  roundsCompleted: session.currentRound,
192
301
  });
193
302
  return true;
194
303
  }
304
+ /** v1.18: Pause a running session. The session state is persisted to
305
+ * vault so it survives process restarts. Returns the session status.
306
+ * Paused sessions cannot be advanced — they must be resumed first. */
307
+ pause(sessionId) {
308
+ const session = this.sessions.get(sessionId);
309
+ if (!session)
310
+ return { sessionId, round: 0, status: "not_found" };
311
+ if (session.status !== "running") {
312
+ return { sessionId, round: session.currentRound, status: session.status };
313
+ }
314
+ session.status = "paused";
315
+ this.save(session);
316
+ logEvent("session_paused", {
317
+ sessionId,
318
+ loopId: session.loopId,
319
+ round: session.currentRound,
320
+ });
321
+ return { sessionId, round: session.currentRound, status: "paused" };
322
+ }
323
+ restoredPromptResult(session) {
324
+ if (!session.currentPrompt)
325
+ return null;
326
+ return {
327
+ sessionId: session.sessionId,
328
+ round: session.currentRound,
329
+ roundId: session.roundSnapshot?.roundId,
330
+ prompt: session.currentPrompt,
331
+ level: session.currentLevel ?? "l2",
332
+ quality: 0,
333
+ roundSuccess: undefined,
334
+ warnings: parseWarnings(session.currentPrompt),
335
+ };
336
+ }
337
+ /** Reconcile the crash window where feedback committed but session_state
338
+ * still points at the old prompt. Returns null when no commit is pending. */
339
+ reconcileCommittedRound(session) {
340
+ if (!session.roundSnapshot)
341
+ return null;
342
+ const recovered = new RoundDriver(session.engine, this.backend).recover(session.roundSnapshot);
343
+ if (!recovered)
344
+ return null;
345
+ const pr = recovered.result;
346
+ session.roundSnapshot = recovered.snapshot;
347
+ // Replay the committed counter — but with per-rule tracking.
348
+ if (pr.action === "reject" && pr.rejectionCheck) {
349
+ session.consecutiveRejections =
350
+ pr.rejectionCheck === session.lastRejectionCheck
351
+ ? pr.newConsecutiveRejections
352
+ : 1;
353
+ session.lastRejectionCheck = pr.rejectionCheck;
354
+ }
355
+ else {
356
+ session.consecutiveRejections = pr.newConsecutiveRejections;
357
+ if (pr.action !== "reject")
358
+ session.lastRejectionCheck = "";
359
+ }
360
+ if (pr.newLastSelfEval)
361
+ session.lastSelfEval = pr.newLastSelfEval;
362
+ if (pr.shouldPushSuccessTrajectory &&
363
+ session.successTrajectory.length < session.currentRound) {
364
+ session.successTrajectory.push(pr.roundSuccess);
365
+ }
366
+ session.currentPrompt = null;
367
+ if (pr.action === "stop" || pr.action === "terminate") {
368
+ const reason = pr.action === "terminate"
369
+ ? "enforcement_terminated"
370
+ : pr.stopReason ?? "stalled";
371
+ session.status = reason === "stalled" ? "stalled" : "stopped";
372
+ this.save(session);
373
+ void this.notifyTerminal(session, reason);
374
+ return {
375
+ sessionId: session.sessionId,
376
+ round: session.currentRound,
377
+ roundId: recovered.snapshot.roundId,
378
+ prompt: null,
379
+ stopReason: reason,
380
+ roundSuccess: pr.roundSuccess,
381
+ quality: pr.roundSuccess ? 5 : 1,
382
+ };
383
+ }
384
+ if (pr.action !== "continue")
385
+ return null;
386
+ session.currentRound++;
387
+ const request = buildLoopRequest(session, pr.newLastSelfEval, pr.roundSuccess ? 5 : 1, pr.verificationFlags);
388
+ const prepared = new RoundDriver(session.engine, this.backend).prepareSync(request, session.loopId, session.currentRound);
389
+ if (!prepared) {
390
+ session.status = "stalled";
391
+ this.save(session);
392
+ return {
393
+ sessionId: session.sessionId,
394
+ round: session.currentRound,
395
+ prompt: null,
396
+ stopReason: "stalled",
397
+ };
398
+ }
399
+ const prompt = prepared.prompt;
400
+ session.evidenceBaseline = prepared.evidenceBaseline;
401
+ session.roundSnapshot = prepared.snapshot;
402
+ session.currentPrompt = prompt;
403
+ session.currentLevel = prepared.level;
404
+ policyMetrics.recordStrategy(session.loopId, session.currentLevel);
405
+ this.save(session);
406
+ return {
407
+ sessionId: session.sessionId,
408
+ round: session.currentRound,
409
+ roundId: session.roundSnapshot.roundId,
410
+ prompt,
411
+ level: session.currentLevel,
412
+ roundSuccess: pr.roundSuccess,
413
+ quality: pr.roundSuccess ? 5 : 1,
414
+ warnings: parseWarnings(prompt),
415
+ };
416
+ }
417
+ /** v1.18: Resume a paused session. Reconstructs from vault state and
418
+ * compiles the next prompt. Returns null if no paused session exists
419
+ * for this loopId. */
420
+ async unpause(loopId) {
421
+ const persistedEntry = this.findSessionEntry(loopId);
422
+ if (!persistedEntry)
423
+ return null;
424
+ const lineage = (persistedEntry.loop_lineage ?? {});
425
+ const status = lineage.status ?? "running";
426
+ if (status !== "paused")
427
+ return null;
428
+ const sessionEntry = this.claimSessionEntry(loopId);
429
+ if (!sessionEntry)
430
+ return this.leaseConflictResult(loopId, persistedEntry);
431
+ const session = this.reconstructSession(sessionEntry, true);
432
+ if (!session)
433
+ return null;
434
+ // Set to running so advance() works
435
+ session.status = "running";
436
+ this.sessions.set(session.sessionId, session);
437
+ // Replace the sync-fallback evidence baseline with async evidence
438
+ // so resumed sessions don't silently drop async provider data.
439
+ try {
440
+ const asyncEvidence = await EvidenceCollector.fromPolicy().collectAsync({
441
+ loopId: session.loopId,
442
+ phase: "before",
443
+ });
444
+ if (asyncEvidence.length > 0) {
445
+ session.evidenceBaseline = asyncEvidence;
446
+ if (!session.roundSnapshot?.beforeEvidence?.length) {
447
+ session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
448
+ }
449
+ }
450
+ }
451
+ catch {
452
+ // Async evidence is best-effort; fall back to sync baseline.
453
+ }
454
+ const reconciled = this.reconcileCommittedRound(session);
455
+ if (reconciled)
456
+ return reconciled;
457
+ const restored = this.restoredPromptResult(session);
458
+ if (restored) {
459
+ this.save(session);
460
+ return restored;
461
+ }
462
+ // Compile the next prompt from the current round state
463
+ const lcr = makeLoopCompileRequest({
464
+ loop_id: session.loopId,
465
+ round: session.currentRound,
466
+ goal_id: "",
467
+ task: session.task,
468
+ domain: undefined,
469
+ plan_source: null,
470
+ constraints_from_plan: [],
471
+ health_check_interval: 1,
472
+ });
473
+ const compileRequest = {
474
+ task: session.task,
475
+ mode: Mode.LOOP_COMPILE,
476
+ feedback: null,
477
+ skill_name: null,
478
+ task_id: null,
479
+ loop_id: lcr.loop_id,
480
+ round: lcr.round,
481
+ goal_id: lcr.goal_id,
482
+ domain: lcr.domain ?? "",
483
+ plan_source: lcr.plan_source ?? null,
484
+ constraints_from_plan: lcr.constraints_from_plan ?? [],
485
+ health_check_interval: lcr.health_check_interval,
486
+ max_rounds: session.maxRounds,
487
+ };
488
+ const prepared = await new RoundDriver(session.engine, this.backend).prepare(compileRequest, session.loopId, session.currentRound);
489
+ if (!prepared) {
490
+ session.status = "stopped";
491
+ this.save(session);
492
+ void this.notifyTerminal(session, "stalled");
493
+ return { sessionId: session.sessionId, round: session.currentRound, prompt: null, stopReason: "stalled" };
494
+ }
495
+ const prompt = prepared.prompt;
496
+ const level = prepared.level;
497
+ session.evidenceBaseline = prepared.evidenceBaseline;
498
+ session.roundSnapshot = prepared.snapshot;
499
+ session.currentPrompt = prompt;
500
+ session.currentLevel = level;
501
+ policyMetrics.recordStrategy(session.loopId, level);
502
+ this.save(session);
503
+ return {
504
+ sessionId: session.sessionId,
505
+ round: session.currentRound,
506
+ roundId: session.roundSnapshot.roundId,
507
+ prompt,
508
+ level,
509
+ quality: 0,
510
+ roundSuccess: undefined,
511
+ warnings: [],
512
+ };
513
+ }
195
514
  /** Persist session state to vault for cross-process recovery.
196
- * Uses upsert: removes any previous session_state entry for this loop,
197
- * then appends a new one with current state.
198
- * Entire read→filter→write→append is wrapped in a file lock to prevent
199
- * lost updates from concurrent processes. */
515
+ * The filtered vault and replacement entry are written once under the
516
+ * backend lock, so recovery never observes the old two-write gap. */
200
517
  save(session) {
201
- if (!this.backend)
518
+ if (!this.sessionStore)
202
519
  return;
203
- const doSave = () => {
204
- // Upsert: remove old session_state entries for this loop
205
- const vault = this.backend.readVault();
206
- const entries = vault.entries || [];
207
- vault.entries = entries.filter((e) => !(e.task_type === "session_state" && e.loop_id === session.loopId));
208
- this.backend.writeVault(vault);
209
- // Append fresh session state
210
- this.backend.appendEntry({
211
- task_id: `loop:${session.loopId}:session`,
212
- task_type: "session_state",
213
- timestamp: new Date().toISOString(),
214
- loop_id: session.loopId,
215
- task: session.task,
216
- loop_lineage: {
217
- session_id: session.sessionId,
218
- current_round: session.currentRound,
219
- max_rounds: session.maxRounds,
220
- success_trajectory: session.successTrajectory,
221
- status: session.status,
222
- created_at: session.createdAt,
223
- // v1.7: Memory integration state for cross-process recovery
224
- injection_count: session.injectionCount,
225
- last_injection_round: session.lastInjectionRound,
226
- phase2_triggered: session.phase2Triggered,
227
- phase3_triggered: session.phase3Triggered,
228
- },
229
- });
520
+ const leaseActive = session.status === "running";
521
+ const sessionEntry = {
522
+ task_id: `loop:${session.loopId}:session`,
523
+ task_type: "session_state",
524
+ timestamp: new Date().toISOString(),
525
+ loop_id: session.loopId,
526
+ task: session.task,
527
+ loop_lineage: {
528
+ session_id: session.sessionId,
529
+ current_round: session.currentRound,
530
+ max_rounds: session.maxRounds,
531
+ success_trajectory: session.successTrajectory,
532
+ status: session.status,
533
+ created_at: session.createdAt,
534
+ // v1.13: Enforcement gate state
535
+ consecutive_rejections: session.consecutiveRejections,
536
+ last_rejection_check: session.lastRejectionCheck,
537
+ // v1.19: durable round transaction state
538
+ round_snapshot: session.roundSnapshot ?? null,
539
+ last_self_eval: session.lastSelfEval ?? null,
540
+ current_prompt: session.currentPrompt ?? null,
541
+ current_level: session.currentLevel ?? "",
542
+ // v1.20: cross-process single-owner lease
543
+ lease_owner: leaseActive ? this.ownerId : "",
544
+ lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
545
+ },
230
546
  };
231
- // Use FSBackend's file lock if available (only FSBackend implements withLock)
232
- if ("withLock" in this.backend &&
233
- typeof this.backend.withLock === "function") {
234
- this.backend.withLock(doSave);
547
+ this.sessionStore.save(sessionEntry, {
548
+ expectedLeaseOwner: this.ownerId,
549
+ });
550
+ if (this.checkpointSinks.size > 0) {
551
+ const checkpoint = createCognitiveCheckpoint(session, sessionEntry.timestamp);
552
+ for (const sink of this.checkpointSinks) {
553
+ try {
554
+ const pending = sink.save(checkpoint);
555
+ if (pending && typeof pending.then === "function") {
556
+ void pending.catch(() => undefined);
557
+ }
558
+ }
559
+ catch {
560
+ // External adapters must not affect session durability.
561
+ }
562
+ }
235
563
  }
236
- else {
237
- doSave();
564
+ }
565
+ /** Reconstruct a McpSession from a vault session_state entry.
566
+ * Returns null if the entry is not "running" status.
567
+ * Shared by resume() and autoResumeAll(). */
568
+ reconstructSession(entry, allowPaused = false) {
569
+ const lineage = (entry.loop_lineage ?? {});
570
+ const status = lineage.status ?? "running";
571
+ if (status !== "running" && !(allowPaused && status === "paused")) {
572
+ return null;
238
573
  }
574
+ const loopId = entry.loop_id;
575
+ const currentRound = lineage.current_round ?? 1;
576
+ const successTrajectory = lineage.success_trajectory ?? lineage.quality_trajectory ?? [];
577
+ const task = entry.task ?? "";
578
+ const maxRounds = lineage.max_rounds ?? getPolicy().runtime.max_rounds;
579
+ const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
580
+ const fallbackEvidence = EvidenceCollector.fromProviderNames(getPolicy().evidence.providers).collect();
581
+ const persistedEval = lineage.last_self_eval;
582
+ const lastSelfEval = persistedEval !== null &&
583
+ typeof persistedEval === "object" &&
584
+ !Array.isArray(persistedEval) &&
585
+ typeof persistedEval.success === "boolean" &&
586
+ typeof persistedEval.output_summary === "string"
587
+ ? persistedEval
588
+ : undefined;
589
+ const engine = new LoopForgeEngine(this.backend);
590
+ return {
591
+ sessionId: typeof lineage.session_id === "string" && lineage.session_id
592
+ ? lineage.session_id
593
+ : randomUUID(),
594
+ loopId, task, engine,
595
+ currentRound, maxRounds, successTrajectory,
596
+ status: status,
597
+ createdAt: lineage.created_at ?? Date.now(),
598
+ consecutiveRejections: lineage.consecutive_rejections ?? 0,
599
+ lastRejectionCheck: typeof lineage.last_rejection_check === "string"
600
+ ? lineage.last_rejection_check
601
+ : "",
602
+ evidenceBaseline: roundSnapshot?.beforeEvidence ?? fallbackEvidence,
603
+ roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, fallbackEvidence),
604
+ lastSelfEval,
605
+ currentPrompt: typeof lineage.current_prompt === "string"
606
+ ? lineage.current_prompt
607
+ : null,
608
+ currentLevel: typeof lineage.current_level === "string"
609
+ ? lineage.current_level
610
+ : undefined,
611
+ };
239
612
  }
240
613
  /** Resume a loop from vault state.
241
614
  * Reconstructs the session and compiles the prompt for the next round.
242
615
  * Returns null if no session_state entry exists for this loopId. */
243
616
  resume(loopId) {
244
- if (!this.backend)
617
+ const persistedEntry = this.findSessionEntry(loopId);
618
+ if (!persistedEntry)
245
619
  return null;
246
- const entries = this.backend.queryEntries({
247
- prefix: `loop:${loopId}:session`,
248
- });
249
- const sessionEntry = entries.find((e) => e.task_type === "session_state");
620
+ const persistedLineage = (persistedEntry.loop_lineage ?? {});
621
+ const persistedStatus = persistedLineage.status ?? "running";
622
+ const sessionEntry = persistedStatus === "running"
623
+ ? this.claimSessionEntry(loopId)
624
+ : persistedEntry;
250
625
  if (!sessionEntry)
251
- return null;
252
- const lineage = (sessionEntry.loop_lineage ?? {});
253
- const status = lineage.status ?? "running";
254
- const currentRound = lineage.current_round ?? 1;
255
- const successTrajectory = lineage.success_trajectory ?? lineage.quality_trajectory ?? [];
256
- const task = sessionEntry.task ?? "";
257
- const maxRounds = lineage.max_rounds ?? getPolicy().runtime.max_rounds;
258
- // If the loop was already stopped or stalled, return immediately
259
- if (status !== "running") {
626
+ return this.leaseConflictResult(loopId, persistedEntry);
627
+ const session = this.reconstructSession(sessionEntry);
628
+ if (!session) {
629
+ // was not "running" — return stopped/stalled status
630
+ const lineage = (sessionEntry.loop_lineage ?? {});
631
+ const currentRound = lineage.current_round ?? 1;
632
+ const status = lineage.status ?? "stopped";
260
633
  return {
261
634
  sessionId: "",
262
635
  round: currentRound,
@@ -264,41 +637,70 @@ export class SessionManager {
264
637
  stopReason: status,
265
638
  };
266
639
  }
267
- // Reconstruct session with a fresh engine
268
- const engine = new LoopForgeEngine("skills", this.backend);
269
- const sessionId = randomUUID();
270
- const session = {
271
- sessionId,
272
- loopId,
273
- task,
274
- engine,
275
- currentRound,
276
- maxRounds,
277
- successTrajectory,
278
- status: "running",
279
- createdAt: lineage.created_at ?? Date.now(),
280
- // v1.7: Memory integration state restored from vault
281
- injectionCount: lineage.injection_count ?? 0,
282
- lastInjectionRound: lineage.last_injection_round ?? 0,
283
- injectedContexts: [],
284
- phase2Triggered: lineage.phase2_triggered ?? false,
285
- phase3Triggered: lineage.phase3_triggered ?? false,
286
- };
287
- this.sessions.set(sessionId, session);
288
- // Compile the next round's prompt from vault lineage
640
+ this.sessions.set(session.sessionId, session);
641
+ const reconciled = this.reconcileCommittedRound(session);
642
+ if (reconciled)
643
+ return reconciled;
644
+ const restored = this.restoredPromptResult(session);
645
+ if (restored)
646
+ return restored;
647
+ // Legacy session without a stored prompt: compile once, then persist it.
289
648
  const request = buildLoopRequest(session);
290
- const result = engine.invokeLoopCompile(request);
649
+ const prepared = new RoundDriver(session.engine, this.backend).prepareSync(request, session.loopId, session.currentRound);
650
+ const prompt = prepared?.prompt ?? null;
651
+ session.evidenceBaseline = prepared?.evidenceBaseline ?? [];
652
+ session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, []);
653
+ session.currentPrompt = prompt;
654
+ session.currentLevel = prepared?.level ?? "l2";
655
+ policyMetrics.recordStrategy(session.loopId, session.currentLevel);
656
+ this.save(session);
291
657
  return {
292
- sessionId,
293
- round: currentRound,
294
- prompt: result.response?.prompt ?? null,
295
- technique: result.response?.analysis?.technique ?? "zero-shot",
296
- level: parseLevel(result.response?.analysis?.rationale),
658
+ sessionId: session.sessionId,
659
+ round: session.currentRound,
660
+ roundId: session.roundSnapshot.roundId,
661
+ prompt,
662
+ level: session.currentLevel,
297
663
  roundSuccess: false,
298
664
  quality: 0,
299
- warnings: parseWarnings(result.response?.prompt ?? null),
665
+ warnings: parseWarnings(prompt),
300
666
  };
301
667
  }
668
+ /** Auto-resume all "running" sessions from vault on server startup.
669
+ * Scans vault for session_state entries, reconstructs each as an in-memory
670
+ * McpSession (without compiling — the next loopforge_next will do that).
671
+ * Returns the number of sessions resumed. */
672
+ autoResumeAll() {
673
+ if (!this.sessionStore)
674
+ return 0;
675
+ const entries = this.sessionStore.list();
676
+ // Build set of already-active loopIds
677
+ const activeLoopIds = new Set();
678
+ for (const s of this.sessions.values()) {
679
+ activeLoopIds.add(s.loopId);
680
+ }
681
+ let count = 0;
682
+ for (const entry of entries) {
683
+ if (entry.task_type !== "session_state")
684
+ continue;
685
+ const lid = entry.loop_id;
686
+ if (!lid || activeLoopIds.has(lid))
687
+ continue;
688
+ const lineage = (entry.loop_lineage ?? {});
689
+ const status = lineage.status ?? "running";
690
+ if (status !== "running" && status !== "paused")
691
+ continue;
692
+ const claimedEntry = this.claimSessionEntry(lid);
693
+ if (!claimedEntry)
694
+ continue;
695
+ const session = this.reconstructSession(claimedEntry, true);
696
+ if (session) {
697
+ this.sessions.set(session.sessionId, session);
698
+ activeLoopIds.add(lid);
699
+ count++;
700
+ }
701
+ }
702
+ return count;
703
+ }
302
704
  list() {
303
705
  const seen = new Set();
304
706
  const result = [];
@@ -312,10 +714,9 @@ export class SessionManager {
312
714
  status: s.status,
313
715
  });
314
716
  }
315
- // Merge vault-persisted sessions not already in memory
316
- if (this.backend) {
317
- const vault = this.backend.readVault();
318
- const entries = vault.entries ?? [];
717
+ // Merge persisted sessions not already in memory
718
+ if (this.sessionStore) {
719
+ const entries = this.sessionStore.list();
319
720
  for (const e of entries) {
320
721
  if (e.task_type !== "session_state")
321
722
  continue;
@@ -350,11 +751,8 @@ export class SessionManager {
350
751
  }
351
752
  }
352
753
  // Fall back to vault for task
353
- if (!task && this.backend) {
354
- const entries = this.backend.queryEntries({
355
- prefix: `loop:${loopId}:session`,
356
- });
357
- const sessionEntry = entries.find((e) => e.task_type === "session_state");
754
+ if (!task) {
755
+ const sessionEntry = this.findSessionEntry(loopId);
358
756
  if (sessionEntry) {
359
757
  task = sessionEntry.task ?? "";
360
758
  }
@@ -362,7 +760,7 @@ export class SessionManager {
362
760
  if (!task)
363
761
  return null;
364
762
  // Hydrate vault context
365
- const engine = new LoopForgeEngine("skills", this.backend);
763
+ const engine = new LoopForgeEngine(this.backend);
366
764
  const vaultContext = engine.hydrateLoopContext(loopId);
367
765
  // Build a minimal request for health check
368
766
  const request = makeLoopCompileRequest({
@@ -379,6 +777,7 @@ export class SessionManager {
379
777
  drift_detected: health.drift_detected,
380
778
  strategy_stability: health.strategy_stability,
381
779
  task_continuity: health.task_continuity,
780
+ policy_metrics: policyMetrics.snapshot(loopId),
382
781
  };
383
782
  }
384
783
  /** Core cycle: extract self-eval → record feedback → check stop → compile next.
@@ -386,6 +785,24 @@ export class SessionManager {
386
785
  * When provided (MCP path with evaluation parameter), skips regex extraction.
387
786
  * When undefined (runtime/CLI path), falls back to regex extraction from output. */
388
787
  async advance(sessionId, output, preExtractedEval) {
788
+ return this.withSessionQueue(sessionId, async () => {
789
+ const session = this.sessions.get(sessionId);
790
+ if (session?.status === "running" &&
791
+ !this.renewSessionLease(session.loopId)) {
792
+ return this.leaseConflictResult(session.loopId, this.findSessionEntry(session.loopId));
793
+ }
794
+ try {
795
+ return await this.advanceUnlocked(sessionId, output, preExtractedEval);
796
+ }
797
+ catch (error) {
798
+ if (error instanceof SessionLeaseConflictError) {
799
+ return this.leaseConflictResult(error.loopId, this.findSessionEntry(error.loopId));
800
+ }
801
+ throw error;
802
+ }
803
+ });
804
+ }
805
+ async advanceUnlocked(sessionId, output, preExtractedEval) {
389
806
  const session = this.sessions.get(sessionId);
390
807
  if (!session)
391
808
  return { sessionId, round: 0, prompt: null, stopReason: "session_not_found" };
@@ -408,211 +825,196 @@ export class SessionManager {
408
825
  if (!selfEval) {
409
826
  session.status = "stalled";
410
827
  this.save(session);
411
- void this.doWriteback(session, "stalled");
828
+ void this.notifyTerminal(session, "stalled");
412
829
  logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
413
830
  return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", roundSuccess: false, quality: 0 };
414
831
  }
415
- // 1.5. Verification gate — cross-round consistency check (v1.6)
416
- let verificationFlags = [];
417
- let gateVerdict = "trusted";
418
- {
419
- const vaultEntries = this.backend
420
- ? this.backend.queryEntries({ prefix: `loop:${session.loopId}:r` })
421
- : [];
422
- const verifyResult = verifySelfEvaluation(selfEval, session.currentRound, vaultEntries, session.lastSelfEval ?? null);
423
- verificationFlags = verifyResult.flags;
424
- gateVerdict = verifyResult.verdict;
425
- }
426
- // 2. Record feedback (flushes immediately so next compile sees success flags)
427
- const roundSuccess = session.engine.autoFeedback(selfEval, session.loopId, session.currentRound, session.task);
428
- // Contradicted verdict: skip success trend
429
- if (gateVerdict !== "contradicted") {
430
- session.successTrajectory.push(roundSuccess);
431
- }
432
- // Note: feedback vault entry is always persisted via autoFeedback above.
433
- // Only the in-memory trend is skipped — the raw data stays for audit.
434
- // Store selfEval for next round's verification gate.
435
- // NOTE: lastSelfEval is intentionally NOT persisted to vault (save()).
436
- // A resumed session starts with lastSelfEval=undefined, which means the
437
- // first round after resumption runs with degraded verification (most
438
- // checks skip without prevSelfEval). The gate recovers on the next round.
439
- session.lastSelfEval = selfEval;
440
- // Build deprecated quality alias for backward compat
441
- const deprecatedQuality = roundSuccess ? 5 : 1;
442
- // 3. Stop conditions (extraction-first order — see memory)
443
- if (extractionFailed) {
444
- session.status = "stalled";
445
- this.save(session);
446
- void this.doWriteback(session, "stalled");
447
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "stalled", round: session.currentRound });
448
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "stalled", roundSuccess, quality: deprecatedQuality };
832
+ // 1.5. Unified transaction: before → evaluate → verify → commit/reject.
833
+ const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
834
+ const completed = await new RoundDriver(session.engine, this.backend).complete({
835
+ snapshot,
836
+ loopId: session.loopId,
837
+ task: session.task,
838
+ maxRounds: session.maxRounds,
839
+ selfEval,
840
+ extractionSucceeded: !extractionFailed,
841
+ lastSelfEval: session.lastSelfEval,
842
+ consecutiveRejections: session.consecutiveRejections,
843
+ successTrajectory: session.successTrajectory,
844
+ });
845
+ const outcome = completed.outcome;
846
+ const actualSnapshots = completed.actualEvidence;
847
+ session.roundSnapshot = outcome.snapshot;
848
+ const pr = outcome.result;
849
+ policyMetrics.recordStrategyOutcome(session.loopId, session.currentLevel, pr, outcome.replayed);
850
+ const verificationFlags = pr.verificationFlags;
851
+ // Track per-rule rejections: only same-check rejections
852
+ // accumulate. A different rejection reason resets the counter.
853
+ if (pr.action === "reject" && pr.rejectionCheck) {
854
+ session.consecutiveRejections =
855
+ pr.rejectionCheck === session.lastRejectionCheck
856
+ ? pr.newConsecutiveRejections
857
+ : 1;
858
+ session.lastRejectionCheck = pr.rejectionCheck;
449
859
  }
450
- if (!selfEval.should_continue) {
451
- session.status = "stopped";
860
+ else {
861
+ session.consecutiveRejections = pr.newConsecutiveRejections;
862
+ if (pr.action !== "reject")
863
+ session.lastRejectionCheck = "";
864
+ }
865
+ if (pr.newLastSelfEval)
866
+ session.lastSelfEval = pr.newLastSelfEval;
867
+ if (pr.shouldPushSuccessTrajectory) {
868
+ session.successTrajectory.push(pr.roundSuccess);
869
+ }
870
+ // Handle enforcement actions
871
+ if (pr.action === "reject") {
872
+ const retryRequest = buildLoopRequest(session, undefined, undefined, verificationFlags);
873
+ const preparedRetry = await new RoundDriver(session.engine, this.backend).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
874
+ if (!preparedRetry) {
875
+ session.status = "stalled";
876
+ session.currentPrompt = null;
877
+ this.save(session);
878
+ return {
879
+ sessionId,
880
+ round: session.currentRound,
881
+ roundId: session.roundSnapshot.roundId,
882
+ prompt: null,
883
+ stopReason: "stalled",
884
+ };
885
+ }
886
+ session.roundSnapshot = preparedRetry.snapshot;
887
+ session.currentPrompt = preparedRetry.prompt;
888
+ session.currentLevel = preparedRetry.level;
452
889
  this.save(session);
453
- void this.doWriteback(session, "task_complete");
454
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "task_complete", round: session.currentRound });
455
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "task_complete", roundSuccess, quality: deprecatedQuality };
890
+ return {
891
+ sessionId,
892
+ round: session.currentRound,
893
+ roundId: session.roundSnapshot.roundId,
894
+ prompt: preparedRetry.prompt,
895
+ level: preparedRetry.level,
896
+ enforcementAction: "reject",
897
+ enforcementReason: pr.enforcementReason,
898
+ };
456
899
  }
457
- if (session.engine.shouldBreak()) {
900
+ // Accepted rounds advance the before-snapshot. Rejected rounds retain the
901
+ // original baseline so their retry is still a zero-commit transaction.
902
+ session.evidenceBaseline = actualSnapshots;
903
+ if (pr.action === "terminate") {
458
904
  session.status = "stopped";
905
+ session.currentPrompt = null;
459
906
  this.save(session);
460
- void this.doWriteback(session, "circuit_breaker");
461
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "circuit_breaker", round: session.currentRound });
462
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "circuit_breaker", roundSuccess, quality: deprecatedQuality };
907
+ void this.notifyTerminal(session, "enforcement_terminated");
908
+ logEvent("session_end", {
909
+ sessionId, loopId: session.loopId,
910
+ stopReason: "enforcement_terminated", round: session.currentRound,
911
+ });
912
+ return {
913
+ sessionId,
914
+ round: session.currentRound,
915
+ roundId: session.roundSnapshot.roundId,
916
+ prompt: null,
917
+ stopReason: "enforcement_terminated",
918
+ enforcementAction: "terminate",
919
+ enforcementReason: pr.enforcementReason,
920
+ };
463
921
  }
464
- if (session.currentRound >= session.maxRounds) {
465
- session.status = "stopped";
922
+ if (pr.action === "stop") {
923
+ const reason = pr.stopReason ?? "stalled";
924
+ session.status = reason === "stalled" ? "stalled" : "stopped";
925
+ session.currentPrompt = null;
466
926
  this.save(session);
467
- void this.doWriteback(session, "max_rounds");
468
- logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: "max_rounds", round: session.currentRound });
469
- return { sessionId, round: session.currentRound, prompt: null, stopReason: "max_rounds", roundSuccess, quality: deprecatedQuality };
927
+ void this.notifyTerminal(session, reason);
928
+ logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
929
+ return { sessionId, round: session.currentRound, roundId: session.roundSnapshot.roundId, prompt: null, stopReason: reason, roundSuccess: pr.roundSuccess, quality: pr.roundSuccess ? 5 : 1 };
470
930
  }
471
- // 4. Compile next round
931
+ const roundSuccess = pr.roundSuccess;
932
+ // Feedback + decision metadata were committed by the transaction layer.
933
+ // Build deprecated quality alias for backward compat
934
+ const deprecatedQuality = roundSuccess ? 5 : 1;
935
+ // 4. Compile next round after the accepted commit.
472
936
  session.currentRound++;
937
+ session.currentPrompt = null;
473
938
  // v1.8: Memory injection for phases 2/3 — tier-aware
474
939
  let externalCtx = "";
475
- const miPolicy = getPolicy().memory_injection;
476
- if (miPolicy.enabled && this.memoryProvider) {
477
- const allowedPhases = new Set(resolveAllowedPhases(session.maxRounds, miPolicy.round_tiers));
478
- if (session.injectionCount < allowedPhases.size &&
479
- session.currentRound - session.lastInjectionRound >= miPolicy.min_rounds_between_injections) {
480
- const progress = selfEval.execution_evidence?.progress_estimate;
481
- const hasProgress = typeof progress === "number" && progress >= 0;
482
- let phase = null;
483
- if (allowedPhases.has(2) &&
484
- !session.phase2Triggered &&
485
- hasProgress &&
486
- progress >= miPolicy.phase_thresholds.phase2.threshold) {
487
- phase = 2;
488
- session.phase2Triggered = true;
489
- }
490
- else if (allowedPhases.has(3) &&
491
- !session.phase3Triggered &&
492
- hasProgress &&
493
- progress >= miPolicy.phase_thresholds.phase3.threshold) {
494
- phase = 3;
495
- session.phase3Triggered = true;
496
- }
497
- if (phase !== null) {
498
- try {
499
- const accCtx = {
500
- recurringIssues: selfEval.constraint_violations ?? [],
501
- failedPatterns: [],
502
- keyLessons: selfEval.emerged_subtasks ?? [],
503
- remainingCriteria: selfEval.execution_evidence?.success_criteria_remaining ?? [],
504
- };
505
- const ctx = {
506
- loopId: session.loopId,
507
- round: session.currentRound,
508
- task: session.task,
509
- domain: "",
510
- phase,
511
- progressEstimate: hasProgress ? progress : -1,
512
- accumulatedContext: accCtx,
513
- };
514
- const rawContext = await this.memoryProvider(ctx);
515
- if (rawContext?.trim()) {
516
- // Dedup
517
- const newTokens = tokenize(rawContext);
518
- let isDuplicate = false;
519
- for (const old of session.injectedContexts) {
520
- if (jaccard(newTokens, tokenize(old)) > miPolicy.dedup_threshold) {
521
- isDuplicate = true;
522
- break;
523
- }
524
- }
525
- if (!isDuplicate) {
526
- externalCtx = rawContext.trim().slice(0, miPolicy.max_context_length);
527
- session.injectionCount++;
528
- session.lastInjectionRound = session.currentRound;
529
- session.injectedContexts.push(externalCtx);
530
- logEvent("memory_injected", {
531
- loopId: session.loopId, round: session.currentRound,
532
- phase, injectionCount: session.injectionCount,
533
- contextLength: externalCtx.length,
534
- });
535
- }
536
- }
537
- }
538
- catch {
539
- logEvent("memory_provider_error", {
540
- loopId: session.loopId, round: session.currentRound,
541
- });
542
- }
543
- }
940
+ if (this.contextProvider) {
941
+ try {
942
+ externalCtx = (await this.contextProvider({
943
+ loopId: session.loopId,
944
+ round: session.currentRound,
945
+ task: session.task,
946
+ domain: "",
947
+ lastEvaluation: selfEval,
948
+ })).trim();
949
+ }
950
+ catch {
951
+ logEvent("context_provider_error", {
952
+ loopId: session.loopId,
953
+ round: session.currentRound,
954
+ });
544
955
  }
545
956
  }
957
+ // pause()/delete() are intentionally synchronous for API compatibility.
958
+ // They may run while the memory provider above is awaited. Treat the
959
+ // session map + status as a commit fence so an in-flight advance cannot
960
+ // compile/save another round after a terminal lifecycle transition.
961
+ if (this.sessions.get(sessionId) !== session || session.status !== "running") {
962
+ return {
963
+ sessionId,
964
+ round: session.currentRound,
965
+ prompt: null,
966
+ stopReason: session.status,
967
+ roundSuccess,
968
+ quality: deprecatedQuality,
969
+ };
970
+ }
546
971
  const request = buildLoopRequest(session, selfEval, deprecatedQuality, verificationFlags);
547
972
  if (externalCtx) {
548
973
  request.external_context = externalCtx;
549
974
  }
550
- const result = session.engine.invokeLoopCompile(request);
975
+ const prepared = await new RoundDriver(session.engine, this.backend).prepare(request, session.loopId, session.currentRound);
976
+ const nextPrompt = prepared?.prompt ?? null;
977
+ const nextLevel = prepared?.level ?? "l2";
978
+ const nextBaseline = prepared?.evidenceBaseline ?? actualSnapshots;
979
+ session.evidenceBaseline = nextBaseline;
980
+ session.roundSnapshot = prepared?.snapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, nextBaseline);
981
+ session.currentPrompt = nextPrompt;
982
+ session.currentLevel = nextLevel;
983
+ policyMetrics.recordStrategy(session.loopId, nextLevel);
551
984
  this.save(session);
552
985
  return {
553
986
  sessionId,
554
987
  round: session.currentRound,
555
- prompt: result.response?.prompt ?? null,
556
- technique: result.response?.analysis?.technique ?? "zero-shot",
557
- level: parseLevel(result.response?.analysis?.rationale),
988
+ roundId: session.roundSnapshot.roundId,
989
+ prompt: nextPrompt,
990
+ level: nextLevel,
558
991
  roundSuccess,
559
992
  quality: deprecatedQuality,
560
- warnings: parseWarnings(result.response?.prompt ?? null),
993
+ warnings: parseWarnings(nextPrompt),
561
994
  };
562
995
  }
563
996
  /** Write back loop knowledge to long-term memory.
564
- * Called when a loop terminates for any reason. */
565
- async doWriteback(session, stopReason) {
566
- if (!this.memoryWriter)
567
- return;
568
- const wp = getPolicy().memory_writeback;
569
- if (!wp.enabled)
570
- return;
571
- if (wp.write_on_outcomes.length > 0 && !wp.write_on_outcomes.includes(stopReason))
997
+ * Uses shared base builder from policy.ts. Called when a loop terminates. */
998
+ async notifyTerminal(session, stopReason) {
999
+ if (this.terminalSinks.size === 0)
572
1000
  return;
573
- if (session.currentRound < 1)
574
- return;
575
- const outcome = ["completed", "circuit_breaker", "stalled", "max_rounds", "stopped"].find((o) => stopReason === o) ?? "stopped";
576
- try {
577
- const payload = {
578
- loopId: session.loopId,
579
- task: session.task,
580
- outcome,
581
- roundsCompleted: session.currentRound,
582
- projectEntry: {
583
- title: `${session.task.slice(0, 80)} — ${outcome}`,
584
- objective: session.task.slice(0, 200),
585
- keyOutcome: outcome === "completed"
586
- ? `Completed successfully in ${session.currentRound} rounds.`
587
- : `Terminated with reason '${stopReason}' after ${session.currentRound} rounds.`,
588
- keyDiscoveries: [],
589
- date: new Date().toISOString().split("T")[0],
590
- },
591
- feedbackEntries: [],
592
- referenceEntry: {
593
- description: `LoopForge vault data for "${session.task.slice(0, 80)}"`,
594
- vaultLocation: `.promptcraft/prompt_vault.json → loop:${session.loopId}:*`,
595
- },
596
- };
597
- await this.memoryWriter(payload);
598
- logEvent("memory_writeback", {
599
- loopId: session.loopId, stopReason,
600
- feedbackCount: payload.feedbackEntries.length,
601
- });
602
- }
603
- catch {
604
- logEvent("memory_writeback_error", {
605
- loopId: session.loopId, stopReason,
606
- });
607
- }
1001
+ const event = {
1002
+ loopId: session.loopId,
1003
+ task: session.task,
1004
+ success: stopReason === "completed",
1005
+ stopReason: stopReason,
1006
+ roundsCompleted: session.currentRound,
1007
+ successTrajectory: [...session.successTrajectory],
1008
+ lastEvaluation: session.lastSelfEval,
1009
+ };
1010
+ await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
608
1011
  }
609
1012
  /** Replay timeline for a session — creates ReplayBackend from the stored backend. */
610
1013
  replayTimeline(sessionId) {
611
1014
  const session = this.sessions.get(sessionId);
612
1015
  if (!session)
613
1016
  return null;
614
- const vaultBackend = this.backend ?? new FSBackend();
615
- const replay = new ReplayBackend(vaultBackend);
1017
+ const replay = new ReplayBackend(this.backend);
616
1018
  return replay.timeline(session.loopId);
617
1019
  }
618
1020
  }