loopforge 3.9.4 → 4.0.7

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 (283) hide show
  1. package/README.md +236 -112
  2. package/dist/audit.d.ts +73 -15
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +131 -61
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +114 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +276 -182
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +41 -12
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +0 -2
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +0 -4
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/committed-round.d.ts +97 -156
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +223 -419
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/constraint-lifecycle.d.ts +73 -0
  22. package/dist/constraint-lifecycle.d.ts.map +1 -0
  23. package/dist/constraint-lifecycle.js +174 -0
  24. package/dist/constraint-lifecycle.js.map +1 -0
  25. package/dist/context-boundary.d.ts +83 -0
  26. package/dist/context-boundary.d.ts.map +1 -0
  27. package/dist/context-boundary.js +101 -0
  28. package/dist/context-boundary.js.map +1 -0
  29. package/dist/contract-items.d.ts +12 -8
  30. package/dist/contract-items.d.ts.map +1 -1
  31. package/dist/contract-items.js +31 -26
  32. package/dist/contract-items.js.map +1 -1
  33. package/dist/contract-requirement.d.ts +47 -0
  34. package/dist/contract-requirement.d.ts.map +1 -0
  35. package/dist/contract-requirement.js +51 -0
  36. package/dist/contract-requirement.js.map +1 -0
  37. package/dist/criterion-status.d.ts +35 -0
  38. package/dist/criterion-status.d.ts.map +1 -0
  39. package/dist/criterion-status.js +89 -0
  40. package/dist/criterion-status.js.map +1 -0
  41. package/dist/enforcement-gate.d.ts +26 -38
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +196 -319
  44. package/dist/enforcement-gate.js.map +1 -1
  45. package/dist/engine.d.ts +9 -103
  46. package/dist/engine.d.ts.map +1 -1
  47. package/dist/engine.js +27 -897
  48. package/dist/engine.js.map +1 -1
  49. package/dist/errors.d.ts +41 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +69 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/evidence-claims.d.ts +33 -31
  54. package/dist/evidence-claims.d.ts.map +1 -1
  55. package/dist/evidence-claims.js +58 -72
  56. package/dist/evidence-claims.js.map +1 -1
  57. package/dist/evidence-posture-view.d.ts +19 -0
  58. package/dist/evidence-posture-view.d.ts.map +1 -0
  59. package/dist/evidence-posture-view.js +116 -0
  60. package/dist/evidence-posture-view.js.map +1 -0
  61. package/dist/evidence-posture.d.ts +73 -0
  62. package/dist/evidence-posture.d.ts.map +1 -0
  63. package/dist/evidence-posture.js +281 -0
  64. package/dist/evidence-posture.js.map +1 -0
  65. package/dist/evidence-provider.d.ts +55 -37
  66. package/dist/evidence-provider.d.ts.map +1 -1
  67. package/dist/evidence-provider.js +182 -332
  68. package/dist/evidence-provider.js.map +1 -1
  69. package/dist/explain.d.ts +166 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +372 -58
  72. package/dist/explain.js.map +1 -1
  73. package/dist/field-accounting.d.ts.map +1 -1
  74. package/dist/field-accounting.js +3 -22
  75. package/dist/field-accounting.js.map +1 -1
  76. package/dist/generate-schema.js +13 -8
  77. package/dist/generate-schema.js.map +1 -1
  78. package/dist/git-boundary.d.ts +128 -0
  79. package/dist/git-boundary.d.ts.map +1 -0
  80. package/dist/git-boundary.js +625 -0
  81. package/dist/git-boundary.js.map +1 -0
  82. package/dist/git-facts.d.ts +22 -0
  83. package/dist/git-facts.d.ts.map +1 -0
  84. package/dist/git-facts.js +29 -0
  85. package/dist/git-facts.js.map +1 -0
  86. package/dist/git-motion.d.ts +37 -0
  87. package/dist/git-motion.d.ts.map +1 -0
  88. package/dist/git-motion.js +59 -0
  89. package/dist/git-motion.js.map +1 -0
  90. package/dist/history.d.ts +44 -0
  91. package/dist/history.d.ts.map +1 -0
  92. package/dist/history.js +77 -0
  93. package/dist/history.js.map +1 -0
  94. package/dist/index.d.ts +19 -9
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +12 -4
  97. package/dist/index.js.map +1 -1
  98. package/dist/init.d.ts +16 -0
  99. package/dist/init.d.ts.map +1 -1
  100. package/dist/init.js +80 -14
  101. package/dist/init.js.map +1 -1
  102. package/dist/loop-compiler.d.ts +39 -23
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +368 -565
  105. package/dist/loop-compiler.js.map +1 -1
  106. package/dist/loop-store.d.ts +6 -5
  107. package/dist/loop-store.d.ts.map +1 -1
  108. package/dist/loop-store.js +153 -35
  109. package/dist/loop-store.js.map +1 -1
  110. package/dist/mcp/round-lifecycle.d.ts +255 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1552 -539
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts.map +1 -1
  115. package/dist/mcp/server.js +42 -18
  116. package/dist/mcp/server.js.map +1 -1
  117. package/dist/mcp/session.d.ts +81 -39
  118. package/dist/mcp/session.d.ts.map +1 -1
  119. package/dist/mcp/session.js +956 -282
  120. package/dist/mcp/session.js.map +1 -1
  121. package/dist/mcp/submission-boundary.d.ts +23 -31
  122. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  123. package/dist/mcp/submission-boundary.js +242 -92
  124. package/dist/mcp/submission-boundary.js.map +1 -1
  125. package/dist/mcp/submission-handle.d.ts +66 -0
  126. package/dist/mcp/submission-handle.d.ts.map +1 -0
  127. package/dist/mcp/submission-handle.js +173 -0
  128. package/dist/mcp/submission-handle.js.map +1 -0
  129. package/dist/mcp/tools.d.ts +17 -7
  130. package/dist/mcp/tools.d.ts.map +1 -1
  131. package/dist/mcp/tools.js +707 -244
  132. package/dist/mcp/tools.js.map +1 -1
  133. package/dist/open-question.d.ts +7 -0
  134. package/dist/open-question.d.ts.map +1 -0
  135. package/dist/open-question.js +14 -0
  136. package/dist/open-question.js.map +1 -0
  137. package/dist/policy-metrics.d.ts +31 -21
  138. package/dist/policy-metrics.d.ts.map +1 -1
  139. package/dist/policy-metrics.js +117 -64
  140. package/dist/policy-metrics.js.map +1 -1
  141. package/dist/policy.d.ts +11 -39
  142. package/dist/policy.d.ts.map +1 -1
  143. package/dist/policy.js +12 -12
  144. package/dist/policy.js.map +1 -1
  145. package/dist/progress.d.ts +45 -0
  146. package/dist/progress.d.ts.map +1 -0
  147. package/dist/progress.js +136 -0
  148. package/dist/progress.js.map +1 -0
  149. package/dist/prompt-assembler.d.ts +20 -6
  150. package/dist/prompt-assembler.d.ts.map +1 -1
  151. package/dist/prompt-assembler.js +475 -479
  152. package/dist/prompt-assembler.js.map +1 -1
  153. package/dist/prompt-coverage.d.ts +0 -6
  154. package/dist/prompt-coverage.d.ts.map +1 -1
  155. package/dist/prompt-coverage.js +4 -8
  156. package/dist/prompt-coverage.js.map +1 -1
  157. package/dist/prompt-freshness.d.ts +6 -17
  158. package/dist/prompt-freshness.d.ts.map +1 -1
  159. package/dist/prompt-freshness.js +27 -22
  160. package/dist/prompt-freshness.js.map +1 -1
  161. package/dist/prompt-policy.d.ts +14 -7
  162. package/dist/prompt-policy.d.ts.map +1 -1
  163. package/dist/prompt-policy.js +21 -5
  164. package/dist/prompt-policy.js.map +1 -1
  165. package/dist/protocol.d.ts +843 -424
  166. package/dist/protocol.d.ts.map +1 -1
  167. package/dist/protocol.js +73 -102
  168. package/dist/protocol.js.map +1 -1
  169. package/dist/recovery-guidance.d.ts +13 -0
  170. package/dist/recovery-guidance.d.ts.map +1 -0
  171. package/dist/recovery-guidance.js +15 -0
  172. package/dist/recovery-guidance.js.map +1 -0
  173. package/dist/recovery.d.ts +68 -0
  174. package/dist/recovery.d.ts.map +1 -0
  175. package/dist/recovery.js +234 -0
  176. package/dist/recovery.js.map +1 -0
  177. package/dist/replay.d.ts +42 -8
  178. package/dist/replay.d.ts.map +1 -1
  179. package/dist/replay.js +311 -84
  180. package/dist/replay.js.map +1 -1
  181. package/dist/result-facts.d.ts +45 -0
  182. package/dist/result-facts.d.ts.map +1 -0
  183. package/dist/result-facts.js +86 -0
  184. package/dist/result-facts.js.map +1 -0
  185. package/dist/round-contract.d.ts +38 -8
  186. package/dist/round-contract.d.ts.map +1 -1
  187. package/dist/round-contract.js +71 -31
  188. package/dist/round-contract.js.map +1 -1
  189. package/dist/round-coordinator.d.ts +41 -89
  190. package/dist/round-coordinator.d.ts.map +1 -1
  191. package/dist/round-coordinator.js +250 -267
  192. package/dist/round-coordinator.js.map +1 -1
  193. package/dist/round-driver.d.ts +56 -16
  194. package/dist/round-driver.d.ts.map +1 -1
  195. package/dist/round-driver.js +225 -39
  196. package/dist/round-driver.js.map +1 -1
  197. package/dist/round-facts.d.ts +33 -52
  198. package/dist/round-facts.d.ts.map +1 -1
  199. package/dist/round-facts.js +51 -160
  200. package/dist/round-facts.js.map +1 -1
  201. package/dist/round-guidance.d.ts +56 -0
  202. package/dist/round-guidance.d.ts.map +1 -0
  203. package/dist/round-guidance.js +161 -0
  204. package/dist/round-guidance.js.map +1 -0
  205. package/dist/round-outcome.d.ts +21 -0
  206. package/dist/round-outcome.d.ts.map +1 -0
  207. package/dist/round-outcome.js +31 -0
  208. package/dist/round-outcome.js.map +1 -0
  209. package/dist/round-transaction.d.ts +168 -21
  210. package/dist/round-transaction.d.ts.map +1 -1
  211. package/dist/round-transaction.js +837 -38
  212. package/dist/round-transaction.js.map +1 -1
  213. package/dist/self-eval.d.ts +37 -61
  214. package/dist/self-eval.d.ts.map +1 -1
  215. package/dist/self-eval.js +413 -392
  216. package/dist/self-eval.js.map +1 -1
  217. package/dist/storage.d.ts +16 -0
  218. package/dist/storage.d.ts.map +1 -1
  219. package/dist/storage.js +54 -4
  220. package/dist/storage.js.map +1 -1
  221. package/dist/subgoal-state.d.ts +2 -3
  222. package/dist/subgoal-state.d.ts.map +1 -1
  223. package/dist/subgoal-state.js +2 -3
  224. package/dist/subgoal-state.js.map +1 -1
  225. package/dist/submission-guidance.d.ts +29 -21
  226. package/dist/submission-guidance.d.ts.map +1 -1
  227. package/dist/submission-guidance.js +308 -260
  228. package/dist/submission-guidance.js.map +1 -1
  229. package/dist/submission-receipt.d.ts +16 -8
  230. package/dist/submission-receipt.d.ts.map +1 -1
  231. package/dist/submission-receipt.js +145 -51
  232. package/dist/submission-receipt.js.map +1 -1
  233. package/dist/token-utils.d.ts +28 -7
  234. package/dist/token-utils.d.ts.map +1 -1
  235. package/dist/token-utils.js +27 -11
  236. package/dist/token-utils.js.map +1 -1
  237. package/dist/transaction-identity.d.ts +9 -0
  238. package/dist/transaction-identity.d.ts.map +1 -0
  239. package/dist/transaction-identity.js +69 -0
  240. package/dist/transaction-identity.js.map +1 -0
  241. package/dist/verification-actions.d.ts.map +1 -1
  242. package/dist/verification-actions.js +10 -11
  243. package/dist/verification-actions.js.map +1 -1
  244. package/dist/verification-gate.d.ts +23 -47
  245. package/dist/verification-gate.d.ts.map +1 -1
  246. package/dist/verification-gate.js +202 -423
  247. package/dist/verification-gate.js.map +1 -1
  248. package/dist/version.d.ts +1 -1
  249. package/dist/version.js +1 -1
  250. package/dist/working-memory.d.ts +187 -0
  251. package/dist/working-memory.d.ts.map +1 -0
  252. package/dist/working-memory.js +767 -0
  253. package/dist/working-memory.js.map +1 -0
  254. package/dist/workspace-checkpoint.d.ts +88 -0
  255. package/dist/workspace-checkpoint.d.ts.map +1 -0
  256. package/dist/workspace-checkpoint.js +326 -0
  257. package/dist/workspace-checkpoint.js.map +1 -0
  258. package/dist/workspace-restore.d.ts +68 -0
  259. package/dist/workspace-restore.d.ts.map +1 -0
  260. package/dist/workspace-restore.js +604 -0
  261. package/dist/workspace-restore.js.map +1 -0
  262. package/dist/workspace.d.ts +0 -9
  263. package/dist/workspace.d.ts.map +1 -1
  264. package/dist/workspace.js +0 -17
  265. package/dist/workspace.js.map +1 -1
  266. package/loop_policy.json +4 -18
  267. package/package.json +68 -68
  268. package/skills/forge/SKILL.md +212 -0
  269. package/skills/forge/references/evidence-and-contracts.md +204 -0
  270. package/skills/forge/references/recovery-and-views.md +188 -0
  271. package/dist/cognitive-facts.d.ts +0 -30
  272. package/dist/cognitive-facts.d.ts.map +0 -1
  273. package/dist/cognitive-facts.js +0 -113
  274. package/dist/cognitive-facts.js.map +0 -1
  275. package/dist/loop-extras-parser.d.ts +0 -81
  276. package/dist/loop-extras-parser.d.ts.map +0 -1
  277. package/dist/loop-extras-parser.js +0 -213
  278. package/dist/loop-extras-parser.js.map +0 -1
  279. package/dist/loop-projection.d.ts +0 -6
  280. package/dist/loop-projection.d.ts.map +0 -1
  281. package/dist/loop-projection.js +0 -28
  282. package/dist/loop-projection.js.map +0 -1
  283. package/skills/perception/SKILL.md +0 -389
@@ -10,25 +10,36 @@
10
10
  * session ownership, queues, and leases remain in SessionManager.
11
11
  */
12
12
  import { randomUUID } from "node:crypto";
13
+ import { relative, resolve, sep } from "node:path";
13
14
  import { LoopForgeEngine } from "../engine.js";
14
15
  import { deriveGate } from "../cognitive-governance.js";
15
- import { Mode, makeLoopCompileRequest, } from "../protocol.js";
16
- import { captureEntrypointTrust, EvidenceCollector } from "../evidence-provider.js";
17
- import { deriveRoundObservationDelta, makeRoundId, parseRoundTransactionSnapshot, prepareRoundTransaction, } from "../round-transaction.js";
18
- import { deriveActiveRoundContract, deriveRoundContractView } from "../round-contract.js";
16
+ import { makeLoopObjective, } from "../protocol.js";
17
+ import { captureEntrypointTrust } from "../evidence-provider.js";
18
+ import { deriveRoundObservationDelta, deriveTransactionId, parseRoundTransactionSnapshot, rootBranchId, } from "../round-transaction.js";
19
+ import { deriveActiveRoundContract } from "../round-contract.js";
19
20
  import { deriveSubGoals } from "../subgoal-state.js";
20
- import { effectiveOutcome } from "../self-eval.js";
21
- import { deriveSubmissionReceipt } from "../submission-receipt.js";
21
+ import { declaresBlocked, effectiveOutcome, effectiveSuccess } from "../round-outcome.js";
22
+ import { deriveCommittedSubmissionView, deriveSubmissionReceipt, } from "../submission-receipt.js";
23
+ import { rollbackEvidencePosture } from "../evidence-posture.js";
22
24
  import { RoundDriver } from "../round-driver.js";
25
+ import { deriveRecoveryDirective, findCheckpointRef, validateCheckpointManifest, } from "../recovery.js";
26
+ import { executeRestore, preflightRestore, RestoreFailure, } from "../workspace-restore.js";
27
+ import { resolveStateDirectory } from "../policy.js";
23
28
  import { finalizeStopReason } from "../round-coordinator.js";
24
29
  import { getPolicy } from "../policy.js";
25
- import { checkRoundSequence, queryLoopEntries } from "../loop-store.js";
26
- import { derivationRounds } from "../committed-round.js";
30
+ import { checkRoundSequence } from "../loop-store.js";
31
+ import { branchRounds } from "../committed-round.js";
32
+ import { historyWindow, loadCommittedHistory, } from "../history.js";
27
33
  import { deriveSubmissionGuidance } from "../submission-guidance.js";
34
+ import { latestObjective } from "../loop-compiler.js";
35
+ import { heldPromptSnapshot, makeSubmissionHandle, } from "./submission-handle.js";
28
36
  import { logEvent } from "../observability.js";
29
37
  import { policyMetrics } from "../policy-metrics.js";
30
- import { normalizeText } from "../token-utils.js";
38
+ import { SessionLeaseConflictError } from "../storage.js";
39
+ import { isRecord, normalizeText } from "../token-utils.js";
31
40
  import { ProjectionCoverageError } from "../prompt-coverage.js";
41
+ import { CompileFailureError, LegacyHistoryError } from "../errors.js";
42
+ import { renderResultFacts } from "../result-facts.js";
32
43
  // ── Types ──────────────────────────────────────────────────────────────────
