loopforge 3.9.2 → 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 (285) 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 +152 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +382 -206
  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 +105 -141
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +249 -372
  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 +32 -67
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +203 -439
  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 +28 -887
  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 +167 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +373 -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 +51 -19
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +423 -507
  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 +285 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1629 -574
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts +5 -0
  115. package/dist/mcp/server.d.ts.map +1 -1
  116. package/dist/mcp/server.js +61 -19
  117. package/dist/mcp/server.js.map +1 -1
  118. package/dist/mcp/session.d.ts +81 -39
  119. package/dist/mcp/session.d.ts.map +1 -1
  120. package/dist/mcp/session.js +963 -271
  121. package/dist/mcp/session.js.map +1 -1
  122. package/dist/mcp/submission-boundary.d.ts +23 -31
  123. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  124. package/dist/mcp/submission-boundary.js +242 -82
  125. package/dist/mcp/submission-boundary.js.map +1 -1
  126. package/dist/mcp/submission-handle.d.ts +66 -0
  127. package/dist/mcp/submission-handle.d.ts.map +1 -0
  128. package/dist/mcp/submission-handle.js +173 -0
  129. package/dist/mcp/submission-handle.js.map +1 -0
  130. package/dist/mcp/tools.d.ts +17 -7
  131. package/dist/mcp/tools.d.ts.map +1 -1
  132. package/dist/mcp/tools.js +763 -244
  133. package/dist/mcp/tools.js.map +1 -1
  134. package/dist/open-question.d.ts +7 -0
  135. package/dist/open-question.d.ts.map +1 -0
  136. package/dist/open-question.js +14 -0
  137. package/dist/open-question.js.map +1 -0
  138. package/dist/policy-metrics.d.ts +31 -21
  139. package/dist/policy-metrics.d.ts.map +1 -1
  140. package/dist/policy-metrics.js +117 -64
  141. package/dist/policy-metrics.js.map +1 -1
  142. package/dist/policy.d.ts +15 -40
  143. package/dist/policy.d.ts.map +1 -1
  144. package/dist/policy.js +13 -13
  145. package/dist/policy.js.map +1 -1
  146. package/dist/progress.d.ts +45 -0
  147. package/dist/progress.d.ts.map +1 -0
  148. package/dist/progress.js +136 -0
  149. package/dist/progress.js.map +1 -0
  150. package/dist/prompt-assembler.d.ts +28 -9
  151. package/dist/prompt-assembler.d.ts.map +1 -1
  152. package/dist/prompt-assembler.js +592 -603
  153. package/dist/prompt-assembler.js.map +1 -1
  154. package/dist/prompt-coverage.d.ts +39 -0
  155. package/dist/prompt-coverage.d.ts.map +1 -0
  156. package/dist/prompt-coverage.js +80 -0
  157. package/dist/prompt-coverage.js.map +1 -0
  158. package/dist/prompt-freshness.d.ts +25 -0
  159. package/dist/prompt-freshness.d.ts.map +1 -0
  160. package/dist/prompt-freshness.js +88 -0
  161. package/dist/prompt-freshness.js.map +1 -0
  162. package/dist/prompt-policy.d.ts +14 -4
  163. package/dist/prompt-policy.d.ts.map +1 -1
  164. package/dist/prompt-policy.js +29 -10
  165. package/dist/prompt-policy.js.map +1 -1
  166. package/dist/protocol.d.ts +875 -419
  167. package/dist/protocol.d.ts.map +1 -1
  168. package/dist/protocol.js +73 -99
  169. package/dist/protocol.js.map +1 -1
  170. package/dist/recovery-guidance.d.ts +13 -0
  171. package/dist/recovery-guidance.d.ts.map +1 -0
  172. package/dist/recovery-guidance.js +15 -0
  173. package/dist/recovery-guidance.js.map +1 -0
  174. package/dist/recovery.d.ts +68 -0
  175. package/dist/recovery.d.ts.map +1 -0
  176. package/dist/recovery.js +234 -0
  177. package/dist/recovery.js.map +1 -0
  178. package/dist/replay.d.ts +42 -8
  179. package/dist/replay.d.ts.map +1 -1
  180. package/dist/replay.js +311 -84
  181. package/dist/replay.js.map +1 -1
  182. package/dist/result-facts.d.ts +45 -0
  183. package/dist/result-facts.d.ts.map +1 -0
  184. package/dist/result-facts.js +86 -0
  185. package/dist/result-facts.js.map +1 -0
  186. package/dist/round-contract.d.ts +38 -8
  187. package/dist/round-contract.d.ts.map +1 -1
  188. package/dist/round-contract.js +71 -31
  189. package/dist/round-contract.js.map +1 -1
  190. package/dist/round-coordinator.d.ts +45 -91
  191. package/dist/round-coordinator.d.ts.map +1 -1
  192. package/dist/round-coordinator.js +254 -271
  193. package/dist/round-coordinator.js.map +1 -1
  194. package/dist/round-driver.d.ts +56 -16
  195. package/dist/round-driver.d.ts.map +1 -1
  196. package/dist/round-driver.js +225 -39
  197. package/dist/round-driver.js.map +1 -1
  198. package/dist/round-facts.d.ts +33 -52
  199. package/dist/round-facts.d.ts.map +1 -1
  200. package/dist/round-facts.js +54 -157
  201. package/dist/round-facts.js.map +1 -1
  202. package/dist/round-guidance.d.ts +56 -0
  203. package/dist/round-guidance.d.ts.map +1 -0
  204. package/dist/round-guidance.js +161 -0
  205. package/dist/round-guidance.js.map +1 -0
  206. package/dist/round-outcome.d.ts +21 -0
  207. package/dist/round-outcome.d.ts.map +1 -0
  208. package/dist/round-outcome.js +31 -0
  209. package/dist/round-outcome.js.map +1 -0
  210. package/dist/round-transaction.d.ts +168 -21
  211. package/dist/round-transaction.d.ts.map +1 -1
  212. package/dist/round-transaction.js +856 -39
  213. package/dist/round-transaction.js.map +1 -1
  214. package/dist/self-eval.d.ts +40 -60
  215. package/dist/self-eval.d.ts.map +1 -1
  216. package/dist/self-eval.js +455 -384
  217. package/dist/self-eval.js.map +1 -1
  218. package/dist/storage.d.ts +16 -0
  219. package/dist/storage.d.ts.map +1 -1
  220. package/dist/storage.js +54 -4
  221. package/dist/storage.js.map +1 -1
  222. package/dist/subgoal-state.d.ts +2 -3
  223. package/dist/subgoal-state.d.ts.map +1 -1
  224. package/dist/subgoal-state.js +2 -3
  225. package/dist/subgoal-state.js.map +1 -1
  226. package/dist/submission-guidance.d.ts +43 -21
  227. package/dist/submission-guidance.d.ts.map +1 -1
  228. package/dist/submission-guidance.js +368 -262
  229. package/dist/submission-guidance.js.map +1 -1
  230. package/dist/submission-receipt.d.ts +16 -8
  231. package/dist/submission-receipt.d.ts.map +1 -1
  232. package/dist/submission-receipt.js +146 -51
  233. package/dist/submission-receipt.js.map +1 -1
  234. package/dist/token-utils.d.ts +47 -7
  235. package/dist/token-utils.d.ts.map +1 -1
  236. package/dist/token-utils.js +66 -15
  237. package/dist/token-utils.js.map +1 -1
  238. package/dist/transaction-identity.d.ts +9 -0
  239. package/dist/transaction-identity.d.ts.map +1 -0
  240. package/dist/transaction-identity.js +69 -0
  241. package/dist/transaction-identity.js.map +1 -0
  242. package/dist/verification-actions.d.ts +13 -0
  243. package/dist/verification-actions.d.ts.map +1 -0
  244. package/dist/verification-actions.js +44 -0
  245. package/dist/verification-actions.js.map +1 -0
  246. package/dist/verification-gate.d.ts +26 -46
  247. package/dist/verification-gate.d.ts.map +1 -1
  248. package/dist/verification-gate.js +218 -427
  249. package/dist/verification-gate.js.map +1 -1
  250. package/dist/version.d.ts +1 -1
  251. package/dist/version.js +1 -1
  252. package/dist/working-memory.d.ts +187 -0
  253. package/dist/working-memory.d.ts.map +1 -0
  254. package/dist/working-memory.js +767 -0
  255. package/dist/working-memory.js.map +1 -0
  256. package/dist/workspace-checkpoint.d.ts +88 -0
  257. package/dist/workspace-checkpoint.d.ts.map +1 -0
  258. package/dist/workspace-checkpoint.js +326 -0
  259. package/dist/workspace-checkpoint.js.map +1 -0
  260. package/dist/workspace-restore.d.ts +68 -0
  261. package/dist/workspace-restore.d.ts.map +1 -0
  262. package/dist/workspace-restore.js +604 -0
  263. package/dist/workspace-restore.js.map +1 -0
  264. package/dist/workspace.d.ts +0 -9
  265. package/dist/workspace.d.ts.map +1 -1
  266. package/dist/workspace.js +0 -17
  267. package/dist/workspace.js.map +1 -1
  268. package/loop_policy.json +5 -19
  269. package/package.json +2 -2
  270. package/skills/forge/SKILL.md +212 -0
  271. package/skills/forge/references/evidence-and-contracts.md +204 -0
  272. package/skills/forge/references/recovery-and-views.md +188 -0
  273. package/dist/cognitive-facts.d.ts +0 -30
  274. package/dist/cognitive-facts.d.ts.map +0 -1
  275. package/dist/cognitive-facts.js +0 -113
  276. package/dist/cognitive-facts.js.map +0 -1
  277. package/dist/loop-extras-parser.d.ts +0 -81
  278. package/dist/loop-extras-parser.d.ts.map +0 -1
  279. package/dist/loop-extras-parser.js +0 -213
  280. package/dist/loop-extras-parser.js.map +0 -1
  281. package/dist/loop-projection.d.ts +0 -6
  282. package/dist/loop-projection.d.ts.map +0 -1
  283. package/dist/loop-projection.js +0 -28
  284. package/dist/loop-projection.js.map +0 -1
  285. package/skills/perception/SKILL.md +0 -370
@@ -10,23 +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, makeLoopRoundResult, } 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";
38
+ import { SessionLeaseConflictError } from "../storage.js";
39
+ import { isRecord, normalizeText } from "../token-utils.js";
40
+ import { ProjectionCoverageError } from "../prompt-coverage.js";
41
+ import { CompileFailureError, LegacyHistoryError } from "../errors.js";
42
+ import { renderResultFacts } from "../result-facts.js";
30
43
  // ── Types ──────────────────────────────────────────────────────────────────
31
44
  /** M3: parse a persisted skipped-file fingerprint map (string → string).
32
45
  * Lenient by design — v3.8.3 reuses it for the entrypoint trust baseline,
@@ -42,62 +55,165 @@ function parseFingerprintMap(value) {
42
55
  }
43
56
  return out;
44
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
+ }
104
+ /** v3.9.4: a structural defect in the start criteria argument. The MCP
105
+ * handler maps this to `invalid_argument`; it is never a policy defect. */
106
+ export class StartCriteriaError extends Error {
107
+ code = "invalid_argument";
108
+ }
109
+ /** v3.9.4: normalize initial criteria at the start boundary.
110
+ *
111
+ * - a non-array / non-string element is a structural defect (throws);
112
+ * - empty text is dropped with a warning;
113
+ * - duplicate normalized-exact text keeps the first spelling, with a
114
+ * warning (the stable id derivation would otherwise produce two entries
115
+ * with one `cr-*` id);
116
+ * - over `policy.evolution.max_start_criteria` is dropped from the tail with
117
+ * a warning;
118
+ * - an empty result is the caller's signal to keep the default criterion. */
119
+ export function normalizeStartCriteria(raw) {
120
+ if (raw === undefined)
121
+ return { criteria: [], warnings: [] };
122
+ if (!Array.isArray(raw)) {
123
+ throw new StartCriteriaError("criteria must be an array of non-empty strings");
124
+ }
125
+ const values = [];
126
+ let empty = 0;
127
+ for (const entry of raw) {
128
+ if (typeof entry !== "string") {
129
+ throw new StartCriteriaError("criteria entries must be strings");
130
+ }
131
+ const text = entry.trim();
132
+ if (!text) {
133
+ empty++;
134
+ continue;
135
+ }
136
+ values.push(text);
137
+ }
138
+ const warnings = [];
139
+ if (empty > 0) {
140
+ warnings.push(`start_criteria_empty: dropped ${empty} empty criteria`);
141
+ }
142
+ const seen = new Set();
143
+ const unique = [];
144
+ let duplicates = 0;
145
+ for (const text of values) {
146
+ const key = normalizeText(text);
147
+ if (seen.has(key)) {
148
+ duplicates++;
149
+ continue;
150
+ }
151
+ seen.add(key);
152
+ unique.push(text);
153
+ }
154
+ if (duplicates > 0) {
155
+ warnings.push(`start_criteria_duplicate: dropped ${duplicates} duplicate criteria`);
156
+ }
157
+ const cap = Math.max(0, getPolicy().evolution.max_start_criteria);
158
+ const criteria = unique.slice(0, cap);
159
+ if (unique.length > criteria.length) {
160
+ warnings.push(`start_criteria_capped: kept ${criteria.length} of ${unique.length} ` +
161
+ `(max_start_criteria=${cap})`);
162
+ }
163
+ return { criteria, warnings };
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";
45
170
  // ── Helpers ────────────────────────────────────────────────────────────────
46
- export function buildLoopRequest(session, lastEval, verificationFlags) {
47
- const req = {
48
- task: session.task,
49
- mode: Mode.LOOP_COMPILE,
50
- feedback: null,
51
- skill_name: null,
52
- task_id: null,
171
+ export function buildLoopRequest(session,
172
+ /** v4.0.5: the host's explicit invocation input for THIS compile. */
173
+ inputs) {
174
+ // The request carries CURRENT-ROUND context only. Every previous-round
175
+ // fact is read from the Vault's committed rounds by the compiler, so live,
176
+ // resume, unpause and backtrack compiles cannot disagree about the same
177
+ // history. `attempt` is restored only while the held snapshot belongs to
178
+ // the current round; entering the next round resets it to 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;
187
+ return {
53
188
  loop_id: session.loopId,
54
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",
55
205
  max_rounds: session.maxRounds,
56
- verification_flags: verificationFlags ?? [],
206
+ verification_flags: retry ? [...retry.verificationFlags] : [],
207
+ attempt: heldAttempt,
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
+ : {}),
57
216
  };
58
- if (lastEval) {
59
- req.last_round_result = makeLoopRoundResult({
60
- round: session.currentRound - 1,
61
- success: lastEval.success,
62
- output_summary: lastEval.output_summary,
63
- constraint_violations: lastEval.constraint_violations,
64
- manual_fixes_needed: "",
65
- // P0–P2: Forward evolution fields to next compile.
66
- // v3.9.1: worker_results stays a report; only top-level
67
- // discovered_constraints promotes a finding into the active set.
68
- discovered_constraints: lastEval.discovered_constraints ?? [],
69
- objective_refinement: lastEval.objective_refinement ?? "",
70
- emerged_subtasks: lastEval.emerged_subtasks ?? [],
71
- // P4: Execution evidence
72
- execution_report: lastEval.execution_report ?? undefined,
73
- // P5: Self-correction
74
- retracted_constraints: lastEval.retracted_constraints ?? [],
75
- revised_success_criteria: lastEval.revised_success_criteria ?? [],
76
- wrong_assumptions: lastEval.wrong_assumptions ?? [],
77
- // Multi-agent: Forward delegation results to next compile
78
- worker_results: lastEval.worker_results ?? [],
79
- // v1.10: Checkpoint boundary
80
- compression_checkpoint: lastEval.compression_checkpoint ?? false,
81
- checkpoint_label: lastEval.checkpoint_label ?? "",
82
- // v3.7.1: Sub-goal lifecycle — explicit transitions
83
- subgoal_updates: lastEval.subgoal_updates ?? [],
84
- // v2.9: Model's information needs for the next prompt
85
- prompt_requests: lastEval.prompt_requests,
86
- // v2.12: Tri-state outcome + blocker + retroactive claims
87
- outcome: lastEval.outcome,
88
- blocker: lastEval.blocker,
89
- retroactiveClaims: lastEval.retroactiveClaims ?? [],
90
- // v3.3: round_contract was carried here so it could drive the next
91
- // round's Current Task. v3.4: intentionally NOT forwarded — the
92
- // submission's round_contract is a PROPOSAL for the next round, and
93
- // the ACTIVE contract is derived from committed rounds at compile time
94
- // (`deriveRoundFacts` → `deriveActiveRoundContract`), which also keeps
95
- // it correct on retry / resume / unpause / backtrack compiles that have
96
- // no last_round_result. Reading this field again would reintroduce the
97
- // second source of truth the derivation replaced.
98
- });
99
- }
100
- return req;
101
217
  }
102
218
  // ── RoundLifecycle ─────────────────────────────────────────────────────────
103
219
  export class RoundLifecycle {
@@ -107,40 +223,63 @@ export class RoundLifecycle {
107
223
  terminalSinks;
108
224
  ownerId;
109
225
  leaseMs;
110
- getContext;
226
+ checkpointStoreFor;
111
227
  constructor(deps) {
228
+ this.checkpointStoreFor = deps.checkpointStoreFor;
112
229
  this.store = deps.store;
113
230
  this.sessionStore = deps.sessionStore;
114
231
  this.registry = deps.registry;
115
232
  this.terminalSinks = deps.terminalSinks;
116
233
  this.ownerId = deps.ownerId;
117
234
  this.leaseMs = deps.leaseMs;
118
- 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));
119
240
  }
120
241
  /** v3.9.1: the submission-guidance projection for a session's held round.
121
- * One history window (`derivationRounds`, the compile window), no writes.
242
+ * One history read and one compile window, no writes.
122
243
  * Findings from a rejected/retried attempt are supplied by the caller. */
123
244
  guidanceFor(session, extras = {}) {
124
- const prefix = `loop:${session.loopId}:`;
125
- const entries = [
126
- ...queryLoopEntries(this.store, session.loopId, { prefix }),
127
- ...queryLoopEntries(this.store, session.loopId, { prefix, feedbackOnly: true }),
128
- ];
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;
129
251
  return deriveSubmissionGuidance(session, {
130
- rounds: derivationRounds(entries, session.currentRound),
131
- ...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),
132
261
  });
133
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
+ }
134
273
  /** v3.9.1: the before facts of the round being submitted, from the SAME
135
274
  * history window the compile uses. Works on a replay too: rounds below the
136
275
  * logical round exclude the round itself. */
137
- beforeFactsFor(session) {
138
- const prefix = `loop:${session.loopId}:`;
139
- const entries = [
140
- ...queryLoopEntries(this.store, session.loopId, { prefix }),
141
- ...queryLoopEntries(this.store, session.loopId, { prefix, feedbackOnly: true }),
142
- ];
143
- 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;
144
283
  return {
145
284
  contractId: deriveActiveRoundContract(rounds, getPolicy().evidence.commands ?? [])?.id ?? null,
146
285
  subGoals: deriveSubGoals({
@@ -148,31 +287,19 @@ export class RoundLifecycle {
148
287
  currentRound: session.currentRound,
149
288
  rounds,
150
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,
151
293
  };
152
294
  }
153
295
  /** v3.9.1: derive this submission's receipt from the transaction and the
154
296
  * shared read model. Never writes and never invents an applied effect. */
155
297
  buildReceipt(base, opts) {
156
- const prefix = `loop:${base.snapshot.loopId}:`;
157
- const entries = [
158
- ...queryLoopEntries(this.store, base.snapshot.loopId, { prefix }),
159
- ...queryLoopEntries(this.store, base.snapshot.loopId, {
160
- prefix,
161
- feedbackOnly: true,
162
- }),
163
- ];
298
+ const history = loadCommittedHistory(this.store, base.snapshot.loopId);
164
299
  const committed = opts.disposition === "accepted" || opts.disposition === "stopped";
165
- 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);
166
301
  const commands = getPolicy().evidence.commands ?? [];
167
302
  const roundDeltas = deriveRoundObservationDelta(base.snapshot.beforeEvidence, base.actualEvidence);
168
- const contractView = deriveRoundContractView({
169
- rounds,
170
- round: base.snapshot.round,
171
- report: base.selfEval.execution_report ?? null,
172
- deltas: roundDeltas,
173
- outcome: effectiveOutcome(base.selfEval),
174
- commands,
175
- });
176
303
  const contractAfterId = opts.contractAfterId !== undefined
177
304
  ? opts.contractAfterId
178
305
  : committed
@@ -187,6 +314,15 @@ export class RoundLifecycle {
187
314
  rounds,
188
315
  })
189
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);
190
326
  return deriveSubmissionReceipt({
191
327
  snapshot: base.snapshot,
192
328
  selfEval: base.selfEval,
@@ -194,25 +330,26 @@ export class RoundLifecycle {
194
330
  disposition: opts.disposition,
195
331
  diagnostics: base.diagnostics,
196
332
  submittedFields: base.submittedFields,
333
+ recoveryDirective,
334
+ ...(opts.restoreVerified !== undefined
335
+ ? { recoveryRestoreVerified: opts.restoreVerified }
336
+ : {}),
197
337
  facts: {
198
338
  contractBefore: base.beforeContractId,
199
339
  contractAfter: contractAfterId,
200
340
  subGoalsBefore: base.beforeSubGoals,
201
341
  subGoalsAfter: afterSubGoals,
202
342
  constraintsActiveAfter: opts.constraintsActiveAfter ?? null,
343
+ constraintsRetiredAfter: opts.constraintsRetiredAfter ?? null,
203
344
  },
204
345
  nextArtifact: opts.nextArtifact ?? null,
205
- contractItems: contractView.statuses.items.map((item) => ({
206
- item_id: item.itemId,
207
- status: item.status,
208
- })),
209
346
  evidenceDeltas: roundDeltas,
210
347
  });
211
348
  }
212
349
  /** Persist session state to vault for cross-process recovery.
213
350
  * The filtered vault and replacement entry are written once under the
214
351
  * backend lock, so recovery never observes the old two-write gap. */
215
- save(session) {
352
+ save(session, options = {}) {
216
353
  if (!this.sessionStore)
217
354
  return;
218
355
  const leaseActive = session.status === "running";
@@ -226,30 +363,20 @@ export class RoundLifecycle {
226
363
  session_id: session.sessionId,
227
364
  current_round: session.currentRound,
228
365
  max_rounds: session.maxRounds,
229
- success_trajectory: session.successTrajectory,
230
366
  status: session.status,
231
367
  created_at: session.createdAt,
232
368
  // v1.13: Enforcement gate state
233
369
  consecutive_rejections: session.consecutiveRejections,
234
370
  last_rejection_check: session.lastRejectionCheck,
235
- // v2.13: Backtrack skipped files for workspace restore check
236
- backtrack_skipped_files: session.backtrackSkippedFiles,
237
- // M3: skipped-file fingerprints at their failed rounds (restore
238
- // check machine arm) — empty unless a backtrack is pending
239
- backtrack_skipped_fingerprints: session.backtrackSkippedFingerprints,
240
371
  // v3.8.3: the in-flight entrypoint trust baseline. Read back
241
372
  // leniently (parseFingerprintMap) so a corrupt or absent value
242
373
  // degrades to "no absolute arm" instead of costing the round.
243
374
  entrypoint_trust: session.entrypointTrust ?? null,
244
- // v2.12: Backtrack restore-point git HEAD (crash recovery)
245
- backtrack_target_git_head: session.backtrackTargetGitHead ?? null,
246
- // v3.9: Restore point's own dirty files (restore baseline)
247
- 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,
248
378
  // v1.19: durable round transaction state
249
379
  round_snapshot: session.roundSnapshot ?? null,
250
- last_self_eval: session.lastSelfEval ?? null,
251
- current_prompt: session.currentPrompt ?? null,
252
- current_level: session.currentLevel ?? "",
253
380
  // v1.20: cross-process single-owner lease
254
381
  lease_owner: leaseActive ? this.ownerId : "",
255
382
  lease_expires_at: leaseActive ? Date.now() + this.leaseMs : 0,
@@ -257,6 +384,7 @@ export class RoundLifecycle {
257
384
  };
258
385
  this.sessionStore.save(sessionEntry, {
259
386
  expectedLeaseOwner: this.ownerId,
387
+ ...options,
260
388
  });
261
389
  }
262
390
  /** Reconstruct a McpSession from a vault session_state entry.
@@ -265,7 +393,11 @@ export class RoundLifecycle {
265
393
  reconstructSession(entry, allowPaused = false) {
266
394
  const lineage = (entry.loop_lineage ?? {});
267
395
  const status = lineage.status ?? "running";
268
- 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")) {
269
401
  return null;
270
402
  }
271
403
  const loopId = entry.loop_id;
@@ -276,60 +408,64 @@ export class RoundLifecycle {
276
408
  // only reachable through the read-only audit.
277
409
  checkRoundSequence(this.store, loopId);
278
410
  const currentRound = lineage.current_round ?? 1;
279
- const successTrajectory = Array.isArray(lineage.success_trajectory)
280
- ? lineage.success_trajectory.filter((value) => typeof value === "boolean")
281
- : [];
282
411
  const task = entry.task ?? "";
283
412
  const maxRounds = lineage.max_rounds ?? getPolicy().engine.max_rounds;
284
- const roundSnapshot = parseRoundTransactionSnapshot(lineage.round_snapshot);
285
- // v3.7: the synchronous evidence fallback was removed (async providers
286
- // could never be collected synchronously — they were skipped and logged
287
- // as failures). Every path that needs evidence re-collects asynchronously
288
- // through prepare()/unpause(); a missing snapshot starts with no baseline.
289
- const fallbackEvidence = [];
290
- const persistedEval = lineage.last_self_eval;
291
- const lastSelfEval = persistedEval !== null &&
292
- typeof persistedEval === "object" &&
293
- !Array.isArray(persistedEval) &&
294
- typeof persistedEval.success === "boolean" &&
295
- typeof persistedEval.output_summary === "string"
296
- ? persistedEval
297
- : 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;
298
438
  const engine = new LoopForgeEngine(this.store);
299
439
  return {
300
440
  sessionId: typeof lineage.session_id === "string" && lineage.session_id
301
441
  ? lineage.session_id
302
442
  : randomUUID(),
303
443
  loopId, task, engine,
304
- currentRound, maxRounds, successTrajectory,
444
+ currentRound, maxRounds,
305
445
  status: status,
306
446
  createdAt: lineage.created_at ?? Date.now(),
307
- consecutiveRejections: lineage.consecutive_rejections ?? 0,
308
- lastRejectionCheck: typeof lineage.last_rejection_check === "string"
309
- ? lineage.last_rejection_check
310
- : "",
311
- backtrackSkippedFiles: lineage.backtrack_skipped_files ?? [],
312
- backtrackSkippedFingerprints: parseFingerprintMap(lineage.backtrack_skipped_fingerprints),
313
- entrypointTrust: parseFingerprintMap(lineage.entrypoint_trust),
314
- backtrackTargetGitHead: typeof lineage.backtrack_target_git_head === "string" &&
315
- lineage.backtrack_target_git_head.length > 0
316
- ? lineage.backtrack_target_git_head
317
- : undefined,
318
- // v3.9: absent means "baseline unknown" (a session from before this
319
- // field), never an empty baseline: the restore check then falls back
320
- // to HEAD equality alone rather than reading unrelated dirt as failure.
321
- backtrackTargetDirty: Array.isArray(lineage.backtrack_target_dirty)
322
- ? lineage.backtrack_target_dirty.filter((file) => typeof file === "string")
323
- : undefined,
324
- evidenceBaseline: roundSnapshot?.beforeEvidence ?? fallbackEvidence,
325
- roundSnapshot: roundSnapshot ?? prepareRoundTransaction(loopId, currentRound, fallbackEvidence),
326
- lastSelfEval,
327
- currentPrompt: typeof lineage.current_prompt === "string"
328
- ? lineage.current_prompt
329
- : null,
330
- currentLevel: typeof lineage.current_level === "string"
331
- ? lineage.current_level
332
- : 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,
333
469
  };
334
470
  }
335
471
  /** Apply a prepared round to the session, persist, and build the result.
@@ -347,6 +483,14 @@ export class RoundLifecycle {
347
483
  * `newConsecutiveRejections` comes from the enforcement gate and already
348
484
  * counts the round; it is only honoured when the check is unchanged. */
349
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
+ }
350
494
  if (pr.action === "reject" && pr.rejectionCheck) {
351
495
  session.consecutiveRejections =
352
496
  pr.rejectionCheck === session.lastRejectionCheck
@@ -359,8 +503,6 @@ export class RoundLifecycle {
359
503
  if (pr.action !== "reject")
360
504
  session.lastRejectionCheck = "";
361
505
  }
362
- if (pr.newLastSelfEval)
363
- session.lastSelfEval = pr.newLastSelfEval;
364
506
  }
365
507
  /** v3.8.3: capture the trusted entrypoint baseline for the round about to be
366
508
  * prepared.
@@ -373,6 +515,9 @@ export class RoundLifecycle {
373
515
  *
374
516
  * Best-effort: an unreadable policy or an unresolvable command must leave
375
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. */
376
521
  seedEntrypointTrust(session) {
377
522
  try {
378
523
  session.entrypointTrust = captureEntrypointTrust(getPolicy());
@@ -381,68 +526,212 @@ export class RoundLifecycle {
381
526
  session.entrypointTrust = undefined;
382
527
  }
383
528
  }
384
- persistPrepared(session, prepared, roundSuccess, warningsOverride) {
385
- 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;
386
563
  session.roundSnapshot = prepared.snapshot;
387
- this.seedEntrypointTrust(session);
388
- session.currentPrompt = prepared.prompt;
389
- session.currentLevel = prepared.level;
390
- session.currentWarnings = prepared.warnings;
391
- 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
+ }
392
599
  policyMetrics.recordStrategy(session.loopId, prepared.level);
393
- this.save(session);
394
600
  return {
395
601
  sessionId: session.sessionId,
396
602
  round: session.currentRound,
397
- roundId: session.roundSnapshot.roundId,
603
+ ...this.heldHandle(session),
604
+ ...this.transactionFacts(session),
605
+ ...this.contextFacts(prepared.snapshot.promptArtifact),
398
606
  prompt: prepared.prompt,
399
607
  level: prepared.level,
400
- roundSuccess,
401
- // v3.8: the posture of the round that just committed — read from the
402
- // transaction's committed result, not recomputed here.
403
- verificationStatus: session.roundSnapshot?.result?.verificationStatus,
404
- 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 ?? [],
405
616
  submission_guidance: this.guidanceFor(session),
406
617
  };
407
618
  }
408
- restoredPromptResult(session) {
409
- 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)
410
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;
411
706
  return {
412
707
  sessionId: session.sessionId,
413
708
  round: session.currentRound,
414
- roundId: session.roundSnapshot?.roundId,
415
- prompt: session.currentPrompt,
416
- level: session.currentLevel ?? "l2",
417
- roundSuccess: undefined,
418
- warnings: (session.currentWarnings ?? []),
419
- 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 } : {}),
420
720
  };
421
721
  }