33
44
  /** M3: parse a persisted skipped-file fingerprint map (string → string).
34
45
  * Lenient by design — v3.8.3 reuses it for the entrypoint trust baseline,
@@ -44,6 +55,52 @@ function parseFingerprintMap(value) {
44
55
  }
45
56
  return out;
46
57
  }
58
+ /** Parse the session's operator-acceptance cursor leniently. A corrupt copy
59
+ * degrades to the committed root transaction; it never invents criteria. */
60
+ function parseOperatorAcceptance(value) {
61
+ if (!isRecord(value))
62
+ return undefined;
63
+ if (typeof value.task !== "string" ||
64
+ !Array.isArray(value.success_criteria) ||
65
+ !value.success_criteria.every((item) => typeof item === "string") ||
66
+ !Array.isArray(value.hard_constraints) ||
67
+ !value.hard_constraints.every((item) => typeof item === "string")) {
68
+ return undefined;
69
+ }
70
+ return value;
71
+ }
72
+ /** v4.0.5: a structural defect in invocation input. The MCP handlers map this
73
+ * to `invalid_argument`; it is never a policy or round defect. */
74
+ export class InvocationInputError extends Error {
75
+ code = "invalid_argument";
76
+ }
77
+ /** v4.0.5: normalize the invocation instruction at the transport boundary.
78
+ * `undefined`/absent stays absent ("not provided"); non-string or
79
+ * empty/whitespace is a structural defect. The trimmed text is stored
80
+ * verbatim — no case folding, no internal-whitespace folding. */
81
+ export function normalizeCurrentUserInstruction(raw) {
82
+ if (raw === undefined || raw === null)
83
+ return undefined;
84
+ if (typeof raw !== "string") {
85
+ throw new InvocationInputError("currentUserInstruction must be a non-empty string");
86
+ }
87
+ const text = raw.trim();
88
+ if (text.length === 0) {
89
+ throw new InvocationInputError("currentUserInstruction must be a non-empty string");
90
+ }
91
+ return text;
92
+ }
93
+ /** v4.0.5: normalize a whole transport input bundle before any state is read
94
+ * or mutated. */
95
+ export function normalizeInvocationInputs(inputs) {
96
+ if (!inputs)
97
+ return undefined;
98
+ const instruction = normalizeCurrentUserInstruction(inputs.currentUserInstruction);
99
+ if (instruction === undefined && inputs.currentUserInstruction === undefined) {
100
+ return inputs;
101
+ }
102
+ return { ...inputs, currentUserInstruction: instruction };
103
+ }
47
104
  /** v3.9.4: a structural defect in the start criteria argument. The MCP
48
105
  * handler maps this to `invalid_argument`; it is never a policy defect. */
49
106
  export class StartCriteriaError extends Error {
@@ -105,27 +162,57 @@ export function normalizeStartCriteria(raw) {
105
162
  }
106
163
  return { criteria, warnings };
107
164
  }
165
+ /** v4.0.4: the terminal reason a manual `loopforge_stop` reports to the
166
+ * terminal sink and in its response. It is never persisted as a committed
167
+ * round fact, so status/resume after a restart still report "no committed
168
+ * terminal reason". One constant keeps the response and the sink aligned. */
169
+ export const MANUAL_STOP_REASON = "cancelled";
108
170
  // ── Helpers ────────────────────────────────────────────────────────────────