422
722
  /** Reconcile the crash window where feedback committed but session_state
423
723
  * still points at the old prompt. Returns null when no commit is pending.
424
724
  * v3.7: async — the continue-after-crash branch compiles through
425
725
  * RoundDriver.prepare (the sync prepareSync fallback was removed). */
426
- async reconcileCommittedRound(session) {
726
+ async reconcileCommittedRound(session, inputs) {
427
727
  if (!session.roundSnapshot)
428
728
  return null;
429
- const recovered = new RoundDriver(session.engine, this.store).recover(session.roundSnapshot);
729
+ const recovered = this.driver(session.engine, session.loopId).recover(session.roundSnapshot);
430
730
  if (!recovered)
431
731
  return null;
432
732
  const pr = recovered.result;
433
733
  session.roundSnapshot = recovered.snapshot;
434
734
  this.applyRoundCounters(session, pr);
435
- // L1 (v3.7.x): trajectory push guard must use the SAME judgement as the
436
- // counter guard below (recovered.snapshot.round). Comparing against
437
- // session.currentRound conflates two windows: the crash window (persisted
438
- // length N-1, currentRound N — push needed) and the pause race (pause()
439
- // persisted the incremented counter AND the already-pushed round N —
440
- // length N, currentRound N+1 — pushing again duplicates the entry).
441
- if (pr.shouldPushSuccessTrajectory &&
442
- session.successTrajectory.length < recovered.snapshot.round) {
443
- session.successTrajectory.push(pr.roundSuccess);
444
- }
445
- session.currentPrompt = null;
446
735
  if (pr.action === "stop" || pr.action === "terminate") {
447
736
  // v3.8.3: a replayed termination carries the gate's own reason for the
448
737
  // same reason the live path does (see buildTerminationResult) — the
@@ -453,34 +742,38 @@ export class RoundLifecycle {
453
742
  session.status = reason === "stalled" ? "stalled" : "stopped";
454
743
  this.save(session);
455
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;
456
755
  return {
457
756
  sessionId: session.sessionId,
458
757
  round: session.currentRound,
459
- roundId: recovered.snapshot.roundId,
460
758
  prompt: null,
461
759
  stopReason: reason,
462
- stopDetail: reason === "completed"
463
- ? "Task completed successfully — the agent declared should_continue=false with success=true."
464
- : reason === "blocked"
465
- ? "Agent cannot proceed under current constraints. The task may need revised constraints or human intervention."
466
- : reason === "failed"
467
- ? "Agent declared should_continue=false with success=false — the task was not achieved."
468
- : `Loop stopped: ${reason}.`,
469
- 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 } : {}),
470
766
  };
471
767
  }
472
- if (pr.action === "backtrack") {
473
- // Backtrack committed but prompt not delivered (crash window).
474
- // Reset round counter to the restore target + 1 so the next
475
- // resume / unpause compiles from the correct restored state.
476
- const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
477
- 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;
478
773
  session.consecutiveRejections = 0;
479
774
  session.lastRejectionCheck = "";
480
- session.lastSelfEval = undefined;
481
- session.currentPrompt = null;
482
775
  this.save(session);
483
- // Return null — caller (resume/unpause) will recompile normally
776
+ // Return null — caller (resume/unpause) will restore/recompile.
484
777
  return null;
485
778
  }
486
779
  if (pr.action !== "continue")
@@ -493,9 +786,78 @@ export class RoundLifecycle {
493
786
  if (session.currentRound <= recovered.snapshot.round) {
494
787
  session.currentRound = recovered.snapshot.round + 1;
495
788
  }
496
- const request = buildLoopRequest(session, pr.newLastSelfEval, pr.verificationFlags);
497
- const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, session.currentRound);
498
- 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) {
499
861
  session.status = "stalled";
500
862
  this.save(session);
501
863
  return {
@@ -503,123 +865,313 @@ export class RoundLifecycle {
503
865
  round: session.currentRound,
504
866
  prompt: null,
505
867
  stopReason: "stalled",
506
- 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.",
507
869
  };
508
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
+ }
509
1013
  return this.persistPrepared(session, {
510
1014
  prompt: prepared.prompt,
511
1015
  level: prepared.level,
512
1016
  baseline: prepared.evidenceBaseline,
513
1017
  snapshot: prepared.snapshot,
514
- warnings: prepared?.warnings ?? [],
515
- }, 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));
516
1092
  }
517
1093
  /** Resume tail: the session has been reconstructed and registered. Reconcile
518
1094
  * a committed-but-undelivered round, restore the held prompt, or recover a
519
1095
  * missing prompt from current round state. v3.7: async — compilation runs
520
1096
  * through RoundDriver.prepare like unpause (the prepareSync fallback was
521
1097
  * removed); a failed compile now degrades to the same stalled terminal
522
- * result unpause returns instead of persisting a null prompt. */
523
- async resume(session) {
524
- 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);
525
1106
  if (reconciled)
526
1107
  return reconciled;
527
- 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);
528
1112
  if (restored)
529
1113
  return restored;
530
1114
  // Missing held prompt: compile (async) once, then persist it.
531
- const request = buildLoopRequest(session);
532
- const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, session.currentRound);
533
- if (!prepared) {
534
- session.status = "stopped";
535
- this.save(session);
536
- void this.notifyTerminal(session, "stalled");
537
- return {
538
- sessionId: session.sessionId,
539
- round: session.currentRound,
540
- prompt: null,
541
- stopReason: "stalled",
542
- stopDetail: "RoundDriver.prepare returned null during resume — the compiler could not produce a prompt.",
543
- };
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;
544
1127
  }
545
1128
  return this.persistPrepared(session, {
546
1129
  prompt: prepared.prompt,
547
1130
  level: prepared.level,
548
1131
  baseline: prepared.evidenceBaseline,
549
1132
  snapshot: prepared.snapshot,
550
- warnings: prepared?.warnings ?? [],
551
- }, false);
1133
+ warnings: prepared.warnings ?? [],
1134
+ }, false, this.resumeWarnings(session, prepared.warnings), false);
552
1135
  }