109
- export function buildLoopRequest(session) {
171
+ export function buildLoopRequest(session,
172
+ /** v4.0.5: the host's explicit invocation input for THIS compile. */
173
+ inputs) {
110
174
  // The request carries CURRENT-ROUND context only. Every previous-round
111
175
  // fact is read from the Vault's committed rounds by the compiler, so live,
112
176
  // resume, unpause and backtrack compiles cannot disagree about the same
113
177
  // history. `attempt` is restored only while the held snapshot belongs to
114
178
  // the current round; entering the next round resets it to 1.
115
- const heldAttempt = session.roundSnapshot?.round === session.currentRound
116
- ? session.roundSnapshot.attempt
117
- : 1;
179
+ const held = session.roundSnapshot?.round === session.currentRound
180
+ ? session.roundSnapshot
181
+ : undefined;
182
+ // v4.0.3: a held retry carries its attempt/notice/findings/posture even
183
+ // after a process restart or a failed retry compile.
184
+ const retry = held?.retryContext;
185
+ const heldAttempt = retry?.attempt ?? held?.attempt ?? 1;
186
+ const acceptance = session.operatorAcceptance ?? session.roundSnapshot?.operatorAcceptance;
118
187
  return {
119
- task: session.task,
120
- mode: Mode.LOOP_COMPILE,
121
- feedback: null,
122
- skill_name: null,
123
- task_id: null,
124
188
  loop_id: session.loopId,
125
189
  round: session.currentRound,
190
+ task: session.task,
191
+ // Round 1 has no committed acceptance to read from history; the session
192
+ // cursor is the bootstrap. Later rounds read the committed copy.
193
+ loop_objective: session.currentRound === 1 && acceptance
194
+ ? makeLoopObjective({
195
+ objective: acceptance.task,
196
+ success_criteria: [...acceptance.success_criteria],
197
+ hard_constraints: [...acceptance.hard_constraints],
198
+ created_at_round: 1,
199
+ loop_id: session.loopId,
200
+ })
201
+ : null,
202
+ plan_source: null,
203
+ constraints_from_plan: [],
204
+ force_level: "auto",
126
205
  max_rounds: session.maxRounds,
206
+ verification_flags: retry ? [...retry.verificationFlags] : [],
127
207
  attempt: heldAttempt,
128
208
  consecutive_rejections: session.consecutiveRejections,
209
+ rejection_notice: retry?.rejectionNotice ?? "",
210
+ ...(retry ? { retry_evidence_posture: retry.evidencePosture } : {}),
211
+ // v4.0.5: invocation input only. It never replaces `task`; the compiler
212
+ // and assembler project it into the current prompt/state/artifact.
213
+ ...(inputs?.currentUserInstruction !== undefined
214
+ ? { current_user_instruction: inputs.currentUserInstruction }
215
+ : {}),
129
216
  };
130
217
  }
131
218
  // ── RoundLifecycle ─────────────────────────────────────────────────────────
@@ -136,40 +223,63 @@ export class RoundLifecycle {
136
223
  terminalSinks;
137
224
  ownerId;
138
225
  leaseMs;
139
- getContext;
226
+ checkpointStoreFor;
140
227
  constructor(deps) {
228
+ this.checkpointStoreFor = deps.checkpointStoreFor;
141
229
  this.store = deps.store;
142
230
  this.sessionStore = deps.sessionStore;
143
231
  this.registry = deps.registry;
144
232
  this.terminalSinks = deps.terminalSinks;
145
233
  this.ownerId = deps.ownerId;
146
234
  this.leaseMs = deps.leaseMs;
147
- this.getContext = deps.getContext;
235
+ }
236
+ /** v3.9.7: the one RoundDriver construction site. Every prepare must carry
237
+ * the per-loop checkpoint store, or no round can produce a before-image. */
238
+ driver(engine, loopId) {
239
+ return new RoundDriver(engine, this.store, this.checkpointStoreFor?.(loopId));
148
240
  }
149
241
  /** v3.9.1: the submission-guidance projection for a session's held round.
150
- * One history window (`derivationRounds`, the compile window), no writes.
242
+ * One history read and one compile window, no writes.
151
243
  * Findings from a rejected/retried attempt are supplied by the caller. */
152
244
  guidanceFor(session, extras = {}) {
153
- const prefix = `loop:${session.loopId}:`;
154
- const entries = [
155
- ...queryLoopEntries(this.store, session.loopId, { prefix }),
156
- ...queryLoopEntries(this.store, session.loopId, { prefix, feedbackOnly: true }),
157
- ];
245
+ const history = loadCommittedHistory(this.store, session.loopId);
246
+ const criteria = this.objectiveCriteriaFor(session, history.entries);
247
+ const { verificationFlags: explicitFlags, ...restExtras } = extras;
248
+ // v4.0.3: a held retry projects its findings even after resume/stall.
249
+ const verificationFlags = explicitFlags ??
250
+ session.roundSnapshot?.retryContext?.verificationFlags;
158
251
  return deriveSubmissionGuidance(session, {
159
- rounds: derivationRounds(entries, session.currentRound),
160
- ...extras,
252
+ rounds: historyWindow(history, session.currentRound),
253
+ ...(criteria ? { objective: { success_criteria: criteria } } : {}),
254
+ ...restExtras,
255
+ ...(verificationFlags ? { verificationFlags } : {}),
256
+ // A pending directive is a current fact. Unless the caller supplied a
257
+ // more specific projection, expose it so status/resume agree.
258
+ recovery: extras.recovery !== undefined
259
+ ? extras.recovery
260
+ : this.pendingRecoveryGuidance(session),
161
261
  });
162
262
  }
263
+ /** The operator criteria the held prompt's reference space was built from.
264
+ * The held acceptance wins; committed objective lineage is the fallback for
265
+ * a session whose snapshot predates the acceptance field. Null when neither
266
+ * is observable — callers fail open. */
267
+ objectiveCriteriaFor(session, entries) {
268
+ const acceptance = session.operatorAcceptance ?? session.roundSnapshot?.operatorAcceptance;
269
+ if (acceptance)
270
+ return acceptance.success_criteria;
271
+ return latestObjective(session.loopId, { results: entries })?.success_criteria ?? null;
272
+ }
163
273
  /** v3.9.1: the before facts of the round being submitted, from the SAME
164
274
  * history window the compile uses. Works on a replay too: rounds below the
165
275
  * logical round exclude the round itself. */
166
- beforeFactsFor(session) {
167
- const prefix = `loop:${session.loopId}:`;
168
- const entries = [
169
- ...queryLoopEntries(this.store, session.loopId, { prefix }),
170
- ...queryLoopEntries(this.store, session.loopId, { prefix, feedbackOnly: true }),
171
- ];
172
- const rounds = derivationRounds(entries, session.currentRound);
276
+ beforeFactsFor(session, loaded) {
277
+ const history = loaded ?? loadCommittedHistory(this.store, session.loopId);
278
+ const rounds = historyWindow(history, session.currentRound);
279
+ const pending = history.pendingDirective?.recovery?.target.redo_round ===
280
+ session.currentRound
281
+ ? history.pendingDirective
282
+ : null;
173
283
  return {
174
284
  contractId: deriveActiveRoundContract(rounds, getPolicy().evidence.commands ?? [])?.id ?? null,
175
285
  subGoals: deriveSubGoals({
@@ -177,31 +287,19 @@ export class RoundLifecycle {
177
287
  currentRound: session.currentRound,
178
288
  rounds,
179
289
  }),
290
+ // Captured before the transaction commits. A redo commit consumes the
291
+ // pending directive, so reading it afterwards would lose the fact.
292
+ recoveryDirective: pending?.recovery ?? null,
180
293
  };
181
294
  }
182
295
  /** v3.9.1: derive this submission's receipt from the transaction and the
183
296
  * shared read model. Never writes and never invents an applied effect. */
184
297
  buildReceipt(base, opts) {
185
- const prefix = `loop:${base.snapshot.loopId}:`;
186
- const entries = [
187
- ...queryLoopEntries(this.store, base.snapshot.loopId, { prefix }),
188
- ...queryLoopEntries(this.store, base.snapshot.loopId, {
189
- prefix,
190
- feedbackOnly: true,
191
- }),
192
- ];
298
+ const history = loadCommittedHistory(this.store, base.snapshot.loopId);
193
299
  const committed = opts.disposition === "accepted" || opts.disposition === "stopped";
194
- const rounds = derivationRounds(entries, committed ? base.snapshot.round + 1 : base.snapshot.round);
300
+ const rounds = historyWindow(history, committed ? base.snapshot.round + 1 : base.snapshot.round);
195
301
  const commands = getPolicy().evidence.commands ?? [];
196
302
  const roundDeltas = deriveRoundObservationDelta(base.snapshot.beforeEvidence, base.actualEvidence);
197
- const contractView = deriveRoundContractView({
198
- rounds,
199
- round: base.snapshot.round,
200
- report: base.selfEval.execution_report ?? null,
201
- deltas: roundDeltas,
202
- outcome: effectiveOutcome(base.selfEval),
203
- commands,
204
- });
205
303
  const contractAfterId = opts.contractAfterId !== undefined
206
304
  ? opts.contractAfterId
207
305
  : committed
@@ -216,6 +314,15 @@ export class RoundLifecycle {
216
314
  rounds,
217
315
  })
218
316
  : base.beforeSubGoals;
317
+ // The pre-commit capture is authoritative. The committed-directive
318
+ // fallback covers a replayed transaction whose pending directive was
319
+ // consumed by a commit this process never observed.
320
+ const recoveryDirective = base.recoveryDirective ??
321
+ (base.snapshot.kind === "rollback"
322
+ ? null
323
+ : [...history.directives].reverse().find((item) => item.recovery !== null && item.recovery !== undefined &&
324
+ item.recovery.target.redo_round === base.snapshot.round &&
325
+ item.recovery.transaction_id === base.snapshot.branchId)?.recovery ?? null);
219
326
  return deriveSubmissionReceipt({
220
327
  snapshot: base.snapshot,
221
328
  selfEval: base.selfEval,
@@ -223,25 +330,26 @@ export class RoundLifecycle {
223
330
  disposition: opts.disposition,
224
331
  diagnostics: base.diagnostics,
225
332
  submittedFields: base.submittedFields,
333
+ recoveryDirective,
334
+ ...(opts.restoreVerified !== undefined
335
+ ? { recoveryRestoreVerified: opts.restoreVerified }
336
+ : {}),
226
337
  facts: {
227
338
  contractBefore: base.beforeContractId,
228
339
  contractAfter: contractAfterId,
229
340
  subGoalsBefore: base.beforeSubGoals,
230
341
  subGoalsAfter: afterSubGoals,
231
342
  constraintsActiveAfter: opts.constraintsActiveAfter ?? null,
343
+ constraintsRetiredAfter: opts.constraintsRetiredAfter ?? null,
232
344
  },
233
345
  nextArtifact: opts.nextArtifact ?? null,
234
- contractItems: contractView.statuses.items.map((item) => ({
235
- item_id: item.itemId,
236
- status: item.status,
237
- })),
238
346
  evidenceDeltas: roundDeltas,
239
347
  });
240
348
  }
241
349
  /** Persist session state to vault for cross-process recovery.
242
350
  * The filtered vault and replacement entry are written once under the
243
351
  * backend lock, so recovery never observes the old two-write gap. */
244
- save(session) {
352
+ save(session, options = {}) {
245
353
  if (!this.sessionStore)
246
354
  return;
247
355
  const leaseActive = session.status === "running";
@@ -255,30 +363,20 @@ export class RoundLifecycle {
255
363
  session_id: session.sessionId,
256
364
  current_round: session.currentRound,
257
365
  max_rounds: session.maxRounds,
258
- success_trajectory: session.successTrajectory,
259
366
  status: session.status,
260
367
  created_at: session.createdAt,
261
368
  // v1.13: Enforcement gate state
262
369
  consecutive_rejections: session.consecutiveRejections,
263
370
  last_rejection_check: session.lastRejectionCheck,
264
- // v2.13: Backtrack skipped files for workspace restore check
265
- backtrack_skipped_files: session.backtrackSkippedFiles,
266
- // M3: skipped-file fingerprints at their failed rounds (restore
267
- // check machine arm) — empty unless a backtrack is pending
268
- backtrack_skipped_fingerprints: session.backtrackSkippedFingerprints,
269
371
  // v3.8.3: the in-flight entrypoint trust baseline. Read back
270
372
  // leniently (parseFingerprintMap) so a corrupt or absent value
271
373
  // degrades to "no absolute arm" instead of costing the round.
272
374
  entrypoint_trust: session.entrypointTrust ?? null,
273
- // v2.12: Backtrack restore-point git HEAD (crash recovery)
274
- backtrack_target_git_head: session.backtrackTargetGitHead ?? null,
275
- // v3.9: Restore point's own dirty files (restore baseline)
276
- backtrack_target_dirty: session.backtrackTargetDirty ?? null,
375
+ // v3.9.7: the operator-owned immutable acceptance cursor. The root
376
+ // committed round-1 transaction remains the authority.
377
+ operator_acceptance: session.operatorAcceptance ?? null,
277
378
  // v1.19: durable round transaction state
278
379
  round_snapshot: session.roundSnapshot ?? null,
279
- last_self_eval: session.lastSelfEval ?? null,
280
- current_prompt: session.currentPrompt ?? null,
281
- current_level: session.currentLevel ?? "",
282
380
  // v1.20: cross-process single-owner lease
283
381
  lease_owner: leaseActive ? this.ownerId : "",
284
382
  lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
@@ -286,6 +384,7 @@ export class RoundLifecycle {
286
384
  };
287
385
  this.sessionStore.save(sessionEntry, {
288
386
  expectedLeaseOwner: this.ownerId,
387
+ ...options,
289
388
  });
290
389
  }
291
390
  /** Reconstruct a McpSession from a vault session_state entry.
@@ -294,7 +393,11 @@ export class RoundLifecycle {
294
393
  reconstructSession(entry, allowPaused = false) {
295
394
  const lineage = (entry.loop_lineage ?? {});
296
395
  const status = lineage.status ?? "running";
297
- if (status !== "running" && !(allowPaused && status === "paused")) {
396
+ // v3.9.7: a persisted `stalled` recovery state MUST be resumable. It is
397
+ // how a committed directive with an unfinished restore survives a
398
+ // process restart; refusing it would strand the loop forever.
399
+ if (status !== "running" && status !== "stalled" &&
400
+ !(allowPaused && status === "paused")) {
298
401
  return null;
299
402
  }
300
403
  const loopId = entry.loop_id;
@@ -305,60 +408,64 @@ export class RoundLifecycle {
305
408
  // only reachable through the read-only audit.
306
409
  checkRoundSequence(this.store, loopId);
307
410
  const currentRound = lineage.current_round ?? 1;
308
- const successTrajectory = Array.isArray(lineage.success_trajectory)
309
- ? lineage.success_trajectory.filter((value) => typeof value === "boolean")
310
- : [];
311
411
  const task = entry.task ?? "";
312
412
  const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
313
- const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
314
- // v3.7: the synchronous evidence fallback was removed (async providers
315
- // could never be collected synchronously — they were skipped and logged
316
- // as failures). Every path that needs evidence re-collects asynchronously
317
- // through prepare()/unpause(); a missing snapshot starts with no baseline.
318
- const fallbackEvidence = [];
319
- const persistedEval = lineage.last_self_eval;
320
- const lastSelfEval = persistedEval !== null &&
321
- typeof persistedEval === "object" &&
322
- !Array.isArray(persistedEval) &&
323
- typeof persistedEval.success === "boolean" &&
324
- typeof persistedEval.output_summary === "string"
325
- ? persistedEval
326
- : undefined;
413
+ const rawSnapshot = lineage.round_snapshot;
414
+ const parsedSnapshot = parseRoundTransactionSnapshot(rawSnapshot);
415
+ // v4.0.4: a held snapshot written by an unsupported schema must fail
416
+ // closed. Silently recompiling would discard the held attempt (and, for a
417
+ // 4.0.3 session, rewrite history this runtime cannot read).
418
+ if (rawSnapshot !== undefined && rawSnapshot !== null && !parsedSnapshot) {
419
+ throw new LegacyHistoryError(loopId, [currentRound]);
420
+ }
421
+ const history = loadCommittedHistory(this.store, loopId);
422
+ // Legacy rounds are NOT history. Recompiling over them would overwrite
423
+ // data this runtime cannot read; refuse and leave the loop read-only.
424
+ if (history.legacyRounds.length > 0) {
425
+ throw new LegacyHistoryError(loopId, history.legacyRounds.map((round) => round.round));
426
+ }
427
+ const branch = history.branch;
428
+ // The session cursor is the primary copy; the held snapshot and the
429
+ // visible root transaction are fallbacks. Losing the operator surface
430
+ // during a restore/restart would be unrecoverable.
431
+ const operatorAcceptance = parseOperatorAcceptance(lineage.operator_acceptance) ??
432
+ parsedSnapshot?.operatorAcceptance ??
433
+ [...branch.rounds, ...branch.directives].find((view) => view.operatorAcceptance !== null)?.operatorAcceptance ??
434
+ undefined;
435
+ // No synthesized evaluation snapshot: a held prompt is exactly the parsed
436
+ // session snapshot. Resume/unpause recompile from committed history.
437
+ const roundSnapshot = parsedSnapshot ?? undefined;
327
438
  const engine = new LoopForgeEngine(this.store);
328
439
  return {
329
440
  sessionId: typeof lineage.session_id === "string" && lineage.session_id
330
441
  ? lineage.session_id
331
442
  : randomUUID(),
332
443
  loopId, task, engine,
333
- currentRound, maxRounds, successTrajectory,
444
+ currentRound, maxRounds,
334
445
  status: status,
335
446
  createdAt: lineage.created_at ?? Date.now(),
336
- consecutiveRejections: lineage.consecutive_rejections ?? 0,
337
- lastRejectionCheck: typeof lineage.last_rejection_check === "string"
338
- ? lineage.last_rejection_check
339
- : "",
340
- backtrackSkippedFiles: lineage.backtrack_skipped_files ?? [],
341
- backtrackSkippedFingerprints: parseFingerprintMap(lineage.backtrack_skipped_fingerprints),
342
- entrypointTrust: parseFingerprintMap(lineage.entrypoint_trust),
343
- backtrackTargetGitHead: typeof lineage.backtrack_target_git_head === "string" &&
344
- lineage.backtrack_target_git_head.length > 0
345
- ? lineage.backtrack_target_git_head
346
- : undefined,
347
- // v3.9: absent means "baseline unknown" (a session from before this
348
- // field), never an empty baseline: the restore check then falls back
349
- // to HEAD equality alone rather than reading unrelated dirt as failure.
350
- backtrackTargetDirty: Array.isArray(lineage.backtrack_target_dirty)
351
- ? lineage.backtrack_target_dirty.filter((file) => typeof file === "string")
352
- : undefined,
353
- evidenceBaseline: roundSnapshot?.beforeEvidence ?? fallbackEvidence,
354
- roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, fallbackEvidence),
355
- lastSelfEval,
356
- currentPrompt: typeof lineage.current_prompt === "string"
357
- ? lineage.current_prompt
358
- : null,
359
- currentLevel: typeof lineage.current_level === "string"
360
- ? lineage.current_level
361
- : undefined,
447
+ // v3.9.9: a retired check's persisted counter is not a current problem.
448
+ ...(() => {
449
+ const check = typeof lineage.last_rejection_check === "string"
450
+ ? lineage.last_rejection_check
451
+ : "";
452
+ if (check === "round_scope_drift") {
453
+ return { consecutiveRejections: 0, lastRejectionCheck: "" };
454
+ }
455
+ return {
456
+ consecutiveRejections: lineage.consecutive_rejections ?? 0,
457
+ lastRejectionCheck: check,
458
+ };
459
+ })(),
460
+ // v4.0.3: an absent baseline stays absent. `{}` must not masquerade as
461
+ // a trusted baseline (there is nothing to compare, and re-capturing it
462
+ // from a modified workspace would launder the change).
463
+ entrypointTrust: (() => {
464
+ const trust = parseFingerprintMap(lineage.entrypoint_trust);
465
+ return Object.keys(trust).length > 0 ? trust : undefined;
466
+ })(),
467
+ ...(operatorAcceptance ? { operatorAcceptance } : {}),
468
+ roundSnapshot,
362
469
  };
363
470
  }
364
471
  /** Apply a prepared round to the session, persist, and build the result.
@@ -376,6 +483,14 @@ export class RoundLifecycle {
376
483
  * `newConsecutiveRejections` comes from the enforcement gate and already
377
484
  * counts the round; it is only honoured when the check is unchanged. */
378
485
  applyRoundCounters(session, pr) {
486
+ // v3.9.9: scope drift is no longer an enforcement row. A session restored
487
+ // from an older runtime may still carry that check's rejection streak;
488
+ // draining it here keeps the deleted check from reaching the
489
+ // max_rejections catch-all as unresolved persistence.
490
+ if (session.lastRejectionCheck === "round_scope_drift") {
491
+ session.lastRejectionCheck = "";
492
+ session.consecutiveRejections = 0;
493
+ }
379
494
  if (pr.action === "reject" && pr.rejectionCheck) {
380
495
  session.consecutiveRejections =
381
496
  pr.rejectionCheck === session.lastRejectionCheck
@@ -388,8 +503,6 @@ export class RoundLifecycle {
388
503
  if (pr.action !== "reject")
389
504
  session.lastRejectionCheck = "";
390
505
  }
391
- if (pr.newLastSelfEval)
392
- session.lastSelfEval = pr.newLastSelfEval;
393
506
  }
394
507
  /** v3.8.3: capture the trusted entrypoint baseline for the round about to be
395
508
  * prepared.
@@ -402,6 +515,9 @@ export class RoundLifecycle {
402
515
  *
403
516
  * Best-effort: an unreadable policy or an unresolvable command must leave
404
517
  * the round without the absolute arm, not fail the preparation. */
518
+ /** v4.0.3: seed the round-start entrypoint baseline. Public so the start
519
+ * path can seed BEFORE the first prompt is delivered; a missing baseline
520
+ * must never be re-derived from an already-modified workspace. */
405
521
  seedEntrypointTrust(session) {
406
522
  try {
407
523
  session.entrypointTrust = captureEntrypointTrust(getPolicy());
@@ -410,68 +526,212 @@ export class RoundLifecycle {
410
526
  session.entrypointTrust = undefined;
411
527
  }
412
528
  }
413
- persistPrepared(session, prepared, roundSuccess, warningsOverride) {
414
- session.evidenceBaseline = prepared.baseline;
529
+ /** v4.0.3: warnings for a resumed round whose trusted baseline is absent.
530
+ * The workspace may already have been modified; re-capturing it would
531
+ * launder the change, so the round runs with the Git-delta arm only. */
532
+ resumeWarnings(session, warnings) {
533
+ const out = [...(warnings ?? [])];
534
+ if (!session.entrypointTrust) {
535
+ out.push("entrypoint_trust_missing: no trusted round-start baseline is held; " +
536
+ "only the Git-delta entrypoint arm runs for this resumed round.");
537
+ }
538
+ return out;
539
+ }
540
+ /** v3.9.7: operator acceptance and branch identity travel with the session
541
+ * cursor (not only the held snapshot). Every prepare carries them forward;
542
+ * a recovery that clears the held snapshot must not reset the branch to
543
+ * root or lose the operator's acceptance surface. */
544
+ prepareOptions(session, branchId) {
545
+ const acceptance = session.operatorAcceptance ?? session.roundSnapshot?.operatorAcceptance;
546
+ const effectiveBranchId = branchId ?? session.roundSnapshot?.branchId;
547
+ return {
548
+ ...(effectiveBranchId ? { branchId: effectiveBranchId } : {}),
549
+ ...(acceptance ? { operatorAcceptance: acceptance } : {}),
550
+ };
551
+ }
552
+ persistPrepared(session, prepared, agentSuccessClaim, warningsOverride,
553
+ /** v4.0.3: a resumed round must NOT re-capture its baseline from a
554
+ * possibly modified workspace. */
555
+ seedTrust = true) {
556
+ // v4.0.5: the shared compile → save → result tail is ATOMIC. If the save
557
+ // fails, the in-memory session must not keep the new snapshot/trust: a
558
+ // caller would otherwise receive a handle for a prompt that was never
559
+ // persisted. The old held snapshot and handle stay valid, and the session
560
+ // becomes resumable.
561
+ const previousSnapshot = session.roundSnapshot;
562
+ const previousTrust = session.entrypointTrust;
415
563
  session.roundSnapshot = prepared.snapshot;
416
- this.seedEntrypointTrust(session);
417
- session.currentPrompt = prepared.prompt;
418
- session.currentLevel = prepared.level;
419
- session.currentWarnings = prepared.warnings;
420
- session.lastCompileResponse = prepared.compileResponse ?? null;
564
+ if (seedTrust)
565
+ this.seedEntrypointTrust(session);
566
+ try {
567
+ this.save(session);
568
+ }
569
+ catch (error) {
570
+ session.roundSnapshot = previousSnapshot;
571
+ session.entrypointTrust = previousTrust;
572
+ if (error instanceof SessionLeaseConflictError)
573
+ throw error;
574
+ logEvent("session_save_failed", {
575
+ loopId: session.loopId,
576
+ round: session.currentRound,
577
+ error: String(error),
578
+ });
579
+ session.status = "stalled";
580
+ try {
581
+ this.save(session);
582
+ }
583
+ catch {
584
+ // The store is still unavailable; the in-memory held facts remain the
585
+ // best available state and the next resume retries the save.
586
+ }
587
+ return {
588
+ sessionId: session.sessionId,
589
+ round: session.currentRound,
590
+ ...this.heldHandle(session),
591
+ ...this.transactionFacts(session),
592
+ prompt: null,
593
+ stopReason: "stalled",
594
+ stopDetail: "The prompt was compiled but the session could not be saved: " +
595
+ `${String(error).slice(0, 300)}. The previous held prompt and ` +
596
+ "handle are unchanged; call loopforge_resume to retry.",
597
+ };
598
+ }
421
599
  policyMetrics.recordStrategy(session.loopId, prepared.level);
422
- this.save(session);
423
600
  return {
424
601
  sessionId: session.sessionId,
425
602
  round: session.currentRound,
426
- roundId: session.roundSnapshot.roundId,
603
+ ...this.heldHandle(session),
604
+ ...this.transactionFacts(session),
605
+ ...this.contextFacts(prepared.snapshot.promptArtifact),
427
606
  prompt: prepared.prompt,
428
607
  level: prepared.level,
429
- roundSuccess,
430
- // v3.8: the posture of the round that just committed — read from the
431
- // transaction's committed result, not recomputed here.
432
- verificationStatus: session.roundSnapshot?.result?.verificationStatus,
433
- warnings: warningsOverride ?? session.currentWarnings ?? [],
608
+ agentSuccessClaim,
609
+ agentOutcome: session.roundSnapshot?.result?.agentOutcome ?? null,
610
+ // v4.0.3: the posture of the round that just committed — read from the
611
+ // transaction's committed result, never recomputed here.
612
+ evidencePosture: session.roundSnapshot?.result?.evidencePosture,
613
+ warnings: warningsOverride ??
614
+ session.roundSnapshot?.promptArtifact?.warnings ??
615
+ prepared.warnings ?? [],
434
616
  submission_guidance: this.guidanceFor(session),
435
617
  };
436
618
  }
437
- restoredPromptResult(session) {
438
- if (!session.currentPrompt)
619
+ /** v4.0.2: the context boundary carried by a prompt-bearing result. It is
620
+ * a PROJECTION of the artifact that produced the prompt — never a second
621
+ * derivation from live session state. */
622
+ contextFacts(artifact) {
623
+ if (!artifact)
624
+ return {};
625
+ return {
626
+ contextMode: artifact.contextMode,
627
+ contextReason: artifact.contextReason,
628
+ };
629
+ }
630
+ /** v3.9.7: execution metadata attached to every held/prepared prompt. These
631
+ * are transport facts, never prompt governance content. v4.0.0: the anchor
632
+ * itself is the separate `submissionHandle`; this bundle owns only the
633
+ * checkpoint facts. */
634
+ transactionFacts(session) {
635
+ const snapshot = session.roundSnapshot;
636
+ if (!snapshot)
637
+ return {};
638
+ const checkpoints = [
639
+ ...(snapshot.beforeCheckpoint ? [snapshot.beforeCheckpoint] : []),
640
+ ...(snapshot.checkpoints ?? []),
641
+ ];
642
+ return {
643
+ ...(checkpoints.length > 0 ? { checkpoints } : {}),
644
+ ...(snapshot.beforeCheckpointError
645
+ ? {
646
+ beforeCheckpointError: {
647
+ code: snapshot.beforeCheckpointError.code,
648
+ detail: snapshot.beforeCheckpointError.detail,
649
+ },
650
+ }
651
+ : {}),
652
+ };
653
+ }
654
+ /** v4.0.0: the transport handle for the current held snapshot, or absent. */
655
+ heldHandle(session) {
656
+ const handle = makeSubmissionHandle(session);
657
+ return handle ? { submissionHandle: handle } : {};
658
+ }
659
+ /** v3.9.7: guidance facts for a redo whose restore is already verified.
660
+ * They are derived from the still-pending committed directive, so a
661
+ * restart returns the same surface as the original recovery response. */
662
+ pendingRecoveryGuidance(session) {
663
+ const pending = loadCommittedHistory(this.store, session.loopId).pendingDirective;
664
+ const directive = pending?.recovery;
665
+ if (!directive)
666
+ return null;
667
+ return {
668
+ pending: true,
669
+ redo_round: directive.target.redo_round,
670
+ checkpoint_id: directive.target.checkpoint_id,
671
+ restore_verified: session.roundSnapshot?.restore?.directiveTransactionId ===
672
+ directive.transaction_id,
673
+ origin: directive.origin,
674
+ reason: directive.reason,
675
+ };
676
+ }
677
+ /** v4.0.0: return the exact stored prompt plus the CURRENT handle. This is
678
+ * the ONE path that re-delivers a prompt without compiling. It fails closed
679
+ * unless the full held predicate holds (phase, round, attempt, rendered
680
+ * prompt), so a committed/terminated/retrying snapshot can never masquerade
681
+ * as held. The warning is appended without mutating the artifact. Public so
682
+ * SessionManager can use it on its pre-queue stale-handle path. */
683
+ heldPromptResult(session, warning) {
684
+ const snapshot = heldPromptSnapshot(session);
685
+ const artifact = snapshot?.promptArtifact;
686
+ if (!snapshot || !artifact)
687
+ return null;
688
+ const handle = makeSubmissionHandle(session);
689
+ if (!handle)
439
690
  return null;
691
+ const warnings = [...(artifact.warnings ?? [])];
692
+ if (warning && !warnings.includes(warning))
693
+ warnings.push(warning);
694
+ // v4.0.3: a held-prompt redelivery carries the most recent committed
695
+ // receipt, so the response it missed is recoverable from history.
696
+ const history = loadCommittedHistory(this.store, session.loopId);
697
+ const latestRound = history.rounds[history.rounds.length - 1];
698
+ const pending = history.pendingDirective;
699
+ const latestRecord = pending &&
700
+ (!latestRound || pending.round >= latestRound.round)
701
+ ? pending
702
+ : latestRound;
703
+ const receipt = latestRecord
704
+ ? deriveCommittedSubmissionView(latestRecord, history.rounds, history.directives)
705
+ : undefined;
440
706
  return {
441
707
  sessionId: session.sessionId,
442
708
  round: session.currentRound,
443
- roundId: session.roundSnapshot?.roundId,
444
- prompt: session.currentPrompt,
445
- level: session.currentLevel ?? "l2",
446
- roundSuccess: undefined,
447
- warnings: (session.currentWarnings ?? []),
448
- submission_guidance: this.guidanceFor(session),
709
+ submissionHandle: handle,
710
+ ...this.transactionFacts(session),
711
+ ...this.contextFacts(artifact),
712
+ prompt: artifact.renderedPrompt,
713
+ level: artifact.level,
714
+ agentSuccessClaim: undefined,
715
+ warnings,
716
+ submission_guidance: this.guidanceFor(session, {
717
+ recovery: this.pendingRecoveryGuidance(session),
718
+ }),
719
+ ...(receipt ? { submission_receipt: receipt } : {}),
449
720
  };
450
721
  }
451
722
  /** Reconcile the crash window where feedback committed but session_state
452
723
  * still points at the old prompt. Returns null when no commit is pending.
453
724
  * v3.7: async — the continue-after-crash branch compiles through
454
725
  * RoundDriver.prepare (the sync prepareSync fallback was removed). */
455
- async reconcileCommittedRound(session) {
726
+ async reconcileCommittedRound(session, inputs) {
456
727
  if (!session.roundSnapshot)
457
728
  return null;
458
- const recovered = new RoundDriver(session.engine, this.store).recover(session.roundSnapshot);
729
+ const recovered = this.driver(session.engine, session.loopId).recover(session.roundSnapshot);
459
730
  if (!recovered)
460
731
  return null;
461
732
  const pr = recovered.result;
462
733
  session.roundSnapshot = recovered.snapshot;
463
734
  this.applyRoundCounters(session, pr);
464
- // L1 (v3.7.x): trajectory push guard must use the SAME judgement as the
465
- // counter guard below (recovered.snapshot.round). Comparing against
466
- // session.currentRound conflates two windows: the crash window (persisted
467
- // length N-1, currentRound N — push needed) and the pause race (pause()
468
- // persisted the incremented counter AND the already-pushed round N —
469
- // length N, currentRound N+1 — pushing again duplicates the entry).
470
- if (pr.shouldPushSuccessTrajectory &&
471
- session.successTrajectory.length < recovered.snapshot.round) {
472
- session.successTrajectory.push(pr.roundSuccess);
473
- }
474
- session.currentPrompt = null;
475
735
  if (pr.action === "stop" || pr.action === "terminate") {
476
736
  // v3.8.3: a replayed termination carries the gate's own reason for the
477
737
  // same reason the live path does (see buildTerminationResult) — the
@@ -482,34 +742,38 @@ export class RoundLifecycle {
482
742
  session.status = reason === "stalled" ? "stalled" : "stopped";
483
743
  this.save(session);
484
744
  void this.notifyTerminal(session, reason);
745
+ // v4.0.4: the crash-recovery stop renders the SAME three fact blocks as
746
+ // the live path. A committed stop also carries the equivalent receipt;
747
+ // a termination never fabricates one.
748
+ const history = loadCommittedHistory(this.store, session.loopId);
749
+ const committedView = pr.action === "stop"
750
+ ? history.rounds.find((view) => view.round === recovered.snapshot.round) ?? null
751
+ : null;
752
+ const receipt = committedView
753
+ ? deriveCommittedSubmissionView(committedView, history.rounds, history.directives)
754
+ : undefined;
485
755
  return {
486
756
  sessionId: session.sessionId,
487
757
  round: session.currentRound,
488
- roundId: recovered.snapshot.roundId,
489
758
  prompt: null,
490
759
  stopReason: reason,
491
- stopDetail: reason === "completed"
492
- ? "Task completed successfully — the agent declared should_continue=false with success=true."
493
- : reason === "blocked"
494
- ? "Agent cannot proceed under current constraints. The task may need revised constraints or human intervention."
495
- : reason === "failed"
496
- ? "Agent declared should_continue=false with success=false — the task was not achieved."
497
- : `Loop stopped: ${reason}.`,
498
- roundSuccess: pr.roundSuccess,
760
+ sessionStatus: session.status,
761
+ stopDetail: renderResultFacts(this.terminalFacts(pr, reason, recovered.snapshot.evaluation ?? null, pr.action === "stop"), "terminal").join("\n"),
762
+ agentSuccessClaim: pr.agentSuccessClaim,
763
+ agentOutcome: pr.agentOutcome,
764
+ agentShouldContinue: recovered.snapshot.evaluation?.should_continue,
765
+ ...(receipt ? { submission_receipt: receipt } : {}),
499
766
  };
500
767
  }
501
- if (pr.action === "backtrack") {
502
- // Backtrack committed but prompt not delivered (crash window).
503
- // Reset round counter to the restore target + 1 so the next
504
- // resume / unpause compiles from the correct restored state.
505
- const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
506
- session.currentRound = restoreTarget + 1;
768
+ if (pr.action === "backtrack" || pr.action === "recovery") {
769
+ // Backtrack/directive committed but the redo prompt was not delivered
770
+ // (crash window). The pending-directive-first path in resume/unpause
771
+ // performs the restore; here we only align the cursor.
772
+ session.currentRound = pr.recovery?.target.redo_round ?? session.currentRound;
507
773
  session.consecutiveRejections = 0;
508
774
  session.lastRejectionCheck = "";
509
- session.lastSelfEval = undefined;
510
- session.currentPrompt = null;
511
775
  this.save(session);
512
- // Return null — caller (resume/unpause) will recompile normally
776
+ // Return null — caller (resume/unpause) will restore/recompile.
513
777
  return null;
514
778
  }
515
779
  if (pr.action !== "continue")
@@ -522,9 +786,78 @@ export class RoundLifecycle {
522
786
  if (session.currentRound <= recovered.snapshot.round) {
523
787
  session.currentRound = recovered.snapshot.round + 1;
524
788
  }
525
- const request = buildLoopRequest(session);
526
- const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, session.currentRound);
527
- if (!prepared) {
789
+ // v4.0.5: a host instruction explicitly supplied with THIS resume call
790
+ // carries into the next round's compile. It is never inherited from an
791
+ // older artifact here.
792
+ const request = buildLoopRequest(session, inputs);
793
+ let prepared;
794
+ try {
795
+ prepared = await this.driver(session.engine, session.loopId).prepare(request, session.loopId, session.currentRound, this.prepareOptions(session));
796
+ }
797
+ catch (error) {
798
+ if (error instanceof ProjectionCoverageError) {
799
+ return this.stalledAfterCommit(session.sessionId, session, pr.agentSuccessClaim, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
800
+ }
801
+ if (error instanceof CompileFailureError) {
802
+ return this.stalledAfterCommit(session.sessionId, session, pr.agentSuccessClaim, `The committed round is held, but the next prompt could not be compiled: ${error.message}. Call loopforge_resume to retry.`);
803
+ }
804
+ throw error;
805
+ }
806
+ return this.persistPrepared(session, {
807
+ prompt: prepared.prompt,
808
+ level: prepared.level,
809
+ baseline: prepared.evidenceBaseline,
810
+ snapshot: prepared.snapshot,
811
+ warnings: prepared.warnings ?? [],
812
+ }, pr.agentSuccessClaim);
813
+ }
814
+ /** v3.9.7: a committed directive with no verified redo snapshot must be
815
+ * completed BEFORE any ordinary compile/held-prompt path. The restore and
816
+ * redo prepare are idempotent; a verified held redo prompt is returned
817
+ * without re-checking the workspace (the Agent may already have worked). */
818
+ async completePendingRecovery(session, inputs) {
819
+ const pending = loadCommittedHistory(this.store, session.loopId).pendingDirective;
820
+ if (!pending?.recovery)
821
+ return null;
822
+ const directive = pending.recovery;
823
+ const verified = session.roundSnapshot?.restore?.directiveTransactionId ===
824
+ directive.transaction_id &&
825
+ session.roundSnapshot.round === directive.target.redo_round;
826
+ if (verified) {
827
+ // v4.0.5: the verified redo prompt goes through the SAME held-prompt
828
+ // entry as every other resume/unpause path, so a new instruction is
829
+ // never silently dropped on this branch.
830
+ return this.heldPromptWithInputs(session, inputs);
831
+ }
832
+ session.currentRound = directive.target.redo_round;
833
+ session.roundSnapshot = undefined;
834
+ session.status = "running";
835
+ const pr = {
836
+ action: directive.origin === "agent" ? "recovery" : "backtrack",
837
+ origin: directive.origin,
838
+ reason: directive.reason,
839
+ recovery: directive,
840
+ // v4.0.3: replay the directive's compact trigger findings instead of
841
+ // losing them with the process that produced the backtrack.
842
+ verificationFlags: [...(directive.trigger_findings ?? [])],
843
+ agentSuccessClaim: false,
844
+ agentOutcome: null,
845
+ gateContradicted: false,
846
+ evidencePosture: rollbackEvidencePosture(directive.target.redo_round),
847
+ newConsecutiveRejections: 0,
848
+ shouldPushSuccessTrajectory: false,
849
+ };
850
+ // The receipt base is the committed directive's own snapshot, not a
851
+ // freshly synthesized empty transaction. It is readable history (the
852
+ // legacy guard already ran) and carries the operator acceptance the
853
+ // recovery preserved.
854
+ const source = pending.sourceEntry;
855
+ const lineage = isRecord(source.loop_lineage) ? source.loop_lineage : {};
856
+ const transaction = isRecord(lineage.round_transaction)
857
+ ? lineage.round_transaction
858
+ : {};
859
+ const snapshot = parseRoundTransactionSnapshot(transaction.snapshot);
860
+ if (!snapshot) {
528
861
  session.status = "stalled";
529
862
  this.save(session);
530
863
  return {
@@ -532,129 +865,313 @@ export class RoundLifecycle {
532
865
  round: session.currentRound,
533
866
  prompt: null,
534
867
  stopReason: "stalled",
535
- stopDetail: "RoundDriver.prepare returned null — prompt compilation failed during crash recovery. The session state may be inconsistent.",
868
+ stopDetail: "The committed rollback directive is unreadable; its history must be repaired before the restore can continue.",
536
869
  };
537
870
  }
871
+ const beforeFacts = this.beforeFactsFor(session);
872
+ return this.buildBacktrackResult(session.sessionId, session, pr, {
873
+ snapshot,
874
+ selfEval: {
875
+ outcome: "failed",
876
+ output_summary: "",
877
+ constraint_violations: [],
878
+ should_continue: true,
879
+ },
880
+ pr,
881
+ actualEvidence: [],
882
+ diagnostics: [],
883
+ submittedFields: [],
884
+ beforeContractId: beforeFacts.contractId,
885
+ beforeSubGoals: beforeFacts.subGoals,
886
+ recoveryDirective: directive,
887
+ replayed: true,
888
+ }, inputs);
889
+ }
890
+ /** v4.0.5: the zero-side-effect refusal for a cold handoff whose held retry
891
+ * facts are missing or inconsistent. Nothing is saved, compiled or
892
+ * advanced; the held snapshot and handle are unchanged. */
893
+ invocationRecoveryUnavailable(session, detail) {
894
+ return {
895
+ sessionId: session.sessionId,
896
+ round: session.currentRound,
897
+ ...this.heldHandle(session),
898
+ ...this.transactionFacts(session),
899
+ prompt: null,
900
+ submissionError: {
901
+ code: "invalid_argument",
902
+ details: { reason_code: "invocation_recovery_unavailable", detail },
903
+ },
904
+ stopDetail: detail,
905
+ };
906
+ }
907
+ /** v4.0.5: map a compile failure onto the existing stalled/coverage result.
908
+ * Returns null for an unexpected error so the caller can rethrow it. */
909
+ compileStalledResult(session, error, detail) {
910
+ if (error instanceof ProjectionCoverageError) {
911
+ return this.stalledAfterCommit(session.sessionId, session, false, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
912
+ }
913
+ if (error instanceof CompileFailureError) {
914
+ return this.stalledAfterCommit(session.sessionId, session, false, `${detail}: ${error.message}. The previous prompt and handle are ` +
915
+ "unchanged; call loopforge_resume to retry.");
916
+ }
917
+ return null;
918
+ }
919
+ /** v4.0.5: ONE held-prompt entry for resume/unpause/recovery. With no new
920
+ * instruction it re-delivers the stored prompt and handle unchanged; with a
921
+ * new instruction it recompiles the SAME round/attempt and preserves the
922
+ * held context boundary (including an existing invocation recovery). */
923
+ async heldPromptWithInputs(session, inputs) {
924
+ const held = heldPromptSnapshot(session);
925
+ if (!held?.promptArtifact)
926
+ return null;
927
+ const instruction = inputs?.currentUserInstruction;
928
+ if (instruction === undefined ||
929
+ instruction === held.promptArtifact.currentUserInstruction) {
930
+ return this.heldPromptResult(session);
931
+ }
932
+ return this.recompileHeldPrompt(session, held, {
933
+ currentUserInstruction: instruction,
934
+ });
935
+ }
936
+ /** v4.0.5: the public `loopforge_next` instruction-recompile entry. It only
937
+ * recompiles the held prompt for a new invocation instruction: no
938
+ * submission, no round/attempt change, no evidence collection. The handle
939
+ * was validated by SessionManager before its queue and lease heartbeat. */
940
+ async recompileHeldWithInstruction(session, instruction) {
941
+ const held = heldPromptSnapshot(session);
942
+ if (!held?.promptArtifact) {
943
+ return {
944
+ sessionId: session.sessionId,
945
+ round: session.currentRound,
946
+ prompt: null,
947
+ submissionError: {
948
+ code: "invalid_argument",
949
+ details: {
950
+ reason_code: "no_held_prompt",
951
+ detail: "No held prompt exists to recompile.",
952
+ },
953
+ },
954
+ };
955
+ }
956
+ if (instruction === held.promptArtifact.currentUserInstruction) {
957
+ // Idempotent: the exact same instruction returns the same prompt and
958
+ // handle without compiling again.
959
+ return this.heldPromptResult(session) ?? {
960
+ sessionId: session.sessionId,
961
+ round: session.currentRound,
962
+ prompt: null,
963
+ };
964
+ }
965
+ return this.recompileHeldPrompt(session, held, {
966
+ currentUserInstruction: instruction,
967
+ });
968
+ }
969
+ /** v4.0.5: compile a held prompt again without collecting evidence, writing
970
+ * the Vault, changing attempt/round or touching retry counters. */
971
+ async recompileHeldPrompt(session, held, inputs) {
972
+ const request = buildLoopRequest(session, inputs);
973
+ if (inputs.invocationRecovery)
974
+ request.invocation_recovery = true;
975
+ let prepared;
976
+ try {
977
+ prepared = await this.driver(session.engine, session.loopId)
978
+ .recompileHeld(request, held);
979
+ }
980
+ catch (error) {
981
+ const stalled = this.compileStalledResult(session, error, "The held prompt could not be recompiled");
982
+ if (stalled)
983
+ return stalled;
984
+ throw error;
985
+ }
986
+ return this.persistPrepared(session, {
987
+ prompt: prepared.prompt,
988
+ level: prepared.level,
989
+ baseline: prepared.evidenceBaseline,
990
+ snapshot: prepared.snapshot,
991
+ warnings: prepared.warnings ?? [],
992
+ }, undefined, this.resumeWarnings(session, prepared.warnings), false);
993
+ }
994
+ /** v4.0.5: compile the retry prompt for a rejected attempt that has no held
995
+ * prompt (its retry compile failed before). This is the one path that
996
+ * creates the next attempt for such a session. */
997
+ async prepareRetryWithInputs(session, rejected, opts) {
998
+ const retryContext = rejected.retryContext;
999
+ if (!retryContext) {
1000
+ throw new Error("prepareRetryWithInputs requires a retry context");
1001
+ }
1002
+ const request = buildLoopRequest(session, opts);
1003
+ let prepared;
1004
+ try {
1005
+ prepared = await this.driver(session.engine, session.loopId).prepareRetry(request, rejected, retryContext.rejectionNotice, session.consecutiveRejections, opts.invocationRecovery ? { invocationRecovery: true } : {});
1006
+ }
1007
+ catch (error) {
1008
+ const stalled = this.compileStalledResult(session, error, "The retry prompt could not be compiled");
1009
+ if (stalled)
1010
+ return stalled;
1011
+ throw error;
1012
+ }
538
1013
  return this.persistPrepared(session, {
539
1014
  prompt: prepared.prompt,
540
1015
  level: prepared.level,
541
1016
  baseline: prepared.evidenceBaseline,
542
1017
  snapshot: prepared.snapshot,
543
- warnings: prepared?.warnings ?? [],
544
- }, pr.roundSuccess);
1018
+ warnings: prepared.warnings ?? [],
1019
+ }, undefined, this.resumeWarnings(session, prepared.warnings), false);
1020
+ }
1021
+ /** v4.0.5: recompile the SAME retry attempt when the host declared the
1022
+ * original invocation lost, or when a submitted retry prompt never existed
1023
+ * (rejected snapshot). Returns null when this session has no retry material
1024
+ * or when the held prompt is already a clean context (idempotent no-op). */
1025
+ async recompileRetryAttempt(session, inputs) {
1026
+ const invocationLost = inputs.invocationLost === true;
1027
+ if (!invocationLost && inputs.currentUserInstruction === undefined) {
1028
+ return null;
1029
+ }
1030
+ const held = session.roundSnapshot?.round === session.currentRound
1031
+ ? session.roundSnapshot
1032
+ : undefined;
1033
+ // A rejected attempt whose retry prompt never compiled: there is nothing
1034
+ // to re-deliver, so the retry prompt is compiled for real. Instruction
1035
+ // inheritance is scoped to this same logical round.
1036
+ if (held?.phase === "rejected" && held.retryContext) {
1037
+ const instruction = inputs.currentUserInstruction ??
1038
+ held.promptArtifact?.currentUserInstruction;
1039
+ return this.prepareRetryWithInputs(session, held, {
1040
+ ...(instruction !== undefined
1041
+ ? { currentUserInstruction: instruction }
1042
+ : {}),
1043
+ invocationRecovery: invocationLost && session.consecutiveRejections < 2,
1044
+ });
1045
+ }
1046
+ // Only an in-context retry has an invocation to recover. A held clean
1047
+ // prompt (first/next round, escalation, recovery redo) is already the
1048
+ // cold-context prompt, so `invocationLost` is an idempotent no-op.
1049
+ if (held?.phase !== "prompted" || !held.promptArtifact)
1050
+ return null;
1051
+ if (held.promptArtifact.contextMode !== "same_round_retry")
1052
+ return null;
1053
+ // A plain instruction recompile is handled by the unified held-prompt
1054
+ // entry; only the lost invocation rebuilds the context boundary.
1055
+ if (!invocationLost)
1056
+ return null;
1057
+ if (!held.retryContext) {
1058
+ return this.invocationRecoveryUnavailable(session, "The held in-context retry has no retry context, so the same attempt " +
1059
+ "cannot be recompiled in cold context.");
1060
+ }
1061
+ if (held.retryContext.attempt !== held.attempt ||
1062
+ held.retryContext.rejectedAttempt + 1 !== held.attempt) {
1063
+ return this.invocationRecoveryUnavailable(session, "The held retry context does not match the held attempt, so the same " +
1064
+ "attempt cannot be recompiled in cold context.");
1065
+ }
1066
+ if (session.consecutiveRejections >= 2) {
1067
+ return this.invocationRecoveryUnavailable(session, "The retry has reached the rejection escalation threshold; it is " +
1068
+ "already clean context and is not rewritten as an invocation recovery.");
1069
+ }
1070
+ const instruction = inputs.currentUserInstruction ??
1071
+ held.promptArtifact.currentUserInstruction;
1072
+ return this.recompileHeldPrompt(session, held, {
1073
+ ...(instruction !== undefined ? { currentUserInstruction: instruction } : {}),
1074
+ invocationRecovery: true,
1075
+ });
1076
+ }
1077
+ /** A rejected round still owns its original before-image. Recover its next
1078
+ * attempt through the same retry preparation used by a live rejection;
1079
+ * ordinary prepare would recapture evidence and reset snapshot.attempt.
1080
+ * v4.0.5: the instruction travels with the compile and is inherited from
1081
+ * the rejected prompt within the same logical round. */
1082
+ prepareMissingPrompt(session, inputs) {
1083
+ const driver = this.driver(session.engine, session.loopId);
1084
+ const rejected = session.roundSnapshot;
1085
+ if (rejected?.phase === "rejected" && rejected.retryContext) {
1086
+ const instruction = inputs?.currentUserInstruction ??
1087
+ rejected.promptArtifact?.currentUserInstruction;
1088
+ const request = buildLoopRequest(session, instruction !== undefined ? { currentUserInstruction: instruction } : undefined);
1089
+ return driver.prepareRetry(request, rejected, rejected.retryContext.rejectionNotice, session.consecutiveRejections);
1090
+ }
1091
+ return driver.prepare(buildLoopRequest(session, inputs), session.loopId, session.currentRound, this.prepareOptions(session));
545
1092
  }
546
1093
  /** Resume tail: the session has been reconstructed and registered. Reconcile
547
1094
  * a committed-but-undelivered round, restore the held prompt, or recover a
548
1095
  * missing prompt from current round state. v3.7: async — compilation runs
549
1096
  * through RoundDriver.prepare like unpause (the prepareSync fallback was
550
1097
  * removed); a failed compile now degrades to the same stalled terminal
551
- * result unpause returns instead of persisting a null prompt. */
552
- async resume(session) {
553
- const reconciled = await this.reconcileCommittedRound(session);
1098
+ * result unpause returns instead of persisting a null prompt.
1099
+ * v4.0.5: an `invocationLost` retry is recompiled as a cold-context prompt
1100
+ * for the SAME logical round/attempt, before the ordinary held-prompt path. */
1101
+ async resume(session, inputs) {
1102
+ const pendingRecovery = await this.completePendingRecovery(session, inputs);
1103
+ if (pendingRecovery)
1104
+ return pendingRecovery;
1105
+ const reconciled = await this.reconcileCommittedRound(session, inputs);
554
1106
  if (reconciled)
555
1107
  return reconciled;
556
- const restored = this.restoredPromptResult(session);
1108
+ const retryRecovery = await this.recompileRetryAttempt(session, inputs ?? {});
1109
+ if (retryRecovery)
1110
+ return retryRecovery;
1111
+ const restored = await this.heldPromptWithInputs(session, inputs);
557
1112
  if (restored)
558
1113
  return restored;
559
1114
  // Missing held prompt: compile (async) once, then persist it.
560
- const request = buildLoopRequest(session);
561
- const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, session.currentRound);
562
- if (!prepared) {
563
- session.status = "stopped";
564
- this.save(session);
565
- void this.notifyTerminal(session, "stalled");
566
- return {
567
- sessionId: session.sessionId,
568
- round: session.currentRound,
569
- prompt: null,
570
- stopReason: "stalled",
571
- stopDetail: "RoundDriver.prepare returned null during resume — the compiler could not produce a prompt.",
572
- };
1115
+ let prepared;
1116
+ try {
1117
+ prepared = await this.prepareMissingPrompt(session, inputs);
1118
+ }
1119
+ catch (error) {
1120
+ if (error instanceof ProjectionCoverageError) {
1121
+ return this.stalledAfterCommit(session.sessionId, session, false, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
1122
+ }
1123
+ if (error instanceof CompileFailureError) {
1124
+ return this.stalledAfterCommit(session.sessionId, session, false, `The compiler could not produce a prompt during resume: ${error.message}. Call loopforge_resume to retry.`);
1125
+ }
1126
+ throw error;
573
1127
  }
574
1128
  return this.persistPrepared(session, {
575
1129
  prompt: prepared.prompt,
576
1130
  level: prepared.level,
577
1131
  baseline: prepared.evidenceBaseline,
578
1132
  snapshot: prepared.snapshot,
579
- warnings: prepared?.warnings ?? [],
580
- }, false);
1133
+ warnings: prepared.warnings ?? [],
1134
+ }, false, this.resumeWarnings(session, prepared.warnings), false);
581
1135
  }
582
1136
  /** Unpause tail: the session has been reconstructed and registered with
583
- * status "running". Refresh async evidence, reconcile a committed round,
584
- * restore the held prompt, or compile the next prompt. */
585
- async unpause(session) {
586
- // Replace the sync-fallback evidence baseline with async evidence
587
- // so resumed sessions don't silently drop async provider data.
588
- try {
589
- const asyncEvidence = await EvidenceCollector.fromPolicy().collectAsync({
590
- loopId: session.loopId,
591
- phase: "before",
592
- });
593
- if (asyncEvidence.length > 0) {
594
- session.evidenceBaseline = asyncEvidence;
595
- if (!session.roundSnapshot?.beforeEvidence?.length) {
596
- session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
597
- }
598
- }
599
- }
600
- catch {
601
- // Async evidence is best-effort; fall back to sync baseline.
602
- }
603
- const reconciled = await this.reconcileCommittedRound(session);
1137
+ * status "running". Reconcile a committed round, restore the held prompt,
1138
+ * or compile the next prompt from the committed window. v4.0.5: the same
1139
+ * invocation inputs and cold-handoff recompile apply here. */
1140
+ async unpause(session, inputs) {
1141
+ const pendingRecovery = await this.completePendingRecovery(session, inputs);
1142
+ if (pendingRecovery)
1143
+ return pendingRecovery;
1144
+ const reconciled = await this.reconcileCommittedRound(session, inputs);
604
1145
  if (reconciled)
605
1146
  return reconciled;
606
- const restored = this.restoredPromptResult(session);
1147
+ const retryRecovery = await this.recompileRetryAttempt(session, inputs ?? {});
1148
+ if (retryRecovery)
1149
+ return retryRecovery;
1150
+ const restored = await this.heldPromptWithInputs(session, inputs);
607
1151
  if (restored) {
608
1152
  this.save(session);
609
1153
  return restored;
610
1154
  }
611
- // Compile the next prompt from the current round state
612
- const lcr = makeLoopCompileRequest({
613
- loop_id: session.loopId,
614
- round: session.currentRound,
615
- goal_id: "",
616
- task: session.task,
617
- domain: undefined,
618
- plan_source: null,
619
- constraints_from_plan: [],
620
- });
621
- const compileRequest = {
622
- task: session.task,
623
- mode: Mode.LOOP_COMPILE,
624
- feedback: null,
625
- skill_name: null,
626
- task_id: null,
627
- loop_id: lcr.loop_id,
628
- round: lcr.round,
629
- goal_id: lcr.goal_id,
630
- domain: lcr.domain ?? "",
631
- plan_source: lcr.plan_source ?? null,
632
- constraints_from_plan: lcr.constraints_from_plan ?? [],
633
- max_rounds: session.maxRounds,
634
- // Preserve an in-flight retry's attempt; a next-round compile resets
635
- // it because the held snapshot belongs to the previous round.
636
- attempt: session.roundSnapshot?.round === session.currentRound
637
- ? session.roundSnapshot.attempt
638
- : 1,
639
- consecutive_rejections: session.consecutiveRejections,
640
- };
641
- const prepared = await new RoundDriver(session.engine, this.store).prepare(compileRequest, session.loopId, session.currentRound);
642
- if (!prepared) {
643
- session.status = "stopped";
644
- this.save(session);
645
- void this.notifyTerminal(session, "stalled");
646
- 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." };
1155
+ let prepared;
1156
+ try {
1157
+ prepared = await this.prepareMissingPrompt(session, inputs);
1158
+ }
1159
+ catch (error) {
1160
+ if (error instanceof ProjectionCoverageError) {
1161
+ return this.stalledAfterCommit(session.sessionId, session, false, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
1162
+ }
1163
+ if (error instanceof CompileFailureError) {
1164
+ return this.stalledAfterCommit(session.sessionId, session, false, `The compiler could not produce a prompt during unpause: ${error.message}. Call loopforge_resume to retry.`);
1165
+ }
1166
+ throw error;
647
1167
  }
648
1168
  return this.persistPrepared(session, {
649
1169
  prompt: prepared.prompt,
650
1170
  level: prepared.level,
651
1171
  baseline: prepared.evidenceBaseline,
652
1172
  snapshot: prepared.snapshot,
653
- warnings: prepared?.warnings ?? [],
654
- }, undefined,
655
- // Historical contract: unpause returns an empty warnings array even
656
- // though the session carries the compiled warnings.
657
- []);
1173
+ warnings: prepared.warnings ?? [],
1174
+ }, undefined, this.resumeWarnings(session, prepared.warnings), false);
658
1175
  }
659
1176
  /** Record a gate_opened entry when an accepted round reports a high-risk
660
1177
  * blocker (USER_RISK hit). Idempotent per gate. The gate is a record
@@ -666,8 +1183,8 @@ export class RoundLifecycle {
666
1183
  if (gate.kind !== "user")
667
1184
  return;
668
1185
  const taskId = `loop:${session.loopId}:gate:${id}`;
669
- const existing = queryLoopEntries(this.store, session.loopId, { prefix: taskId })
670
- .some((entry) => entry.task_id === taskId);
1186
+ const existing = loadCommittedHistory(this.store, session.loopId)
1187
+ .entries.some((entry) => entry.task_id === taskId);
671
1188
  if (existing)
672
1189
  return;
673
1190
  const entry = {
@@ -688,7 +1205,9 @@ export class RoundLifecycle {
688
1205
  }
689
1206
  /** Undecided user gate descriptions for a loop (recorded but unresolved). */
690
1207
  listOpenGateDescriptions(loopId) {
691
- const entries = queryLoopEntries(this.store, loopId, { prefix: `loop:${loopId}:gate:` });
1208
+ const gatePrefix = `loop:${loopId}:gate:`;
1209
+ const entries = loadCommittedHistory(this.store, loopId).entries
1210
+ .filter((entry) => String(entry.task_id ?? "").startsWith(gatePrefix));
692
1211
  const decided = new Set(entries.filter((entry) => entry.task_type === "gate_decision")
693
1212
  .map((entry) => String(entry.gate_id ?? "")));
694
1213
  return entries
@@ -705,24 +1224,21 @@ export class RoundLifecycle {
705
1224
  }
706
1225
  /** Execute the round transaction and apply per-rule rejection tracking.
707
1226
  * MUTATES: session.roundSnapshot, session.consecutiveRejections,
708
- * session.lastRejectionCheck, session.lastSelfEval,
709
- * session.successTrajectory */
710
- async executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields) {
711
- const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
712
- const completed = await new RoundDriver(session.engine, this.store).complete({
1227
+ * session.lastRejectionCheck */
1228
+ async executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields, history) {
1229
+ const snapshot = session.roundSnapshot;
1230
+ if (!snapshot) {
1231
+ throw new Error("No held round transaction — the submission boundary must reject this attempt.");
1232
+ }
1233
+ const completed = await this.driver(session.engine, session.loopId).complete({
713
1234
  snapshot,
714
1235
  loopId: session.loopId,
715
1236
  task: session.task,
716
1237
  maxRounds: session.maxRounds,
1238
+ ...(history ? { history } : {}),
717
1239
  selfEval,
718
- lastSelfEval: session.lastSelfEval,
719
1240
  consecutiveRejections: session.consecutiveRejections,
720
1241
  lastRejectionCheck: session.lastRejectionCheck,
721
- successTrajectory: session.successTrajectory,
722
- backtrackSkippedFiles: session.backtrackSkippedFiles,
723
- backtrackSkippedFingerprints: session.backtrackSkippedFingerprints,
724
- backtrackTargetGitHead: session.backtrackTargetGitHead,
725
- backtrackTargetDirty: session.backtrackTargetDirty,
726
1242
  entrypointTrust: session.entrypointTrust,
727
1243
  normalizationDiagnostics,
728
1244
  submittedFields,
@@ -732,14 +1248,6 @@ export class RoundLifecycle {
732
1248
  session.roundSnapshot = outcome.snapshot;
733
1249
  const pr = outcome.result;
734
1250
  this.applyRoundCounters(session, pr);
735
- // L1 (v3.7.x): never push a REPLAYED outcome onto the trajectory. When a
736
- // crash/exception window left the committed round undelivered, the replay
737
- // re-executes the same round in memory — its success was already recorded
738
- // by the original attempt (crash recovery re-pushes through
739
- // reconcileCommittedRound instead, which uses the length-vs-round guard).
740
- if (pr.shouldPushSuccessTrajectory && !completed.outcome.replayed) {
741
- session.successTrajectory.push(pr.roundSuccess);
742
- }
743
1251
  return {
744
1252
  pr,
745
1253
  verificationFlags: pr.verificationFlags,
@@ -749,169 +1257,386 @@ export class RoundLifecycle {
749
1257
  }
750
1258
  /** Build a rejection result: compile a retry prompt, persist, return.
751
1259
  * MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
752
- async buildRejectionResult(sessionId, session, pr, verificationFlags, receiptBase) {
1260
+ async buildRejectionResult(sessionId, session, pr, verificationFlags, receiptBase, inputs) {
1261
+ const rejectedSnapshot = session.roundSnapshot;
1262
+ if (!rejectedSnapshot) {
1263
+ throw new Error("A rejected attempt must keep its held transaction snapshot.");
1264
+ }
1265
+ // v4.0.3: persist the retry facts BEFORE compiling the retry prompt. A
1266
+ // compile failure (or a process restart) must not lose the attempt,
1267
+ // notice, findings or transient posture.
1268
+ const retryContext = {
1269
+ attempt: rejectedSnapshot.attempt + 1,
1270
+ rejectedAttempt: rejectedSnapshot.attempt,
1271
+ rejectionNotice: pr.rejectionPrompt ?? "",
1272
+ verificationFlags: [...verificationFlags],
1273
+ evidencePosture: pr.evidencePosture,
1274
+ rejectedAt: Date.now(),
1275
+ };
1276
+ session.roundSnapshot = { ...rejectedSnapshot, retryContext };
1277
+ try {
1278
+ this.save(session);
1279
+ }
1280
+ catch (error) {
1281
+ return this.stalledAfterCommit(sessionId, session, false, `The rejected attempt could not be persisted: ${String(error).slice(0, 300)}. ` +
1282
+ "Call loopforge_resume to retry; the rejection facts are still held in memory.");
1283
+ }
753
1284
  // In-flight attempt findings are authority for THIS attempt, not
754
- // committed history: write them explicitly on the retry request.
755
- const retryRequest = buildLoopRequest(session);
1285
+ // committed history: buildLoopRequest now projects them from retryContext.
1286
+ // v4.0.5: a same-round retry inherits the rejected prompt's instruction
1287
+ // unless the host supplied a new one for this submission.
1288
+ const inheritedInstruction = inputs?.currentUserInstruction ??
1289
+ rejectedSnapshot.promptArtifact?.currentUserInstruction;
1290
+ const retryRequest = buildLoopRequest(session, inheritedInstruction !== undefined
1291
+ ? { currentUserInstruction: inheritedInstruction }
1292
+ : undefined);
756
1293
  retryRequest.verification_flags = verificationFlags;
757
- const preparedRetry = await new RoundDriver(session.engine, this.store).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
758
- if (!preparedRetry) {
759
- session.status = "stalled";
760
- session.currentPrompt = null;
761
- this.save(session);
762
- return {
763
- sessionId,
764
- round: session.currentRound,
765
- roundId: session.roundSnapshot?.roundId,
766
- prompt: null,
767
- stopReason: "stalled",
768
- stopDetail: "RoundDriver.prepareRetry returned null — the retry prompt could not be compiled.",
769
- };
1294
+ retryRequest.rejection_notice = pr.rejectionPrompt ?? "";
1295
+ let preparedRetry;
1296
+ try {
1297
+ preparedRetry = await this.driver(session.engine, session.loopId).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
1298
+ }
1299
+ catch (error) {
1300
+ if (error instanceof ProjectionCoverageError) {
1301
+ return this.stalledAfterCommit(sessionId, session, false, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
1302
+ }
1303
+ if (error instanceof CompileFailureError) {
1304
+ return this.stalledAfterCommit(sessionId, session, false, `The retry prompt could not be compiled: ${error.message}. Call loopforge_resume to retry.`);
1305
+ }
1306
+ throw error;
770
1307
  }
771
1308
  session.roundSnapshot = preparedRetry.snapshot;
772
- session.currentPrompt = preparedRetry.prompt;
773
- session.currentLevel = preparedRetry.level;
774
- session.currentWarnings = preparedRetry.warnings ?? [];
775
- session.lastCompileResponse = preparedRetry.compileResponse ?? null;
776
1309
  this.save(session);
1310
+ const retryArtifact = preparedRetry.artifact ??
1311
+ preparedRetry.compileResponse?.prompt_artifact ?? null;
777
1312
  return {
778
1313
  sessionId,
779
1314
  round: session.currentRound,
780
- roundId: session.roundSnapshot.roundId,
1315
+ ...this.heldHandle(session),
1316
+ ...this.transactionFacts(session),
1317
+ ...this.contextFacts(retryArtifact),
781
1318
  prompt: preparedRetry.prompt,
782
1319
  level: preparedRetry.level,
1320
+ agentSuccessClaim: pr.agentSuccessClaim,
1321
+ agentOutcome: pr.agentOutcome,
783
1322
  enforcementAction: "reject",
784
- verificationStatus: pr.verificationStatus,
1323
+ evidencePosture: pr.evidencePosture,
785
1324
  enforcementReason: pr.enforcementReason,
786
1325
  submission_guidance: this.guidanceFor(session, {
787
1326
  verificationFlags: pr.verificationFlags,
788
1327
  }),
789
1328
  submission_receipt: this.buildReceipt(receiptBase, {
790
1329
  disposition: "rejected",
791
- nextArtifact: preparedRetry.artifact ??
792
- preparedRetry.compileResponse?.prompt_artifact ?? null,
1330
+ nextArtifact: retryArtifact,
793
1331
  afterSubGoals: null,
794
1332
  constraintsActiveAfter: null,
795
1333
  contractAfterId: receiptBase.beforeContractId,
796
1334
  }),
797
1335
  };
798
1336
  }
799
- /** v2.10: Build a backtrack result — roll back to last clean round.
800
- * Resets the round counter to the restore target + 1, merges preserved
801
- * discoveries, compiles from the restored state, and injects the
802
- * backtrack prompt at the top.
803
- * MUTATES: session.currentRound, session.roundSnapshot,
804
- * session.currentPrompt, session.currentLevel,
805
- * session.consecutiveRejections, session.lastSelfEval */
806
- async buildBacktrackResult(sessionId, session, pr, receiptBase) {
807
- // v3.8: LoopForge never mutates the working tree. The backtrack prompt
808
- // carries the restore facts (files, fingerprints, target HEAD) and the
809
- // verification gate enforces the check on the redo submission; the agent
810
- // executes the restore itself. The former `backtrack_auto_restore` policy
811
- // and its git stash/reset implementation were deleted.
812
- const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
813
- const newRound = restoreTarget + 1;
814
- logEvent("session_backtrack", {
1337
+ /** v3.9.7: the workspace/state-dir facts shared by restore preflight and
1338
+ * execution. The state directory is never part of the restored boundary. */
1339
+ restoreContext() {
1340
+ const workspace = process.cwd();
1341
+ let stateDir;
1342
+ try {
1343
+ stateDir = resolveStateDirectory(workspace, getPolicy().backend.root_dir);
1344
+ }
1345
+ catch (error) {
1346
+ throw new RestoreFailure("state_directory_invalid", `The runtime state directory could not be resolved: ${String(error)}`);
1347
+ }
1348
+ const exclude = (path) => {
1349
+ const absolute = resolve(workspace, path);
1350
+ const rel = relative(stateDir, absolute);
1351
+ return rel === "" || (!rel.startsWith(`..${sep}`) && rel !== "..");
1352
+ };
1353
+ return { workspace, stateDir, exclude };
1354
+ }
1355
+ /** Execute the committed directive's restore, verify it, then compile the
1356
+ * redo from the SAME baseline. The directive is never rewritten. */
1357
+ async runRestoreAndPrepare(session, directive, extra = {}) {
1358
+ const store = this.checkpointStoreFor?.(session.loopId);
1359
+ if (!store) {
1360
+ return {
1361
+ ok: false,
1362
+ result: this.restoreFailedResult(session, directive, "store_unavailable", "no workspace checkpoint store is attached to this runtime"),
1363
+ };
1364
+ }
1365
+ const manifest = store.readManifest(directive.target.checkpoint_id);
1366
+ const ref = {
1367
+ checkpointId: directive.target.checkpoint_id,
1368
+ contentHash: directive.target.content_hash,
1369
+ role: directive.target.role,
1370
+ round: directive.target.round,
1371
+ roundId: directive.target.round_id,
1372
+ transactionId: directive.target.transaction_id,
1373
+ branchId: session.roundSnapshot?.branchId ?? rootBranchId(session.loopId),
1374
+ redoRound: directive.target.redo_round,
1375
+ createdAt: Date.now(),
1376
+ };
1377
+ const validated = validateCheckpointManifest(ref, manifest);
1378
+ if (!validated.ok) {
1379
+ return {
1380
+ ok: false,
1381
+ result: this.restoreFailedResult(session, directive, validated.reason, validated.detail),
1382
+ };
1383
+ }
1384
+ const redoRound = directive.target.redo_round;
1385
+ // In-memory only: the session stays running and keeps the lease through
1386
+ // the restore. The first persisted state is either the verified redo or an
1387
+ // explicit stalled session.
1388
+ session.currentRound = redoRound;
1389
+ session.roundSnapshot = undefined;
1390
+ session.status = "running";
1391
+ let restore;
1392
+ try {
1393
+ const context = this.restoreContext();
1394
+ await preflightRestore({
1395
+ manifest: validated.manifest,
1396
+ workspace: context.workspace,
1397
+ store,
1398
+ stateDir: context.stateDir,
1399
+ exclude: context.exclude,
1400
+ });
1401
+ restore = await executeRestore({
1402
+ manifest: validated.manifest,
1403
+ workspace: context.workspace,
1404
+ store,
1405
+ directiveTransactionId: directive.transaction_id,
1406
+ stateDir: context.stateDir,
1407
+ exclude: context.exclude,
1408
+ });
1409
+ }
1410
+ catch (error) {
1411
+ return {
1412
+ ok: false,
1413
+ result: this.restoreFailedResult(session, directive, error instanceof RestoreFailure ? error.reasonCode : "restore_failed", String(error instanceof Error ? error.message : error)),
1414
+ };
1415
+ }
1416
+ const request = buildLoopRequest(session, extra.inputs);
1417
+ if (extra.verificationFlags) {
1418
+ request.verification_flags = [...extra.verificationFlags];
1419
+ }
1420
+ let prepared;
1421
+ try {
1422
+ prepared = await new RoundDriver(session.engine, this.store, this.checkpointStoreFor?.(session.loopId)).prepare(request, session.loopId, redoRound, {
1423
+ branchId: directive.transaction_id,
1424
+ ...(session.operatorAcceptance
1425
+ ? { operatorAcceptance: session.operatorAcceptance }
1426
+ : {}),
1427
+ baseline: {
1428
+ observations: restore.baseline,
1429
+ ...(restore.baselineBoundary
1430
+ ? { boundary: restore.baselineBoundary }
1431
+ : {}),
1432
+ },
1433
+ restore: {
1434
+ directiveTransactionId: directive.transaction_id,
1435
+ redoRound,
1436
+ verifiedAt: restore.verifiedAt,
1437
+ },
1438
+ });
1439
+ }
1440
+ catch (error) {
1441
+ return {
1442
+ ok: false,
1443
+ result: this.restoreFailedResult(session, directive, "prepare_failed", String(error instanceof Error ? error.message : error)),
1444
+ };
1445
+ }
1446
+ return { ok: true, restore, prepared };
1447
+ }
1448
+ /** Failed restore: the directive remains pending, no prompt exists, and the
1449
+ * session is resumable. */
1450
+ restoreFailedResult(session, directive, reasonCode, detail) {
1451
+ const redoRound = directive.target.redo_round;
1452
+ session.currentRound = redoRound;
1453
+ session.roundSnapshot = undefined;
1454
+ session.status = "stalled";
1455
+ this.save(session);
1456
+ return {
1457
+ sessionId: session.sessionId,
1458
+ round: redoRound,
1459
+ prompt: null,
1460
+ stopReason: "stalled",
1461
+ stopDetail: `Workspace restore failed (${reasonCode}). The rollback directive remains pending; ` +
1462
+ "call loopforge_resume to retry the same restore.",
1463
+ submissionError: {
1464
+ code: "restore_failed",
1465
+ details: {
1466
+ loopId: session.loopId,
1467
+ directive_transaction_id: directive.transaction_id,
1468
+ redo_round: redoRound,
1469
+ reason_code: reasonCode,
1470
+ detail,
1471
+ },
1472
+ },
1473
+ };
1474
+ }
1475
+ /** v2.10: Build a backtrack result. The directive is already committed;
1476
+ * LoopForge executes and verifies the restore, then compiles the redo from
1477
+ * the verified baseline. A failed restore leaves the directive pending and
1478
+ * the session stalled — no redo prompt is ever issued unverified. */
1479
+ async buildBacktrackResult(sessionId, session, pr, receiptBase, inputs) {
1480
+ const directive = pr.recovery;
1481
+ if (!directive) {
1482
+ session.status = "stalled";
1483
+ this.save(session);
1484
+ return {
1485
+ sessionId,
1486
+ round: session.currentRound,
1487
+ prompt: null,
1488
+ stopReason: "stalled",
1489
+ stopDetail: "A backtrack was committed without a rollback directive.",
1490
+ };
1491
+ }
1492
+ const newRound = directive.target.redo_round;
1493
+ logEvent("session_recovery", {
815
1494
  sessionId,
816
1495
  loopId: session.loopId,
817
1496
  fromRound: session.currentRound,
818
1497
  toRound: newRound,
819
- triggerRule: pr.backtrackTriggerRule ?? "",
820
- skippedDiscoveries: (pr.backtrackSkippedDiscoveries ?? []).length,
1498
+ origin: directive.origin,
1499
+ triggerRule: directive.trigger_rule ?? "",
1500
+ checkpointId: directive.target.checkpoint_id,
1501
+ skippedDiscoveries: directive.skipped_discoveries.length,
821
1502
  });
822
- // Reset session to the restored round
823
- session.currentRound = newRound;
824
1503
  session.consecutiveRejections = 0;
825
1504
  session.lastRejectionCheck = "";
826
- session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
827
- // M3: carry each skipped file's failed-round fingerprint for the
828
- // machine-proven restore check on the redo submission.
829
- session.backtrackSkippedFingerprints = pr.backtrackSkippedFingerprints ?? {};
830
- // v2.12: Remember the restore point's git HEAD — the next round's
831
- // verification gate confirms the workspace returned to this commit.
832
- session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
833
- // v3.9: …and what was already dirty there, so the restore check compares
834
- // dirty sets instead of demanding a globally clean tree.
835
- session.backtrackTargetDirty = pr.backtrackTargetDirty;
836
- session.lastSelfEval = undefined; // force L2 recompile from vault state
837
- session.currentPrompt = null;
838
- // Preserve discoveries from skipped rounds as constraints_from_plan
839
- // so they survive the compilation and re-enter active constraints.
840
- const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
841
- // Compile the restored prompt — use a fresh request with the restored round
842
- const request = {
843
- task: session.task,
844
- mode: Mode.LOOP_COMPILE,
845
- feedback: null,
846
- skill_name: null,
847
- task_id: null,
848
- loop_id: session.loopId,
849
- round: newRound,
850
- max_rounds: session.maxRounds,
851
- verification_flags: pr.verificationFlags,
852
- constraints_from_plan: preservedDiscoveries,
853
- };
854
- const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, newRound);
855
- if (!prepared || !prepared.prompt) {
856
- session.status = "stalled";
857
- session.currentPrompt = null;
858
- this.save(session);
1505
+ const recoveryFlags = pr.verificationFlags.length > 0
1506
+ ? pr.verificationFlags
1507
+ : (directive.trigger_findings ?? []);
1508
+ const outcome = await this.runRestoreAndPrepare(session, directive, {
1509
+ verificationFlags: recoveryFlags,
1510
+ ...(inputs ? { inputs } : {}),
1511
+ });
1512
+ if (!outcome.ok) {
859
1513
  return {
860
- sessionId,
861
- round: newRound,
862
- prompt: null,
863
- stopReason: "stalled",
864
- stopDetail: "Backtrack failed: RoundDriver.prepare returned null — could not compile from restore point.",
865
- enforcementAction: "backtrack",
866
- verificationStatus: pr.verificationStatus,
867
- enforcementReason: pr.enforcementReason,
1514
+ ...outcome.result,
1515
+ agentSuccessClaim: pr.agentSuccessClaim,
1516
+ agentOutcome: pr.agentOutcome,
1517
+ enforcementAction: directive.origin === "agent" ? "recovery" : "backtrack",
1518
+ evidencePosture: pr.evidencePosture,
1519
+ enforcementReason: pr.reason ?? pr.enforcementReason,
868
1520
  };
869
1521
  }
870
- // v3.9.4: the recovery facts are a protected section INSIDE the compiled
871
- // artifact. Never rewrite the prompt after compilation: the delivered
872
- // prompt must equal `prompt_artifact.renderedPrompt` and its hash.
873
- session.evidenceBaseline = prepared.evidenceBaseline;
1522
+ const { restore, prepared } = outcome;
874
1523
  session.roundSnapshot = prepared.snapshot;
875
- // v3.8.3: the rollback redo is a NEW round — `prepare` collected a fresh
876
- // before capture above — so its entrypoint baseline is the workspace as it
877
- // stands after the rollback, which is what lets a round that stalled on a
878
- // rewritten entrypoint proceed once the entrypoint is in place.
879
1524
  this.seedEntrypointTrust(session);
880
- session.currentPrompt = prepared.prompt;
881
- session.currentLevel = prepared.level;
882
- session.currentWarnings = prepared?.warnings ?? [];
883
- session.lastCompileResponse = prepared?.compileResponse ?? null;
884
1525
  this.save(session);
1526
+ const nextArtifact = prepared.artifact ??
1527
+ prepared.compileResponse?.prompt_artifact ?? null;
885
1528
  return {
886
1529
  sessionId,
887
1530
  round: newRound,
888
- roundId: session.roundSnapshot?.roundId,
1531
+ ...this.heldHandle(session),
1532
+ ...this.transactionFacts(session),
1533
+ ...this.contextFacts(nextArtifact),
889
1534
  prompt: prepared.prompt,
890
1535
  level: prepared.level,
891
- enforcementAction: "backtrack",
892
- verificationStatus: pr.verificationStatus,
893
- enforcementReason: pr.enforcementReason,
894
- warnings: prepared?.warnings ?? [],
1536
+ agentSuccessClaim: pr.agentSuccessClaim,
1537
+ agentOutcome: pr.agentOutcome,
1538
+ enforcementAction: directive.origin === "agent" ? "recovery" : "backtrack",
1539
+ evidencePosture: pr.evidencePosture,
1540
+ enforcementReason: pr.reason ?? pr.enforcementReason,
1541
+ recovery: {
1542
+ origin: directive.origin,
1543
+ reason: directive.reason,
1544
+ target_checkpoint_id: directive.target.checkpoint_id,
1545
+ redo_round: newRound,
1546
+ restore: {
1547
+ status: "verified",
1548
+ ...(restore.head ? { head: restore.head } : {}),
1549
+ ...(restore.previousHead ? { previous_head: restore.previousHead } : {}),
1550
+ ...(restore.safetyRef ? { safety_ref: restore.safetyRef } : {}),
1551
+ files_written: restore.filesWritten,
1552
+ files_deleted: restore.filesDeleted,
1553
+ verified_at: restore.verifiedAt,
1554
+ },
1555
+ },
1556
+ warnings: prepared.warnings ?? [],
895
1557
  submission_guidance: this.guidanceFor(session, {
896
- verificationFlags: pr.verificationFlags,
1558
+ verificationFlags: recoveryFlags,
897
1559
  recovery: {
898
- required: true,
899
- restore_point: pr.backtrackTarget ?? Math.max(1, newRound - 1),
900
- ...(pr.backtrackTargetGitHead
901
- ? { target_head: pr.backtrackTargetGitHead }
902
- : {}),
1560
+ pending: true,
1561
+ redo_round: newRound,
1562
+ checkpoint_id: directive.target.checkpoint_id,
1563
+ restore_verified: session.roundSnapshot.restore?.directiveTransactionId ===
1564
+ directive.transaction_id,
1565
+ origin: directive.origin,
1566
+ reason: directive.reason,
903
1567
  },
904
1568
  }),
905
1569
  submission_receipt: this.buildReceipt(receiptBase, {
906
1570
  disposition: "backtracked",
907
- nextArtifact: prepared.artifact ??
908
- prepared.compileResponse?.prompt_artifact ?? null,
1571
+ nextArtifact,
909
1572
  afterSubGoals: null,
910
1573
  constraintsActiveAfter: null,
911
1574
  contractAfterId: receiptBase.beforeContractId,
1575
+ // The SAME verified-restore fact the top-level recovery reports.
1576
+ restoreVerified: true,
912
1577
  }),
913
1578
  };
914
1579
  }
1580
+ /** v4.0.4: the ONE terminal stop-detail formatter. Live stop, termination,
1581
+ * crash-recovered stop and resume-of-stopped read the same three fact
1582
+ * blocks; a committed fact is described identically on every path. */
1583
+ terminalFacts(pr, reason, selfEval, committed) {
1584
+ return {
1585
+ agent: selfEval
1586
+ ? {
1587
+ successClaim: effectiveSuccess(selfEval),
1588
+ outcome: effectiveOutcome(selfEval),
1589
+ shouldContinue: selfEval.should_continue,
1590
+ blocker: selfEval.blocker?.trim() || null,
1591
+ }
1592
+ : null,
1593
+ loopforge: {
1594
+ action: pr.action,
1595
+ loopStopReason: reason,
1596
+ enforcementReason: pr.enforcementReason ?? null,
1597
+ },
1598
+ machine: {
1599
+ posture: pr.evidencePosture ?? null,
1600
+ committed,
1601
+ },
1602
+ };
1603
+ }
1604
+ /** v4.0.4: the terminal result of an already-stopped loop, built from
1605
+ * committed history. This is the ONE read path for `resume` of a stopped
1606
+ * loop: it reports the committed `StopReason` and renders the same three
1607
+ * fact blocks as a live stop. A loop with no committed stop round reports
1608
+ * the session status and says so instead of inventing a reason. */
1609
+ stoppedLoopResult(loopId, currentRound) {
1610
+ const history = loadCommittedHistory(this.store, loopId);
1611
+ if (history.legacyRounds.length > 0) {
1612
+ throw new LegacyHistoryError(loopId, history.legacyRounds.map((round) => round.round));
1613
+ }
1614
+ const terminal = [...history.rounds].reverse().find((view) => view.result?.stopReason !== undefined &&
1615
+ view.result?.stopReason !== null);
1616
+ const reason = terminal?.result?.stopReason;
1617
+ if (!terminal || !reason) {
1618
+ return {
1619
+ sessionId: "",
1620
+ round: currentRound,
1621
+ prompt: null,
1622
+ sessionStatus: "stopped",
1623
+ stopDetail: "Loop is stopped; there is no committed terminal reason for this " +
1624
+ "loop (it may have been cancelled before a stop round committed).",
1625
+ };
1626
+ }
1627
+ return {
1628
+ sessionId: "",
1629
+ round: currentRound,
1630
+ prompt: null,
1631
+ stopReason: reason,
1632
+ sessionStatus: "stopped",
1633
+ stopDetail: renderResultFacts(this.terminalFacts(terminal.result, reason, terminal.evaluation ?? null, true), "terminal").join("\n"),
1634
+ agentSuccessClaim: terminal.agentSuccessClaim,
1635
+ agentOutcome: terminal.agentOutcome,
1636
+ agentShouldContinue: terminal.evaluation?.should_continue,
1637
+ submission_receipt: deriveCommittedSubmissionView(terminal, history.rounds, history.directives),
1638
+ };
1639
+ }
915
1640
  /** Build a termination result: persist stopped status, notify sinks.
916
1641
  * MUTATES: session.status, session.currentPrompt
917
1642
  *
@@ -925,7 +1650,6 @@ export class RoundLifecycle {
925
1650
  guardedReason, receiptBase) {
926
1651
  const reason = guardedReason ?? pr.stopReason ?? "enforcement_terminated";
927
1652
  session.status = "stopped";
928
- session.currentPrompt = null;
929
1653
  // v3.8.3: no round is in flight once the loop stops — the in-flight trust
930
1654
  // baseline goes with it, so a later resume cannot compare against a state
931
1655
  // from a previous life.
@@ -939,12 +1663,19 @@ export class RoundLifecycle {
939
1663
  return {
940
1664
  sessionId,
941
1665
  round: session.currentRound,
942
- roundId: session.roundSnapshot?.roundId,
943
1666
  prompt: null,
944
1667
  stopReason: reason,
945
- stopDetail: pr.enforcementReason ?? "The enforcement gate terminated the loop.",
1668
+ sessionStatus: "stopped",
1669
+ agentSuccessClaim: pr.agentSuccessClaim,
1670
+ agentOutcome: pr.agentOutcome,
1671
+ agentShouldContinue: receiptBase.selfEval.should_continue,
1672
+ // v4.0.6: the Agent's outcome/blocker are the report; LoopForge's
1673
+ // terminal reason is `stopReason`.
1674
+ // v4.0.4: a terminated attempt never committed, so its posture is
1675
+ // explicitly transient; no committed receipt is fabricated.
1676
+ stopDetail: renderResultFacts(this.terminalFacts(pr, reason, receiptBase.selfEval, false), "terminal").join("\n"),
946
1677
  enforcementAction: "terminate",
947
- verificationStatus: pr.verificationStatus,
1678
+ evidencePosture: pr.evidencePosture,
948
1679
  enforcementReason: pr.enforcementReason,
949
1680
  submission_receipt: this.buildReceipt(receiptBase, {
950
1681
  disposition: "terminated",
@@ -963,7 +1694,6 @@ export class RoundLifecycle {
963
1694
  guardedReason, receiptBase) {
964
1695
  const reason = guardedReason ?? pr.stopReason ?? "stalled";
965
1696
  session.status = reason === "stalled" ? "stalled" : "stopped";
966
- session.currentPrompt = null;
967
1697
  // v3.8.3: see buildTerminationResult — the in-flight trust baseline dies
968
1698
  // with the in-flight round.
969
1699
  session.entrypointTrust = undefined;
@@ -972,12 +1702,18 @@ export class RoundLifecycle {
972
1702
  logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
973
1703
  return {
974
1704
  sessionId, round: session.currentRound,
975
- roundId: session.roundSnapshot?.roundId,
976
1705
  prompt: null, stopReason: reason,
977
- stopDetail: pr.stopReason === "max_rounds"
978
- ? "The configured maximum number of rounds has been reached."
979
- : `Loop stopped: ${reason}.`,
980
- roundSuccess: pr.roundSuccess,
1706
+ sessionStatus: session.status,
1707
+ agentSuccessClaim: pr.agentSuccessClaim,
1708
+ agentOutcome: pr.agentOutcome,
1709
+ agentShouldContinue: receiptBase.selfEval.should_continue,
1710
+ // v4.0.6: the Agent's outcome/blocker are the report; LoopForge's
1711
+ // terminal reason is `stopReason`.
1712
+ // v4.0.4: `completed` no longer implies machine success; the three
1713
+ // fact blocks state the Agent report, the LoopForge decision and the
1714
+ // persisted posture separately.
1715
+ stopDetail: renderResultFacts(this.terminalFacts(pr, reason, receiptBase.selfEval, true), "terminal").join("\n"),
1716
+ evidencePosture: pr.evidencePosture,
981
1717
  submission_receipt: this.buildReceipt(receiptBase, {
982
1718
  disposition: "stopped",
983
1719
  nextArtifact: null,
@@ -989,8 +1725,8 @@ export class RoundLifecycle {
989
1725
  * Includes the commit fence (pause/delete race guard) and context provider.
990
1726
  * MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
991
1727
  * session.evidenceBaseline, session.roundSnapshot */
992
- async advanceToNextRound(sessionId, session, selfEval, pr, actualEvidence, receiptBase) {
993
- const roundSuccess = pr.roundSuccess;
1728
+ async advanceToNextRound(sessionId, session, selfEval, pr, actualEvidence, receiptBase, inputs) {
1729
+ const agentSuccessClaim = pr.agentSuccessClaim;
994
1730
  // M4 (v3.7.x): replay guard. The counter may already point past the
995
1731
  // committed round when this continue is a REPLAY — after a crash or an
996
1732
  // in-process exception between the round's commit and this compile, the
@@ -1001,53 +1737,23 @@ export class RoundLifecycle {
1001
1737
  if (!session.roundSnapshot || session.currentRound <= session.roundSnapshot.round) {
1002
1738
  session.currentRound++;
1003
1739
  }
1004
- session.currentPrompt = null;
1005
- // v2.13: Clear backtrack skipped files after advancing past the restore round
1006
- session.backtrackSkippedFiles = [];
1007
- session.backtrackSkippedFingerprints = {};
1008
- // v2.12: The restore-point git HEAD check only applies to the first
1009
- // post-backtrack round — clear it once we advance past it.
1010
- session.backtrackTargetGitHead = undefined;
1011
- session.backtrackTargetDirty = undefined;
1012
- // v1.8: Memory injection — tier-aware external context.
1013
- const contextProvider = this.getContext();
1014
- let externalCtx = "";
1015
- if (contextProvider) {
1016
- try {
1017
- externalCtx = (await contextProvider({
1018
- loopId: session.loopId,
1019
- round: session.currentRound,
1020
- task: session.task,
1021
- domain: "",
1022
- lastEvaluation: selfEval,
1023
- })).trim();
1024
- }
1025
- catch {
1026
- logEvent("context_provider_error", {
1027
- loopId: session.loopId,
1028
- round: session.currentRound,
1029
- });
1030
- }
1031
- }
1032
- // Commit fence: pause()/delete() may have run while the context provider
1033
- // was awaited. Do not compile if the session is no longer running.
1740
+ // Commit fence: pause()/delete() may run while prepare is awaited.
1034
1741
  if (this.registry.get(sessionId) !== session || session.status !== "running") {
1035
1742
  return {
1036
1743
  sessionId,
1037
1744
  round: session.currentRound,
1038
1745
  prompt: null,
1039
- stopReason: session.status,
1040
- stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the context provider was running.`,
1041
- roundSuccess,
1746
+ sessionStatus: session.status,
1747
+ stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the next prompt was being prepared.`,
1748
+ agentSuccessClaim,
1042
1749
  };
1043
1750
  }
1044
- const request = buildLoopRequest(session);
1045
- if (externalCtx) {
1046
- request.external_context = externalCtx;
1047
- }
1751
+ // v4.0.5: a new round never inherits an earlier artifact's instruction;
1752
+ // only the explicit host input for this call travels.
1753
+ const request = buildLoopRequest(session, inputs);
1048
1754
  let prepared;
1049
1755
  try {
1050
- prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, session.currentRound);
1756
+ prepared = await this.driver(session.engine, session.loopId).prepare(request, session.loopId, session.currentRound, this.prepareOptions(session));
1051
1757
  }
1052
1758
  catch (error) {
1053
1759
  // M4: a post-commit compile exception must not silently strand the
@@ -1063,33 +1769,35 @@ export class RoundLifecycle {
1063
1769
  if (error instanceof ProjectionCoverageError) {
1064
1770
  // v3.9.4: the failure is a projection code defect, not a work-quality
1065
1771
  // verdict. Keep the committed round held and surface the stable code.
1066
- return this.stalledAfterCommit(sessionId, session, roundSuccess, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
1772
+ return this.stalledAfterCommit(sessionId, session, agentSuccessClaim, error.message, { code: "projection_incomplete", details: { coverage: error.coverage } });
1773
+ }
1774
+ if (error instanceof CompileFailureError) {
1775
+ return this.stalledAfterCommit(sessionId, session, agentSuccessClaim, `The round committed but the next prompt could not be compiled: ${error.message}. ` +
1776
+ `Call loopforge_resume to reconcile the commit and retry.`);
1067
1777
  }
1068
- return this.stalledAfterCommit(sessionId, session, roundSuccess, `RoundDriver.prepare threw after the round committed: ${String(error).slice(0, 300)}. ` +
1069
- `The session is stalled with the committed round still held — resume or ` +
1070
- `resubmit the same roundId to recompile the next prompt.`);
1071
- }
1072
- if (!prepared) {
1073
- // M4: align with the sibling paths (resume / unpause / reject /
1074
- // backtrack / crash recovery) that treat a null preparation as a
1075
- // stalled session — previously this silently returned prompt:null
1076
- // without a stopReason while the session kept running.
1077
- return this.stalledAfterCommit(sessionId, session, roundSuccess, "RoundDriver.prepare returned null after the round committed — the " +
1078
- "next prompt could not be compiled. Resume or resubmit the same " +
1079
- "roundId to retry.");
1778
+ return this.stalledAfterCommit(sessionId, session, agentSuccessClaim, `RoundDriver.prepare threw after the round committed: ${String(error).slice(0, 300)}. ` +
1779
+ `The session is stalled with the committed round still held — call ` +
1780
+ `loopforge_resume to reconcile it and compile the next prompt.`);
1781
+ }
1782
+ // Commit fence after the async prepare: pause()/delete() may have run
1783
+ // while the compile/evidence collection was in flight. Do not install a
1784
+ // prompt into a session that is no longer running.
1785
+ if (this.registry.get(sessionId) !== session || session.status !== "running") {
1786
+ return {
1787
+ sessionId,
1788
+ round: session.currentRound,
1789
+ prompt: null,
1790
+ sessionStatus: session.status,
1791
+ stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the next prompt was being prepared.`,
1792
+ agentSuccessClaim,
1793
+ };
1080
1794
  }
1081
1795
  const nextPrompt = prepared.prompt;
1082
1796
  const nextLevel = prepared.level;
1083
- const nextBaseline = prepared.evidenceBaseline ?? actualEvidence;
1084
- session.evidenceBaseline = nextBaseline;
1085
1797
  session.roundSnapshot = prepared.snapshot;
1086
1798
  // v3.8.3: the round that just committed is history; the trust baseline
1087
1799
  // moves to the round being prepared now.
1088
1800
  this.seedEntrypointTrust(session);
1089
- session.currentPrompt = nextPrompt;
1090
- session.currentLevel = nextLevel;
1091
- session.currentWarnings = prepared.warnings ?? [];
1092
- session.lastCompileResponse = prepared.compileResponse ?? null;
1093
1801
  policyMetrics.recordStrategy(session.loopId, nextLevel);
1094
1802
  try {
1095
1803
  this.save(session);
@@ -1103,35 +1811,39 @@ export class RoundLifecycle {
1103
1811
  round: session.currentRound,
1104
1812
  error: String(error),
1105
1813
  });
1106
- return this.stalledAfterCommit(sessionId, session, roundSuccess, `The round committed but the session state could not be persisted: ${String(error).slice(0, 300)}. ` +
1814
+ return this.stalledAfterCommit(sessionId, session, agentSuccessClaim, `The round committed but the session state could not be persisted: ${String(error).slice(0, 300)}. ` +
1107
1815
  `Resume the loop to recover the held prompt.`);
1108
1816
  }
1109
1817
  const compiled = prepared.compileResponse;
1818
+ const nextArtifact = prepared.artifact ?? compiled?.prompt_artifact ?? null;
1110
1819
  return {
1111
1820
  sessionId,
1112
1821
  round: session.currentRound,
1113
- roundId: session.roundSnapshot.roundId,
1822
+ ...this.heldHandle(session),
1823
+ ...this.transactionFacts(session),
1824
+ ...this.contextFacts(nextArtifact),
1114
1825
  prompt: nextPrompt,
1115
1826
  level: nextLevel,
1116
- roundSuccess,
1827
+ agentSuccessClaim,
1828
+ agentOutcome: pr.agentOutcome,
1117
1829
  warnings: prepared.warnings ?? [],
1118
1830
  submission_guidance: this.guidanceFor(session),
1119
1831
  submission_receipt: this.buildReceipt(receiptBase, {
1120
1832
  disposition: "accepted",
1121
- nextArtifact: prepared.artifact ?? compiled?.prompt_artifact ?? null,
1833
+ nextArtifact,
1122
1834
  afterSubGoals: compiled?.sub_goals ?? null,
1123
1835
  constraintsActiveAfter: compiled?.constraints_active ?? null,
1836
+ constraintsRetiredAfter: compiled?.constraints_retired ?? null,
1124
1837
  }),
1125
1838
  };
1126
1839
  }
1127
1840
  /** M4: mark a session stalled after its round committed but the next
1128
- * prompt could not be prepared/persisted — mirrors the crash-recovery
1129
- * siblings (reconcileCommittedRound / resume / reject paths). The
1130
- * committed round stays held (roundSnapshot untouched) so a resubmission
1131
- * with the same roundId replays it and recompiles the next round. */
1132
- stalledAfterCommit(sessionId, session, roundSuccess, detail, projectionError) {
1841
+ * prompt could not be prepared/persisted. v4.0.0: the committed snapshot is
1842
+ * NOT a held prompt, so this result carries no handle; the Agent must call
1843
+ * `loopforge_resume`, which reconciles the commit and compiles the next
1844
+ * prompt. Re-submitting an old handle can never resurrect the old round. */
1845
+ stalledAfterCommit(sessionId, session, agentSuccessClaim, detail, projectionError) {
1133
1846
  session.status = "stalled";
1134
- session.currentPrompt = null;
1135
1847
  try {
1136
1848
  this.save(session);
1137
1849
  }
@@ -1145,118 +1857,155 @@ export class RoundLifecycle {
1145
1857
  return {
1146
1858
  sessionId,
1147
1859
  round: session.currentRound,
1148
- roundId: session.roundSnapshot?.roundId,
1149
1860
  prompt: null,
1150
1861
  stopReason: "stalled",
1151
1862
  stopDetail: detail,
1152
- roundSuccess,
1863
+ agentSuccessClaim,
1153
1864
  ...(projectionError ? { projectionError } : {}),
1154
1865
  };
1155
1866
  }
1156
- async advanceUnlocked(sessionId, output, preExtractedEval, roundId, normalizationDiagnostics, submittedFields) {
1867
+ async advanceUnlocked(sessionId, output, preExtractedEval, normalizationDiagnostics, submittedFields, inputs) {
1157
1868
  // ── 1. Validate session ──────────────────────────────────────────────
1869
+ // The handle identity (including the exact transaction attempt) was
1870
+ // already validated by SessionManager before its queue and lease
1871
+ // heartbeat. This lifecycle owns the round state machine, not a second
1872
+ // anchor rule.
1158
1873
  const session = this.registry.get(sessionId);
1159
- if (!session)
1160
- 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." };
1161
- if (session.status !== "running" && session.status !== "stalled") {
1162
- 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.` };
1163
- }
1164
- // A stalled compiler preparation is retryable against the same held
1165
- // round. Format errors never set this status; they return before advance.
1166
- if (session.status === "stalled") {
1167
- session.status = "running";
1874
+ if (!session) {
1875
+ return {
1876
+ sessionId,
1877
+ round: 0,
1878
+ prompt: null,
1879
+ submissionError: {
1880
+ code: "session_not_found",
1881
+ details: {
1882
+ detail: "No session exists with this ID. It may have expired, been " +
1883
+ "deleted, or never existed.",
1884
+ },
1885
+ },
1886
+ };
1168
1887
  }
1169
- // ── 1.5 Anchor the submission to the round it reports on ─────────────
1170
- // v3.0.1: a submission whose roundId no longer matches the current round
1171
- // is stale — the round it reported on already committed (lost response,
1172
- // duplicate call). Never process it against the current round; return the
1173
- // held prompt so the agent recovers the response it missed. The reject
1174
- // path keeps the same roundId (prepareRejectedAttempt), so retries of a
1175
- // rejected round match normally.
1176
- const expectedRoundId = session.roundSnapshot?.roundId
1177
- ?? makeRoundId(session.loopId, session.currentRound);
1178
- if (roundId !== undefined && roundId !== expectedRoundId) {
1179
- logEvent("stale_round_id", {
1888
+ if (session.status !== "running" && session.status !== "stalled") {
1889
+ return {
1180
1890
  sessionId,
1181
- loopId: session.loopId,
1182
- submitted: roundId,
1183
- expected: expectedRoundId,
1184
- });
1185
- const restored = this.restoredPromptResult(session);
1186
- if (restored) {
1187
- return {
1188
- ...restored,
1189
- warnings: [
1190
- ...(restored.warnings ?? []),
1191
- `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.`,
1192
- ],
1193
- };
1194
- }
1891
+ round: session.currentRound,
1892
+ prompt: null,
1893
+ sessionStatus: session.status,
1894
+ stopDetail: `Session is ${session.status}. Use loopforge_resume to restart a ` +
1895
+ "paused session, or loopforge_start for a new task.",
1896
+ };
1897
+ }
1898
+ // v3.9.7: a pending rollback directive with no verified redo snapshot
1899
+ // means the workspace is (or may be) mid-restore. Ordinary work must not
1900
+ // be evaluated against it. Resume completes the restore first.
1901
+ // v4.0.1: this is the ONE pre-commit history read of the advance path; the
1902
+ // before facts and the evaluation coordinator share it.
1903
+ const history = loadCommittedHistory(this.store, session.loopId);
1904
+ const pending = history.pendingDirective;
1905
+ if (pending?.recovery &&
1906
+ (session.roundSnapshot?.restore?.directiveTransactionId !==
1907
+ pending.recovery.transaction_id ||
1908
+ session.roundSnapshot.round !== pending.recovery.target.redo_round)) {
1195
1909
  return {
1196
1910
  sessionId,
1197
1911
  round: session.currentRound,
1198
- roundId: expectedRoundId,
1199
1912
  prompt: null,
1200
- stopReason: "stale_round_id",
1201
- 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.`,
1913
+ stopReason: "stalled",
1914
+ submissionError: {
1915
+ code: "restore_pending",
1916
+ details: {
1917
+ loopId: session.loopId,
1918
+ directive_transaction_id: pending.recovery.transaction_id,
1919
+ redo_round: pending.recovery.target.redo_round,
1920
+ detail: "A pending restore must be completed by loopforge_resume before work can be submitted.",
1921
+ },
1922
+ },
1202
1923
  };
1203
1924
  }
1925
+ // A stalled compiler preparation is retryable against the same held
1926
+ // round. Format errors never set this status; they return before advance.
1927
+ if (session.status === "stalled") {
1928
+ session.status = "running";
1929
+ }
1204
1930
  // ── 2. Extract self-evaluation ───────────────────────────────────────
1205
1931
  const extracted = this.extractEvaluation(output, preExtractedEval);
1206
1932
  if (!extracted) {
1207
1933
  return {
1208
1934
  sessionId,
1209
1935
  round: session.currentRound,
1210
- roundId: session.roundSnapshot?.roundId
1211
- ?? makeRoundId(session.loopId, session.currentRound),
1936
+ ...this.heldHandle(session),
1212
1937
  prompt: null,
1213
- stopReason: "evaluation_invalid",
1214
- stopDetail: "A structured evaluation object is required. Resubmit the same roundId with success, output_summary, constraint_violations, and should_continue.",
1938
+ submissionError: {
1939
+ code: "evaluation_invalid",
1940
+ details: {
1941
+ detail: "A structured evaluation object is required. Resubmit the same " +
1942
+ "submissionHandle with success, output_summary, " +
1943
+ "constraint_violations, and should_continue.",
1944
+ },
1945
+ },
1946
+ stopDetail: "A structured evaluation object is required. Resubmit the same " +
1947
+ "submissionHandle with success, output_summary, " +
1948
+ "constraint_violations, and should_continue.",
1215
1949
  };
1216
1950
  }
1217
1951
  const selfEval = extracted;
1218
1952
  // ── 3. Execute round transaction ─────────────────────────────────────
1219
1953
  // Capture the before facts from the shared compile window BEFORE the
1220
1954
  // transaction commits (a replay sees the same window: rounds < R).
1221
- const beforeFacts = this.beforeFactsFor(session);
1222
- const tx = await this.executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields);
1223
- const receiptSnapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
1955
+ const beforeFacts = this.beforeFactsFor(session, history);
1956
+ const tx = await this.executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields, history);
1957
+ const receiptSnapshot = session.roundSnapshot;
1958
+ if (!receiptSnapshot) {
1959
+ throw new Error("The committed round transaction disappeared before the receipt was built.");
1960
+ }
1961
+ // v4.0.3: a REPLAYED commit is reported from its committed payload, not
1962
+ // from the new submission that happened to carry the same identity.
1963
+ const receiptSelfEval = tx.replayed
1964
+ ? receiptSnapshot.evaluation ?? selfEval
1965
+ : selfEval;
1224
1966
  const receiptBase = {
1225
1967
  snapshot: receiptSnapshot,
1226
- selfEval,
1968
+ selfEval: receiptSelfEval,
1227
1969
  pr: tx.pr,
1228
- actualEvidence: tx.actualEvidence,
1229
- diagnostics: receiptSnapshot.normalizationDiagnostics ??
1230
- normalizationDiagnostics ?? [],
1231
- submittedFields: receiptSnapshot.submittedFields ?? submittedFields ?? [],
1970
+ actualEvidence: tx.replayed
1971
+ ? (receiptSnapshot.afterEvidence ?? [])
1972
+ : tx.actualEvidence,
1973
+ diagnostics: tx.replayed
1974
+ ? (receiptSnapshot.normalizationDiagnostics ?? [])
1975
+ : (receiptSnapshot.normalizationDiagnostics ??
1976
+ normalizationDiagnostics ?? []),
1977
+ submittedFields: tx.replayed
1978
+ ? (receiptSnapshot.submittedFields ?? [])
1979
+ : (receiptSnapshot.submittedFields ?? submittedFields ?? []),
1232
1980
  beforeContractId: beforeFacts.contractId,
1233
1981
  beforeSubGoals: beforeFacts.subGoals,
1982
+ recoveryDirective: beforeFacts.recoveryDirective,
1234
1983
  replayed: tx.replayed,
1235
1984
  };
1236
1985
  // v2.12: Record a user gate when an accepted round reports a high-risk
1237
1986
  // blocker. Record layer only — never blocks the round decision flow.
1238
1987
  if (tx.pr.action !== "reject" && tx.pr.action !== "terminate" &&
1239
- selfEval.outcome === "blocked" && typeof selfEval.blocker === "string") {
1240
- this.recordGateFromBlocked(session, selfEval.blocker);
1988
+ declaresBlocked(receiptSelfEval) &&
1989
+ typeof receiptSelfEval.blocker === "string") {
1990
+ this.recordGateFromBlocked(session, receiptSelfEval.blocker);
1241
1991
  }
1242
1992
  // ── 4. Route disposition ─────────────────────────────────────────────
1243
1993
  if (tx.pr.action === "reject") {
1244
- return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags, receiptBase);
1994
+ return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags, receiptBase, inputs);
1245
1995
  }
1246
- // ── v2.10: Backtrack — roll back to last clean round ──────────────────
1247
- if (tx.pr.action === "backtrack") {
1248
- return this.buildBacktrackResult(sessionId, session, tx.pr, receiptBase);
1996
+ // ── v2.10/v3.9.6: recovery directive (machine or agent) ───────────────
1997
+ if (tx.pr.action === "backtrack" || tx.pr.action === "recovery") {
1998
+ return this.buildBacktrackResult(sessionId, session, tx.pr, receiptBase, inputs);
1249
1999
  }
1250
2000
  // Accepted rounds advance the before-snapshot baseline. Rejected rounds
1251
2001
  // retain the original baseline so their retry remains zero-commit.
1252
- session.evidenceBaseline = tx.actualEvidence;
1253
2002
  // v3.8.3: completion truth — the second line, at the point the disposition
1254
2003
  // becomes the reported stop reason. The coordinator already guards the
1255
2004
  // reason it COMMITS (the round document carries result.stopReason), so
1256
2005
  // this guards what the client and the terminal notification see. A
1257
2006
  // violation can only be a code defect: it degrades to the honest reason
1258
2007
  // and leaves an observability record instead of throwing mid-lifecycle.
1259
- const finalized = finalizeStopReason(tx.pr.stopReason, selfEval);
2008
+ const finalized = finalizeStopReason(tx.pr.stopReason, receiptSelfEval, undefined, receiptSelfEval.round_contract?.supersedes ?? null);
1260
2009
  if (finalized.reason !== tx.pr.stopReason) {
1261
2010
  logEvent("completion_truth_downgraded", {
1262
2011
  loopId: session.loopId,
@@ -1272,31 +2021,295 @@ export class RoundLifecycle {
1272
2021
  return this.buildStopResult(sessionId, session, tx.pr, finalized.reason, receiptBase);
1273
2022
  }
1274
2023
  // ── 5. Continue — compile next round ─────────────────────────────────
1275
- return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.actualEvidence, receiptBase);
2024
+ return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.actualEvidence, receiptBase, inputs);
2025
+ }
2026
+ /** v3.9.6: Agent-initiated recovery. The Agent selects the target; this
2027
+ * method only validates it against committed facts, records the rollback
2028
+ * directive and compiles the redo. It never runs ordinary verification or
2029
+ * enforcement on the abandoned work. */
2030
+ async recover(sessionId, recovery, inputs) {
2031
+ const session = this.registry.get(sessionId);
2032
+ if (!session) {
2033
+ return {
2034
+ sessionId,
2035
+ round: 0,
2036
+ prompt: null,
2037
+ submissionError: {
2038
+ code: "session_not_found",
2039
+ details: { detail: "No session exists with this ID." },
2040
+ },
2041
+ };
2042
+ }
2043
+ if (session.status !== "running" && session.status !== "stalled") {
2044
+ return {
2045
+ sessionId,
2046
+ round: session.currentRound,
2047
+ prompt: null,
2048
+ sessionStatus: session.status,
2049
+ stopDetail: `Session is ${session.status}.`,
2050
+ };
2051
+ }
2052
+ if (!getPolicy().engine.agent_recovery_enabled) {
2053
+ return {
2054
+ sessionId,
2055
+ round: session.currentRound,
2056
+ prompt: null,
2057
+ submissionError: {
2058
+ code: "recovery_disabled",
2059
+ details: { policy: "engine.agent_recovery_enabled" },
2060
+ },
2061
+ };
2062
+ }
2063
+ // The handle identity (including the exact transaction attempt) was
2064
+ // validated by SessionManager before its queue and lease heartbeat.
2065
+ const held = session.roundSnapshot;
2066
+ // Pending directive first: one directive is the record, so a repeated
2067
+ // request never reads a new target and never commits a second directive.
2068
+ const pending = await this.completePendingRecovery(session, inputs);
2069
+ if (pending)
2070
+ return pending;
2071
+ if (held?.kind !== "evaluation" || held.round !== session.currentRound) {
2072
+ return {
2073
+ sessionId,
2074
+ round: session.currentRound,
2075
+ prompt: null,
2076
+ submissionError: {
2077
+ code: "recovery_invalid",
2078
+ details: {
2079
+ reason_code: "no_held_evaluation",
2080
+ detail: "recovery requires a held evaluation prompt for the current round",
2081
+ },
2082
+ },
2083
+ };
2084
+ }
2085
+ const entries = loadCommittedHistory(this.store, session.loopId).entries;
2086
+ const found = findCheckpointRef({
2087
+ entries,
2088
+ held,
2089
+ checkpointId: recovery.target_checkpoint_id,
2090
+ });
2091
+ if (!found.ok) {
2092
+ return {
2093
+ sessionId,
2094
+ round: session.currentRound,
2095
+ ...this.heldHandle(session),
2096
+ ...this.transactionFacts(session),
2097
+ prompt: null,
2098
+ submissionError: {
2099
+ code: found.code,
2100
+ details: {
2101
+ target_checkpoint_id: recovery.target_checkpoint_id,
2102
+ reason_code: found.reason,
2103
+ detail: found.detail,
2104
+ current_round: session.currentRound,
2105
+ },
2106
+ },
2107
+ };
2108
+ }
2109
+ const ref = found.ref;
2110
+ const store = this.checkpointStoreFor?.(session.loopId);
2111
+ if (!store) {
2112
+ return {
2113
+ sessionId,
2114
+ round: session.currentRound,
2115
+ ...this.heldHandle(session),
2116
+ ...this.transactionFacts(session),
2117
+ prompt: null,
2118
+ submissionError: {
2119
+ code: "recovery_unavailable",
2120
+ details: {
2121
+ target_checkpoint_id: recovery.target_checkpoint_id,
2122
+ reason_code: "store_unavailable",
2123
+ detail: "no workspace checkpoint store is attached to this runtime",
2124
+ },
2125
+ },
2126
+ };
2127
+ }
2128
+ const manifest = store.readManifest(ref.checkpointId);
2129
+ const validated = validateCheckpointManifest(ref, manifest);
2130
+ if (!validated.ok) {
2131
+ return {
2132
+ sessionId,
2133
+ round: session.currentRound,
2134
+ ...this.heldHandle(session),
2135
+ ...this.transactionFacts(session),
2136
+ prompt: null,
2137
+ submissionError: {
2138
+ code: validated.code,
2139
+ details: {
2140
+ target_checkpoint_id: recovery.target_checkpoint_id,
2141
+ reason_code: validated.reason,
2142
+ detail: validated.detail,
2143
+ },
2144
+ },
2145
+ };
2146
+ }
2147
+ // Deep preflight BEFORE the directive: an invalid target must leave no
2148
+ // directive and no session cursor change.
2149
+ try {
2150
+ const context = this.restoreContext();
2151
+ await preflightRestore({
2152
+ manifest: validated.manifest,
2153
+ workspace: context.workspace,
2154
+ store,
2155
+ ...(context.stateDir ? { stateDir: context.stateDir } : {}),
2156
+ exclude: context.exclude,
2157
+ });
2158
+ }
2159
+ catch (error) {
2160
+ return {
2161
+ sessionId,
2162
+ round: session.currentRound,
2163
+ ...this.heldHandle(session),
2164
+ ...this.transactionFacts(session),
2165
+ prompt: null,
2166
+ submissionError: {
2167
+ code: "recovery_unavailable",
2168
+ details: {
2169
+ target_checkpoint_id: recovery.target_checkpoint_id,
2170
+ reason_code: error instanceof RestoreFailure
2171
+ ? error.reasonCode
2172
+ : "preflight_failed",
2173
+ detail: String(error instanceof Error ? error.message : error),
2174
+ },
2175
+ },
2176
+ };
2177
+ }
2178
+ const target = {
2179
+ checkpoint_id: ref.checkpointId,
2180
+ content_hash: ref.contentHash,
2181
+ role: ref.role,
2182
+ round: ref.round,
2183
+ round_id: ref.roundId,
2184
+ transaction_id: ref.transactionId,
2185
+ redo_round: ref.redoRound,
2186
+ };
2187
+ const abandoned = branchRounds(entries).filter((view) => view.round >= ref.redoRound);
2188
+ const reportedSkippedFiles = [...new Set(abandoned.flatMap((view) => (view.executionReport?.files_changed ?? [])))].sort();
2189
+ const directiveTransactionId = deriveTransactionId({
2190
+ kind: "rollback",
2191
+ branchId: held.branchId ?? rootBranchId(session.loopId),
2192
+ roundId: held.roundId,
2193
+ attempt: 1,
2194
+ promptHash: held.promptArtifact?.promptHash,
2195
+ recovery: {
2196
+ target,
2197
+ reason: recovery.reason,
2198
+ abandonedRounds: abandoned.map((view) => view.round),
2199
+ reportedSkippedFiles,
2200
+ },
2201
+ });
2202
+ const completed = await new RoundDriver(session.engine, this.store, store).completeRecovery({
2203
+ snapshot: held,
2204
+ loopId: session.loopId,
2205
+ buildRecovery: (actualEvidence) => deriveRecoveryDirective({
2206
+ origin: "agent",
2207
+ reason: recovery.reason,
2208
+ transactionId: directiveTransactionId,
2209
+ targetRef: ref,
2210
+ targetManifest: validated.manifest,
2211
+ abandonedRounds: abandoned,
2212
+ currentAfterEvidence: actualEvidence,
2213
+ triggerRound: session.currentRound,
2214
+ }),
2215
+ });
2216
+ const directive = completed.outcome.snapshot.recovery;
2217
+ if (!directive) {
2218
+ return this.restoreFailedResult(session, {
2219
+ origin: "agent",
2220
+ reason: recovery.reason,
2221
+ target,
2222
+ skipped_files: [],
2223
+ skipped_discoveries: [],
2224
+ failed_rounds: [],
2225
+ approaches: [],
2226
+ wrong_assumptions: [],
2227
+ transaction_id: directiveTransactionId,
2228
+ }, "directive_unavailable", "the committed rollback directive could not be read back");
2229
+ }
2230
+ return this.restoreAndPrepareRedo(session, directive, inputs);
2231
+ }
2232
+ /** Execute the committed directive's restore and return the redo prompt. */
2233
+ async restoreAndPrepareRedo(session, directive, inputs) {
2234
+ const outcome = await this.runRestoreAndPrepare(session, directive, {
2235
+ ...(inputs ? { inputs } : {}),
2236
+ });
2237
+ if (!outcome.ok)
2238
+ return outcome.result;
2239
+ const { restore, prepared } = outcome;
2240
+ const result = this.persistPrepared(session, {
2241
+ prompt: prepared.prompt,
2242
+ level: prepared.level,
2243
+ baseline: prepared.evidenceBaseline,
2244
+ snapshot: prepared.snapshot,
2245
+ warnings: prepared.warnings ?? [],
2246
+ }, false);
2247
+ return {
2248
+ ...result,
2249
+ submission_guidance: this.guidanceFor(session, {
2250
+ recovery: {
2251
+ pending: true,
2252
+ redo_round: directive.target.redo_round,
2253
+ checkpoint_id: directive.target.checkpoint_id,
2254
+ restore_verified: true,
2255
+ origin: directive.origin,
2256
+ reason: directive.reason,
2257
+ },
2258
+ }),
2259
+ recovery: {
2260
+ origin: directive.origin,
2261
+ reason: directive.reason,
2262
+ target_checkpoint_id: directive.target.checkpoint_id,
2263
+ redo_round: directive.target.redo_round,
2264
+ restore: {
2265
+ status: "verified",
2266
+ ...(restore.head ? { head: restore.head } : {}),
2267
+ ...(restore.previousHead ? { previous_head: restore.previousHead } : {}),
2268
+ ...(restore.safetyRef ? { safety_ref: restore.safetyRef } : {}),
2269
+ files_written: restore.filesWritten,
2270
+ files_deleted: restore.filesDeleted,
2271
+ verified_at: restore.verifiedAt,
2272
+ },
2273
+ },
2274
+ };
1276
2275
  }
1277
2276
  /** Core cycle entry used by SessionManager.advance after the queue + lease
1278
2277
  * dance: validate → extract → execute transaction → route disposition
1279
2278
  * → advance to next round (continue) or return terminal result.
1280
- * @param roundId v3.0.1: the roundId of the round this submission reports
1281
- * on (from the last start/next/resume response). Anchors the submission;
1282
- * a stale or duplicate submission returns the held prompt instead of
1283
- * being processed. Optional — when absent the anchor check is skipped. */
1284
- async advance(sessionId, output, preExtractedEval, roundId, normalizationDiagnostics, submittedFields) {
1285
- return this.advanceUnlocked(sessionId, output, preExtractedEval, roundId, normalizationDiagnostics, submittedFields);
2279
+ * v4.0.0: the submission identity (the exact held transaction) is validated
2280
+ * by SessionManager before its queue and lease heartbeat. This entry point
2281
+ * owns the round state machine and does not re-check anchors. */
2282
+ async advance(sessionId, output, preExtractedEval, normalizationDiagnostics, submittedFields,
2283
+ /** v4.0.5: invocation input for the prompt compiled after this submission. */
2284
+ inputs) {
2285
+ return this.advanceUnlocked(sessionId, output, preExtractedEval, normalizationDiagnostics, submittedFields, inputs);
1286
2286
  }
1287
2287
  /** Write back loop knowledge to long-term memory.
1288
2288
  * Uses shared base builder from policy.ts. Called when a loop terminates. */
1289
2289
  async notifyTerminal(session, stopReason) {
1290
2290
  if (this.terminalSinks.size === 0)
1291
2291
  return;
2292
+ const views = loadCommittedHistory(this.store, session.loopId).rounds;
2293
+ const lastView = views.length > 0 ? views[views.length - 1] : undefined;
2294
+ // v4.0.3: the trajectory records MACHINE-BACKED success only.
2295
+ const machineBackedSuccessTrajectory = views
2296
+ .filter((view) => view.result?.shouldPushSuccessTrajectory === true &&
2297
+ view.result?.evidencePosture?.machineBackedSuccess === true)
2298
+ .map((view) => view.agentSuccessClaim);
1292
2299
  const event = {
1293
2300
  loopId: session.loopId,
1294
2301
  task: session.task,
1295
- success: stopReason === "completed",
2302
+ completed: stopReason === "completed",
2303
+ // v4.0.4: no committed posture is unknown, never a synthesized false.
2304
+ machineBackedSuccess: lastView?.evidencePosture?.machineBackedSuccess ?? null,
2305
+ evidencePosture: lastView?.evidencePosture ?? null,
1296
2306
  stopReason: stopReason,
2307
+ // v4.0.4: keep the historical alias, but state the two facts separately.
1297
2308
  roundsCompleted: session.currentRound,
1298
- successTrajectory: [...session.successTrajectory],
1299
- lastEvaluation: session.lastSelfEval,
2309
+ roundCursor: session.currentRound,
2310
+ committedRounds: views.length,
2311
+ machineBackedSuccessTrajectory,
2312
+ lastEvaluation: lastView?.evaluation ?? undefined,
1300
2313
  };
1301
2314
  await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
1302
2315
  }