553
1136
  /** Unpause tail: the session has been reconstructed and registered with
554
- * status "running". Refresh async evidence, reconcile a committed round,
555
- * restore the held prompt, or compile the next prompt. */
556
- async unpause(session) {
557
- // Replace the sync-fallback evidence baseline with async evidence
558
- // so resumed sessions don't silently drop async provider data.
559
- try {
560
- const asyncEvidence = await EvidenceCollector.fromPolicy().collectAsync({
561
- loopId: session.loopId,
562
- phase: "before",
563
- });
564
- if (asyncEvidence.length > 0) {
565
- session.evidenceBaseline = asyncEvidence;
566
- if (!session.roundSnapshot?.beforeEvidence?.length) {
567
- session.roundSnapshot = prepareRoundTransaction(session.loopId, session.currentRound, asyncEvidence);
568
- }
569
- }
570
- }
571
- catch {
572
- // Async evidence is best-effort; fall back to sync baseline.
573
- }
574
- 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);
575
1145
  if (reconciled)
576
1146
  return reconciled;
577
- 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);
578
1151
  if (restored) {
579
1152
  this.save(session);
580
1153
  return restored;
581
1154
  }
582
- // Compile the next prompt from the current round state
583
- const lcr = makeLoopCompileRequest({
584
- loop_id: session.loopId,
585
- round: session.currentRound,
586
- goal_id: "",
587
- task: session.task,
588
- domain: undefined,
589
- plan_source: null,
590
- constraints_from_plan: [],
591
- });
592
- const compileRequest = {
593
- task: session.task,
594
- mode: Mode.LOOP_COMPILE,
595
- feedback: null,
596
- skill_name: null,
597
- task_id: null,
598
- loop_id: lcr.loop_id,
599
- round: lcr.round,
600
- goal_id: lcr.goal_id,
601
- domain: lcr.domain ?? "",
602
- plan_source: lcr.plan_source ?? null,
603
- constraints_from_plan: lcr.constraints_from_plan ?? [],
604
- max_rounds: session.maxRounds,
605
- };
606
- const prepared = await new RoundDriver(session.engine, this.store).prepare(compileRequest, session.loopId, session.currentRound);
607
- if (!prepared) {
608
- session.status = "stopped";
609
- this.save(session);
610
- void this.notifyTerminal(session, "stalled");
611
- 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;
612
1167
  }
613
1168
  return this.persistPrepared(session, {
614
1169
  prompt: prepared.prompt,
615
1170
  level: prepared.level,
616
1171
  baseline: prepared.evidenceBaseline,
617
1172
  snapshot: prepared.snapshot,
618
- warnings: prepared?.warnings ?? [],
619
- }, undefined,
620
- // Historical contract: unpause returns an empty warnings array even
621
- // though the session carries the compiled warnings.
622
- []);
1173
+ warnings: prepared.warnings ?? [],
1174
+ }, undefined, this.resumeWarnings(session, prepared.warnings), false);
623
1175
  }
624
1176
  /** Record a gate_opened entry when an accepted round reports a high-risk
625
1177
  * blocker (USER_RISK hit). Idempotent per gate. The gate is a record
@@ -631,8 +1183,8 @@ export class RoundLifecycle {
631
1183
  if (gate.kind !== "user")
632
1184
  return;
633
1185
  const taskId = `loop:${session.loopId}:gate:${id}`;
634
- const existing = queryLoopEntries(this.store, session.loopId, { prefix: taskId })
635
- .some((entry) => entry.task_id === taskId);
1186
+ const existing = loadCommittedHistory(this.store, session.loopId)
1187
+ .entries.some((entry) => entry.task_id === taskId);
636
1188
  if (existing)
637
1189
  return;
638
1190
  const entry = {
@@ -653,7 +1205,9 @@ export class RoundLifecycle {
653
1205
  }
654
1206
  /** Undecided user gate descriptions for a loop (recorded but unresolved). */
655
1207
  listOpenGateDescriptions(loopId) {
656
- 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));
657
1211
  const decided = new Set(entries.filter((entry) => entry.task_type === "gate_decision")
658
1212
  .map((entry) => String(entry.gate_id ?? "")));
659
1213
  return entries
@@ -670,24 +1224,21 @@ export class RoundLifecycle {
670
1224
  }
671
1225
  /** Execute the round transaction and apply per-rule rejection tracking.
672
1226
  * MUTATES: session.roundSnapshot, session.consecutiveRejections,
673
- * session.lastRejectionCheck, session.lastSelfEval,
674
- * session.successTrajectory */
675
- async executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields) {
676
- const snapshot = session.roundSnapshot ?? prepareRoundTransaction(session.loopId, session.currentRound, session.evidenceBaseline ?? []);
677
- 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({
678
1234
  snapshot,
679
1235
  loopId: session.loopId,
680
1236
  task: session.task,
681
1237
  maxRounds: session.maxRounds,
1238
+ ...(history ? { history } : {}),
682
1239
  selfEval,
683
- lastSelfEval: session.lastSelfEval,
684
1240
  consecutiveRejections: session.consecutiveRejections,
685
1241
  lastRejectionCheck: session.lastRejectionCheck,
686
- successTrajectory: session.successTrajectory,
687
- backtrackSkippedFiles: session.backtrackSkippedFiles,
688
- backtrackSkippedFingerprints: session.backtrackSkippedFingerprints,
689
- backtrackTargetGitHead: session.backtrackTargetGitHead,
690
- backtrackTargetDirty: session.backtrackTargetDirty,
691
1242
  entrypointTrust: session.entrypointTrust,
692
1243
  normalizationDiagnostics,
693
1244
  submittedFields,
@@ -697,14 +1248,6 @@ export class RoundLifecycle {
697
1248
  session.roundSnapshot = outcome.snapshot;
698
1249
  const pr = outcome.result;
699
1250
  this.applyRoundCounters(session, pr);
700
- // L1 (v3.7.x): never push a REPLAYED outcome onto the trajectory. When a
701
- // crash/exception window left the committed round undelivered, the replay
702
- // re-executes the same round in memory — its success was already recorded
703
- // by the original attempt (crash recovery re-pushes through
704
- // reconcileCommittedRound instead, which uses the length-vs-round guard).
705
- if (pr.shouldPushSuccessTrajectory && !completed.outcome.replayed) {
706
- session.successTrajectory.push(pr.roundSuccess);
707
- }
708
1251
  return {
709
1252
  pr,
710
1253
  verificationFlags: pr.verificationFlags,
@@ -714,168 +1257,386 @@ export class RoundLifecycle {
714
1257
  }
715
1258
  /** Build a rejection result: compile a retry prompt, persist, return.
716
1259
  * MUTATES: session.roundSnapshot, session.currentPrompt, session.currentLevel */
717
- async buildRejectionResult(sessionId, session, pr, verificationFlags, receiptBase) {
718
- const retryRequest = buildLoopRequest(session, undefined, verificationFlags);
719
- const preparedRetry = await new RoundDriver(session.engine, this.store).prepareRetry(retryRequest, session.roundSnapshot, pr.rejectionPrompt ?? "", session.consecutiveRejections);
720
- if (!preparedRetry) {
721
- session.status = "stalled";
722
- session.currentPrompt = null;
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 {
723
1278
  this.save(session);
724
- return {
725
- sessionId,
726
- round: session.currentRound,
727
- roundId: session.roundSnapshot?.roundId,
728
- prompt: null,
729
- stopReason: "stalled",
730
- stopDetail: "RoundDriver.prepareRetry returned null — the retry prompt could not be compiled.",
731
- };
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
+ }
1284
+ // In-flight attempt findings are authority for THIS attempt, not
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);
1293
+ retryRequest.verification_flags = verificationFlags;
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;
732
1307
  }
733
1308
  session.roundSnapshot = preparedRetry.snapshot;
734
- session.currentPrompt = preparedRetry.prompt;
735
- session.currentLevel = preparedRetry.level;
736
- session.currentWarnings = preparedRetry.warnings ?? [];
737
- session.lastCompileResponse = preparedRetry.compileResponse ?? null;
738
1309
  this.save(session);
1310
+ const retryArtifact = preparedRetry.artifact ??
1311
+ preparedRetry.compileResponse?.prompt_artifact ?? null;
739
1312
  return {
740
1313
  sessionId,
741
1314
  round: session.currentRound,
742
- roundId: session.roundSnapshot.roundId,
1315
+ ...this.heldHandle(session),
1316
+ ...this.transactionFacts(session),
1317
+ ...this.contextFacts(retryArtifact),
743
1318
  prompt: preparedRetry.prompt,
744
1319
  level: preparedRetry.level,
1320
+ agentSuccessClaim: pr.agentSuccessClaim,
1321
+ agentOutcome: pr.agentOutcome,
745
1322
  enforcementAction: "reject",
746
- verificationStatus: pr.verificationStatus,
1323
+ evidencePosture: pr.evidencePosture,
747
1324
  enforcementReason: pr.enforcementReason,
748
1325
  submission_guidance: this.guidanceFor(session, {
749
1326
  verificationFlags: pr.verificationFlags,
750
1327
  }),
751
1328
  submission_receipt: this.buildReceipt(receiptBase, {
752
1329
  disposition: "rejected",
753
- nextArtifact: preparedRetry.artifact ??
754
- preparedRetry.compileResponse?.prompt_artifact ?? null,
1330
+ nextArtifact: retryArtifact,
755
1331
  afterSubGoals: null,
756
1332
  constraintsActiveAfter: null,
757
1333
  contractAfterId: receiptBase.beforeContractId,
758
1334
  }),
759
1335
  };
760
1336
  }
761
- /** v2.10: Build a backtrack result — roll back to last clean round.
762
- * Resets the round counter to the restore target + 1, merges preserved
763
- * discoveries, compiles from the restored state, and injects the
764
- * backtrack prompt at the top.
765
- * MUTATES: session.currentRound, session.roundSnapshot,
766
- * session.currentPrompt, session.currentLevel,
767
- * session.consecutiveRejections, session.lastSelfEval */
768
- async buildBacktrackResult(sessionId, session, pr, receiptBase) {
769
- // v3.8: LoopForge never mutates the working tree. The backtrack prompt
770
- // carries the restore facts (files, fingerprints, target HEAD) and the
771
- // verification gate enforces the check on the redo submission; the agent
772
- // executes the restore itself. The former `backtrack_auto_restore` policy
773
- // and its git stash/reset implementation were deleted.
774
- const restoreTarget = pr.backtrackTarget ?? (session.currentRound - 1);
775
- const newRound = restoreTarget + 1;
776
- 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", {
777
1494
  sessionId,
778
1495
  loopId: session.loopId,
779
1496
  fromRound: session.currentRound,
780
1497
  toRound: newRound,
781
- triggerRule: pr.backtrackTriggerRule ?? "",
782
- 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,
783
1502
  });
784
- // Reset session to the restored round
785
- session.currentRound = newRound;
786
1503
  session.consecutiveRejections = 0;
787
1504
  session.lastRejectionCheck = "";
788
- session.backtrackSkippedFiles = pr.backtrackSkippedFiles ?? [];
789
- // M3: carry each skipped file's failed-round fingerprint for the
790
- // machine-proven restore check on the redo submission.
791
- session.backtrackSkippedFingerprints = pr.backtrackSkippedFingerprints ?? {};
792
- // v2.12: Remember the restore point's git HEAD — the next round's
793
- // verification gate confirms the workspace returned to this commit.
794
- session.backtrackTargetGitHead = pr.backtrackTargetGitHead;
795
- // v3.9: …and what was already dirty there, so the restore check compares
796
- // dirty sets instead of demanding a globally clean tree.
797
- session.backtrackTargetDirty = pr.backtrackTargetDirty;
798
- session.lastSelfEval = undefined; // force L2 recompile from vault state
799
- session.currentPrompt = null;
800
- // Preserve discoveries from skipped rounds as constraints_from_plan
801
- // so they survive the compilation and re-enter active constraints.
802
- const preservedDiscoveries = pr.backtrackSkippedDiscoveries ?? [];
803
- // Compile the restored prompt — use a fresh request with the restored round
804
- const request = {
805
- task: session.task,
806
- mode: Mode.LOOP_COMPILE,
807
- feedback: null,
808
- skill_name: null,
809
- task_id: null,
810
- loop_id: session.loopId,
811
- round: newRound,
812
- max_rounds: session.maxRounds,
813
- verification_flags: pr.verificationFlags,
814
- constraints_from_plan: preservedDiscoveries,
815
- };
816
- const prepared = await new RoundDriver(session.engine, this.store).prepare(request, session.loopId, newRound);
817
- if (!prepared || !prepared.prompt) {
818
- session.status = "stalled";
819
- session.currentPrompt = null;
820
- 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) {
821
1513
  return {
822
- sessionId,
823
- round: newRound,
824
- prompt: null,
825
- stopReason: "stalled",
826
- stopDetail: "Backtrack failed: RoundDriver.prepare returned null — could not compile from restore point.",
827
- enforcementAction: "backtrack",
828
- verificationStatus: pr.verificationStatus,
829
- 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,
830
1520
  };
831
1521
  }
832
- // Inject backtrack prompt at the top of the compiled prompt
833
- const backtrackHeader = pr.backtrackPrompt ?? "";
834
- const fullPrompt = backtrackHeader
835
- ? backtrackHeader + "\n" + (prepared.prompt ?? "")
836
- : prepared.prompt ?? "";
837
- session.evidenceBaseline = prepared.evidenceBaseline;
1522
+ const { restore, prepared } = outcome;
838
1523
  session.roundSnapshot = prepared.snapshot;
839
- // v3.8.3: the rollback redo is a NEW round — `prepare` collected a fresh
840
- // before capture above — so its entrypoint baseline is the workspace as it
841
- // stands after the rollback, which is what lets a round that stalled on a
842
- // rewritten entrypoint proceed once the entrypoint is in place.
843
1524
  this.seedEntrypointTrust(session);
844
- session.currentPrompt = fullPrompt;
845
- session.currentLevel = prepared.level;
846
- session.currentWarnings = prepared?.warnings ?? [];
847
- session.lastCompileResponse = prepared?.compileResponse ?? null;
848
1525
  this.save(session);
1526
+ const nextArtifact = prepared.artifact ??
1527
+ prepared.compileResponse?.prompt_artifact ?? null;
849
1528
  return {
850
1529
  sessionId,
851
1530
  round: newRound,
852
- roundId: session.roundSnapshot?.roundId,
853
- prompt: fullPrompt,
1531
+ ...this.heldHandle(session),
1532
+ ...this.transactionFacts(session),
1533
+ ...this.contextFacts(nextArtifact),
1534
+ prompt: prepared.prompt,
854
1535
  level: prepared.level,
855
- enforcementAction: "backtrack",
856
- verificationStatus: pr.verificationStatus,
857
- enforcementReason: pr.enforcementReason,
858
- 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 ?? [],
859
1557
  submission_guidance: this.guidanceFor(session, {
860
- verificationFlags: pr.verificationFlags,
1558
+ verificationFlags: recoveryFlags,
861
1559
  recovery: {
862
- required: true,
863
- restore_point: pr.backtrackTarget ?? Math.max(1, newRound - 1),
864
- ...(pr.backtrackTargetGitHead
865
- ? { target_head: pr.backtrackTargetGitHead }
866
- : {}),
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,
867
1567
  },
868
1568
  }),
869
1569
  submission_receipt: this.buildReceipt(receiptBase, {
870
1570
  disposition: "backtracked",
871
- nextArtifact: prepared.artifact ??
872
- prepared.compileResponse?.prompt_artifact ?? null,
1571
+ nextArtifact,
873
1572
  afterSubGoals: null,
874
1573
  constraintsActiveAfter: null,
875
1574
  contractAfterId: receiptBase.beforeContractId,
1575
+ // The SAME verified-restore fact the top-level recovery reports.
1576
+ restoreVerified: true,
876
1577
  }),
877
1578
  };
878
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
+ }
879
1640
  /** Build a termination result: persist stopped status, notify sinks.
880
1641
  * MUTATES: session.status, session.currentPrompt
881
1642
  *
@@ -889,7 +1650,6 @@ export class RoundLifecycle {
889
1650
  guardedReason, receiptBase) {
890
1651
  const reason = guardedReason ?? pr.stopReason ?? "enforcement_terminated";
891
1652
  session.status = "stopped";
892
- session.currentPrompt = null;
893
1653
  // v3.8.3: no round is in flight once the loop stops — the in-flight trust
894
1654
  // baseline goes with it, so a later resume cannot compare against a state
895
1655
  // from a previous life.
@@ -903,12 +1663,19 @@ export class RoundLifecycle {
903
1663
  return {
904
1664
  sessionId,
905
1665
  round: session.currentRound,
906
- roundId: session.roundSnapshot?.roundId,
907
1666
  prompt: null,
908
1667
  stopReason: reason,
909
- 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"),
910
1677
  enforcementAction: "terminate",
911
- verificationStatus: pr.verificationStatus,
1678
+ evidencePosture: pr.evidencePosture,
912
1679
  enforcementReason: pr.enforcementReason,
913
1680
  submission_receipt: this.buildReceipt(receiptBase, {
914
1681
  disposition: "terminated",
@@ -927,7 +1694,6 @@ export class RoundLifecycle {
927
1694
  guardedReason, receiptBase) {
928
1695
  const reason = guardedReason ?? pr.stopReason ?? "stalled";
929
1696
  session.status = reason === "stalled" ? "stalled" : "stopped";
930
- session.currentPrompt = null;
931
1697
  // v3.8.3: see buildTerminationResult — the in-flight trust baseline dies
932
1698
  // with the in-flight round.
933
1699
  session.entrypointTrust = undefined;
@@ -936,12 +1702,18 @@ export class RoundLifecycle {
936
1702
  logEvent("session_end", { sessionId, loopId: session.loopId, stopReason: reason, round: session.currentRound });
937
1703
  return {
938
1704
  sessionId, round: session.currentRound,
939
- roundId: session.roundSnapshot?.roundId,
940
1705
  prompt: null, stopReason: reason,
941
- stopDetail: pr.stopReason === "max_rounds"
942
- ? "The configured maximum number of rounds has been reached."
943
- : `Loop stopped: ${reason}.`,
944
- 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,
945
1717
  submission_receipt: this.buildReceipt(receiptBase, {
946
1718
  disposition: "stopped",
947
1719
  nextArtifact: null,
@@ -953,8 +1725,8 @@ export class RoundLifecycle {
953
1725
  * Includes the commit fence (pause/delete race guard) and context provider.
954
1726
  * MUTATES: session.currentRound, session.currentPrompt, session.currentLevel,
955
1727
  * session.evidenceBaseline, session.roundSnapshot */
956
- async advanceToNextRound(sessionId, session, selfEval, pr, verificationFlags, actualEvidence, receiptBase) {
957
- const roundSuccess = pr.roundSuccess;
1728
+ async advanceToNextRound(sessionId, session, selfEval, pr, actualEvidence, receiptBase, inputs) {
1729
+ const agentSuccessClaim = pr.agentSuccessClaim;
958
1730
  // M4 (v3.7.x): replay guard. The counter may already point past the
959
1731
  // committed round when this continue is a REPLAY — after a crash or an
960
1732
  // in-process exception between the round's commit and this compile, the
@@ -965,53 +1737,23 @@ export class RoundLifecycle {
965
1737
  if (!session.roundSnapshot || session.currentRound <= session.roundSnapshot.round) {
966
1738
  session.currentRound++;
967
1739
  }
968
- session.currentPrompt = null;
969
- // v2.13: Clear backtrack skipped files after advancing past the restore round
970
- session.backtrackSkippedFiles = [];
971
- session.backtrackSkippedFingerprints = {};
972
- // v2.12: The restore-point git HEAD check only applies to the first
973
- // post-backtrack round — clear it once we advance past it.
974
- session.backtrackTargetGitHead = undefined;
975
- session.backtrackTargetDirty = undefined;
976
- // v1.8: Memory injection — tier-aware external context.
977
- const contextProvider = this.getContext();
978
- let externalCtx = "";
979
- if (contextProvider) {
980
- try {
981
- externalCtx = (await contextProvider({
982
- loopId: session.loopId,
983
- round: session.currentRound,
984
- task: session.task,
985
- domain: "",
986
- lastEvaluation: selfEval,
987
- })).trim();
988
- }
989
- catch {
990
- logEvent("context_provider_error", {
991
- loopId: session.loopId,
992
- round: session.currentRound,
993
- });
994
- }
995
- }
996
- // Commit fence: pause()/delete() may have run while the context provider
997
- // was awaited. Do not compile if the session is no longer running.
1740
+ // Commit fence: pause()/delete() may run while prepare is awaited.
998
1741
  if (this.registry.get(sessionId) !== session || session.status !== "running") {
999
1742
  return {
1000
1743
  sessionId,
1001
1744
  round: session.currentRound,
1002
1745
  prompt: null,
1003
- stopReason: session.status,
1004
- stopDetail: `Session is no longer running (status: ${session.status}). It was paused or deleted while the context provider was running.`,
1005
- 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,
1006
1749
  };
1007
1750
  }
1008
- const request = buildLoopRequest(session, selfEval, verificationFlags);
1009
- if (externalCtx) {
1010
- request.external_context = externalCtx;
1011
- }
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);
1012
1754
  let prepared;
1013
1755
  try {
1014
- 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));
1015
1757
  }
1016
1758
  catch (error) {
1017
1759
  // M4: a post-commit compile exception must not silently strand the
@@ -1024,31 +1766,38 @@ export class RoundLifecycle {
1024
1766
  round: session.currentRound,
1025
1767
  error: String(error),
1026
1768
  });
1027
- return this.stalledAfterCommit(sessionId, session, roundSuccess, `RoundDriver.prepare threw after the round committed: ${String(error).slice(0, 300)}. ` +
1028
- `The session is stalled with the committed round still held — resume or ` +
1029
- `resubmit the same roundId to recompile the next prompt.`);
1030
- }
1031
- if (!prepared) {
1032
- // M4: align with the sibling paths (resume / unpause / reject /
1033
- // backtrack / crash recovery) that treat a null preparation as a
1034
- // stalled session — previously this silently returned prompt:null
1035
- // without a stopReason while the session kept running.
1036
- return this.stalledAfterCommit(sessionId, session, roundSuccess, "RoundDriver.prepare returned null after the round committed — the " +
1037
- "next prompt could not be compiled. Resume or resubmit the same " +
1038
- "roundId to retry.");
1769
+ if (error instanceof ProjectionCoverageError) {
1770
+ // v3.9.4: the failure is a projection code defect, not a work-quality
1771
+ // verdict. Keep the committed round held and surface the stable code.
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.`);
1777
+ }
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
+ };
1039
1794
  }
1040
1795
  const nextPrompt = prepared.prompt;
1041
1796
  const nextLevel = prepared.level;
1042
- const nextBaseline = prepared.evidenceBaseline ?? actualEvidence;
1043
- session.evidenceBaseline = nextBaseline;
1044
1797
  session.roundSnapshot = prepared.snapshot;
1045
1798
  // v3.8.3: the round that just committed is history; the trust baseline
1046
1799
  // moves to the round being prepared now.
1047
1800
  this.seedEntrypointTrust(session);
1048
- session.currentPrompt = nextPrompt;
1049
- session.currentLevel = nextLevel;
1050
- session.currentWarnings = prepared.warnings ?? [];
1051
- session.lastCompileResponse = prepared.compileResponse ?? null;
1052
1801
  policyMetrics.recordStrategy(session.loopId, nextLevel);
1053
1802
  try {
1054
1803
  this.save(session);
@@ -1062,35 +1811,39 @@ export class RoundLifecycle {
1062
1811
  round: session.currentRound,
1063
1812
  error: String(error),
1064
1813
  });
1065
- 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)}. ` +
1066
1815
  `Resume the loop to recover the held prompt.`);
1067
1816
  }
1068
1817
  const compiled = prepared.compileResponse;
1818
+ const nextArtifact = prepared.artifact ?? compiled?.prompt_artifact ?? null;
1069
1819
  return {
1070
1820
  sessionId,
1071
1821
  round: session.currentRound,
1072
- roundId: session.roundSnapshot.roundId,
1822
+ ...this.heldHandle(session),
1823
+ ...this.transactionFacts(session),
1824
+ ...this.contextFacts(nextArtifact),
1073
1825
  prompt: nextPrompt,
1074
1826
  level: nextLevel,
1075
- roundSuccess,
1827
+ agentSuccessClaim,
1828
+ agentOutcome: pr.agentOutcome,
1076
1829
  warnings: prepared.warnings ?? [],
1077
1830
  submission_guidance: this.guidanceFor(session),
1078
1831
  submission_receipt: this.buildReceipt(receiptBase, {
1079
1832
  disposition: "accepted",
1080
- nextArtifact: prepared.artifact ?? compiled?.prompt_artifact ?? null,
1833
+ nextArtifact,
1081
1834
  afterSubGoals: compiled?.sub_goals ?? null,
1082
1835
  constraintsActiveAfter: compiled?.constraints_active ?? null,
1836
+ constraintsRetiredAfter: compiled?.constraints_retired ?? null,
1083
1837
  }),
1084
1838
  };
1085
1839
  }
1086
1840
  /** M4: mark a session stalled after its round committed but the next
1087
- * prompt could not be prepared/persisted — mirrors the crash-recovery
1088
- * siblings (reconcileCommittedRound / resume / reject paths). The
1089
- * committed round stays held (roundSnapshot untouched) so a resubmission
1090
- * with the same roundId replays it and recompiles the next round. */
1091
- stalledAfterCommit(sessionId, session, roundSuccess, detail) {
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) {
1092
1846
  session.status = "stalled";
1093
- session.currentPrompt = null;
1094
1847
  try {
1095
1848
  this.save(session);
1096
1849
  }
@@ -1104,117 +1857,155 @@ export class RoundLifecycle {
1104
1857
  return {
1105
1858
  sessionId,
1106
1859
  round: session.currentRound,
1107
- roundId: session.roundSnapshot?.roundId,
1108
1860
  prompt: null,
1109
1861
  stopReason: "stalled",
1110
1862
  stopDetail: detail,
1111
- roundSuccess,
1863
+ agentSuccessClaim,
1864
+ ...(projectionError ? { projectionError } : {}),
1112
1865
  };
1113
1866
  }
1114
- async advanceUnlocked(sessionId, output, preExtractedEval, roundId, normalizationDiagnostics, submittedFields) {
1867
+ async advanceUnlocked(sessionId, output, preExtractedEval, normalizationDiagnostics, submittedFields, inputs) {
1115
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.
1116
1873
  const session = this.registry.get(sessionId);
1117
- if (!session)
1118
- 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." };
1119
- if (session.status !== "running" && session.status !== "stalled") {
1120
- 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.` };
1121
- }
1122
- // A stalled compiler preparation is retryable against the same held
1123
- // round. Format errors never set this status; they return before advance.
1124
- if (session.status === "stalled") {
1125
- 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
+ };
1126
1887
  }
1127
- // ── 1.5 Anchor the submission to the round it reports on ─────────────
1128
- // v3.0.1: a submission whose roundId no longer matches the current round
1129
- // is stale — the round it reported on already committed (lost response,
1130
- // duplicate call). Never process it against the current round; return the
1131
- // held prompt so the agent recovers the response it missed. The reject
1132
- // path keeps the same roundId (prepareRejectedAttempt), so retries of a
1133
- // rejected round match normally.
1134
- const expectedRoundId = session.roundSnapshot?.roundId
1135
- ?? makeRoundId(session.loopId, session.currentRound);
1136
- if (roundId !== undefined && roundId !== expectedRoundId) {
1137
- logEvent("stale_round_id", {
1888
+ if (session.status !== "running" && session.status !== "stalled") {
1889
+ return {
1138
1890
  sessionId,
1139
- loopId: session.loopId,
1140
- submitted: roundId,
1141
- expected: expectedRoundId,
1142
- });
1143
- const restored = this.restoredPromptResult(session);
1144
- if (restored) {
1145
- return {
1146
- ...restored,
1147
- warnings: [
1148
- ...(restored.warnings ?? []),
1149
- `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.`,
1150
- ],
1151
- };
1152
- }
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)) {
1153
1909
  return {
1154
1910
  sessionId,
1155
1911
  round: session.currentRound,
1156
- roundId: expectedRoundId,
1157
1912
  prompt: null,
1158
- stopReason: "stale_round_id",
1159
- 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
+ },
1160
1923
  };
1161
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
+ }
1162
1930
  // ── 2. Extract self-evaluation ───────────────────────────────────────
1163
1931
  const extracted = this.extractEvaluation(output, preExtractedEval);
1164
1932
  if (!extracted) {
1165
1933
  return {
1166
1934
  sessionId,
1167
1935
  round: session.currentRound,
1168
- roundId: session.roundSnapshot?.roundId
1169
- ?? makeRoundId(session.loopId, session.currentRound),
1936
+ ...this.heldHandle(session),
1170
1937
  prompt: null,
1171
- stopReason: "evaluation_invalid",
1172
- 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.",
1173
1949
  };
1174
1950
  }
1175
1951
  const selfEval = extracted;
1176
1952
  // ── 3. Execute round transaction ─────────────────────────────────────
1177
1953
  // Capture the before facts from the shared compile window BEFORE the
1178
1954
  // transaction commits (a replay sees the same window: rounds < R).
1179
- const beforeFacts = this.beforeFactsFor(session);
1180
- const tx = await this.executeRoundTransaction(session, selfEval, normalizationDiagnostics, submittedFields);
1181
- 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;
1182
1966
  const receiptBase = {
1183
1967
  snapshot: receiptSnapshot,
1184
- selfEval,
1968
+ selfEval: receiptSelfEval,
1185
1969
  pr: tx.pr,
1186
- actualEvidence: tx.actualEvidence,
1187
- diagnostics: receiptSnapshot.normalizationDiagnostics ??
1188
- normalizationDiagnostics ?? [],
1189
- 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 ?? []),
1190
1980
  beforeContractId: beforeFacts.contractId,
1191
1981
  beforeSubGoals: beforeFacts.subGoals,
1982
+ recoveryDirective: beforeFacts.recoveryDirective,
1192
1983
  replayed: tx.replayed,
1193
1984
  };
1194
1985
  // v2.12: Record a user gate when an accepted round reports a high-risk
1195
1986
  // blocker. Record layer only — never blocks the round decision flow.
1196
1987
  if (tx.pr.action !== "reject" && tx.pr.action !== "terminate" &&
1197
- selfEval.outcome === "blocked" && typeof selfEval.blocker === "string") {
1198
- this.recordGateFromBlocked(session, selfEval.blocker);
1988
+ declaresBlocked(receiptSelfEval) &&
1989
+ typeof receiptSelfEval.blocker === "string") {
1990
+ this.recordGateFromBlocked(session, receiptSelfEval.blocker);
1199
1991
  }
1200
1992
  // ── 4. Route disposition ─────────────────────────────────────────────
1201
1993
  if (tx.pr.action === "reject") {
1202
- return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags, receiptBase);
1994
+ return this.buildRejectionResult(sessionId, session, tx.pr, tx.verificationFlags, receiptBase, inputs);
1203
1995
  }
1204
- // ── v2.10: Backtrack — roll back to last clean round ──────────────────
1205
- if (tx.pr.action === "backtrack") {
1206
- 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);
1207
1999
  }
1208
2000
  // Accepted rounds advance the before-snapshot baseline. Rejected rounds
1209
2001
  // retain the original baseline so their retry remains zero-commit.
1210
- session.evidenceBaseline = tx.actualEvidence;
1211
2002
  // v3.8.3: completion truth — the second line, at the point the disposition
1212
2003
  // becomes the reported stop reason. The coordinator already guards the
1213
2004
  // reason it COMMITS (the round document carries result.stopReason), so
1214
2005
  // this guards what the client and the terminal notification see. A
1215
2006
  // violation can only be a code defect: it degrades to the honest reason
1216
2007
  // and leaves an observability record instead of throwing mid-lifecycle.
1217
- const finalized = finalizeStopReason(tx.pr.stopReason, selfEval);
2008
+ const finalized = finalizeStopReason(tx.pr.stopReason, receiptSelfEval, undefined, receiptSelfEval.round_contract?.supersedes ?? null);
1218
2009
  if (finalized.reason !== tx.pr.stopReason) {
1219
2010
  logEvent("completion_truth_downgraded", {
1220
2011
  loopId: session.loopId,
@@ -1230,31 +2021,295 @@ export class RoundLifecycle {
1230
2021
  return this.buildStopResult(sessionId, session, tx.pr, finalized.reason, receiptBase);
1231
2022
  }
1232
2023
  // ── 5. Continue — compile next round ─────────────────────────────────
1233
- return this.advanceToNextRound(sessionId, session, selfEval, tx.pr, tx.verificationFlags, 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
+ };
1234
2275
  }
1235
2276
  /** Core cycle entry used by SessionManager.advance after the queue + lease
1236
2277
  * dance: validate → extract → execute transaction → route disposition
1237
2278
  * → advance to next round (continue) or return terminal result.
1238
- * @param roundId v3.0.1: the roundId of the round this submission reports
1239
- * on (from the last start/next/resume response). Anchors the submission;
1240
- * a stale or duplicate submission returns the held prompt instead of
1241
- * being processed. Optional — when absent the anchor check is skipped. */
1242
- async advance(sessionId, output, preExtractedEval, roundId, normalizationDiagnostics, submittedFields) {
1243
- 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);
1244
2286
  }
1245
2287
  /** Write back loop knowledge to long-term memory.
1246
2288
  * Uses shared base builder from policy.ts. Called when a loop terminates. */
1247
2289
  async notifyTerminal(session, stopReason) {
1248
2290
  if (this.terminalSinks.size === 0)
1249
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);
1250
2299
  const event = {
1251
2300
  loopId: session.loopId,
1252
2301
  task: session.task,
1253
- 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,
1254
2306
  stopReason: stopReason,
2307
+ // v4.0.4: keep the historical alias, but state the two facts separately.
1255
2308
  roundsCompleted: session.currentRound,
1256
- successTrajectory: [...session.successTrajectory],
1257
- lastEvaluation: session.lastSelfEval,
2309
+ roundCursor: session.currentRound,
2310
+ committedRounds: views.length,
2311
+ machineBackedSuccessTrajectory,
2312
+ lastEvaluation: lastView?.evaluation ?? undefined,
1258
2313
  };
1259
2314
  await Promise.allSettled([...this.terminalSinks].map((sink) => Promise.resolve(sink(event))));
1260
2315
  }