loopforge 3.9.4 → 4.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (283) hide show
  1. package/README.md +236 -112
  2. package/dist/audit.d.ts +73 -15
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +131 -61
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +114 -40
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +276 -182
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +41 -12
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-governance.d.ts +0 -2
  14. package/dist/cognitive-governance.d.ts.map +1 -1
  15. package/dist/cognitive-governance.js +0 -4
  16. package/dist/cognitive-governance.js.map +1 -1
  17. package/dist/committed-round.d.ts +97 -156
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +223 -419
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/constraint-lifecycle.d.ts +73 -0
  22. package/dist/constraint-lifecycle.d.ts.map +1 -0
  23. package/dist/constraint-lifecycle.js +174 -0
  24. package/dist/constraint-lifecycle.js.map +1 -0
  25. package/dist/context-boundary.d.ts +83 -0
  26. package/dist/context-boundary.d.ts.map +1 -0
  27. package/dist/context-boundary.js +101 -0
  28. package/dist/context-boundary.js.map +1 -0
  29. package/dist/contract-items.d.ts +12 -8
  30. package/dist/contract-items.d.ts.map +1 -1
  31. package/dist/contract-items.js +31 -26
  32. package/dist/contract-items.js.map +1 -1
  33. package/dist/contract-requirement.d.ts +47 -0
  34. package/dist/contract-requirement.d.ts.map +1 -0
  35. package/dist/contract-requirement.js +51 -0
  36. package/dist/contract-requirement.js.map +1 -0
  37. package/dist/criterion-status.d.ts +35 -0
  38. package/dist/criterion-status.d.ts.map +1 -0
  39. package/dist/criterion-status.js +89 -0
  40. package/dist/criterion-status.js.map +1 -0
  41. package/dist/enforcement-gate.d.ts +26 -38
  42. package/dist/enforcement-gate.d.ts.map +1 -1
  43. package/dist/enforcement-gate.js +196 -319
  44. package/dist/enforcement-gate.js.map +1 -1
  45. package/dist/engine.d.ts +9 -103
  46. package/dist/engine.d.ts.map +1 -1
  47. package/dist/engine.js +27 -897
  48. package/dist/engine.js.map +1 -1
  49. package/dist/errors.d.ts +41 -0
  50. package/dist/errors.d.ts.map +1 -0
  51. package/dist/errors.js +69 -0
  52. package/dist/errors.js.map +1 -0
  53. package/dist/evidence-claims.d.ts +33 -31
  54. package/dist/evidence-claims.d.ts.map +1 -1
  55. package/dist/evidence-claims.js +58 -72
  56. package/dist/evidence-claims.js.map +1 -1
  57. package/dist/evidence-posture-view.d.ts +19 -0
  58. package/dist/evidence-posture-view.d.ts.map +1 -0
  59. package/dist/evidence-posture-view.js +116 -0
  60. package/dist/evidence-posture-view.js.map +1 -0
  61. package/dist/evidence-posture.d.ts +73 -0
  62. package/dist/evidence-posture.d.ts.map +1 -0
  63. package/dist/evidence-posture.js +281 -0
  64. package/dist/evidence-posture.js.map +1 -0
  65. package/dist/evidence-provider.d.ts +55 -37
  66. package/dist/evidence-provider.d.ts.map +1 -1
  67. package/dist/evidence-provider.js +182 -332
  68. package/dist/evidence-provider.js.map +1 -1
  69. package/dist/explain.d.ts +166 -27
  70. package/dist/explain.d.ts.map +1 -1
  71. package/dist/explain.js +372 -58
  72. package/dist/explain.js.map +1 -1
  73. package/dist/field-accounting.d.ts.map +1 -1
  74. package/dist/field-accounting.js +3 -22
  75. package/dist/field-accounting.js.map +1 -1
  76. package/dist/generate-schema.js +13 -8
  77. package/dist/generate-schema.js.map +1 -1
  78. package/dist/git-boundary.d.ts +128 -0
  79. package/dist/git-boundary.d.ts.map +1 -0
  80. package/dist/git-boundary.js +625 -0
  81. package/dist/git-boundary.js.map +1 -0
  82. package/dist/git-facts.d.ts +22 -0
  83. package/dist/git-facts.d.ts.map +1 -0
  84. package/dist/git-facts.js +29 -0
  85. package/dist/git-facts.js.map +1 -0
  86. package/dist/git-motion.d.ts +37 -0
  87. package/dist/git-motion.d.ts.map +1 -0
  88. package/dist/git-motion.js +59 -0
  89. package/dist/git-motion.js.map +1 -0
  90. package/dist/history.d.ts +44 -0
  91. package/dist/history.d.ts.map +1 -0
  92. package/dist/history.js +77 -0
  93. package/dist/history.js.map +1 -0
  94. package/dist/index.d.ts +19 -9
  95. package/dist/index.d.ts.map +1 -1
  96. package/dist/index.js +12 -4
  97. package/dist/index.js.map +1 -1
  98. package/dist/init.d.ts +16 -0
  99. package/dist/init.d.ts.map +1 -1
  100. package/dist/init.js +80 -14
  101. package/dist/init.js.map +1 -1
  102. package/dist/loop-compiler.d.ts +39 -23
  103. package/dist/loop-compiler.d.ts.map +1 -1
  104. package/dist/loop-compiler.js +368 -565
  105. package/dist/loop-compiler.js.map +1 -1
  106. package/dist/loop-store.d.ts +6 -5
  107. package/dist/loop-store.d.ts.map +1 -1
  108. package/dist/loop-store.js +153 -35
  109. package/dist/loop-store.js.map +1 -1
  110. package/dist/mcp/round-lifecycle.d.ts +255 -81
  111. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  112. package/dist/mcp/round-lifecycle.js +1552 -539
  113. package/dist/mcp/round-lifecycle.js.map +1 -1
  114. package/dist/mcp/server.d.ts.map +1 -1
  115. package/dist/mcp/server.js +42 -18
  116. package/dist/mcp/server.js.map +1 -1
  117. package/dist/mcp/session.d.ts +81 -39
  118. package/dist/mcp/session.d.ts.map +1 -1
  119. package/dist/mcp/session.js +956 -282
  120. package/dist/mcp/session.js.map +1 -1
  121. package/dist/mcp/submission-boundary.d.ts +23 -31
  122. package/dist/mcp/submission-boundary.d.ts.map +1 -1
  123. package/dist/mcp/submission-boundary.js +242 -92
  124. package/dist/mcp/submission-boundary.js.map +1 -1
  125. package/dist/mcp/submission-handle.d.ts +66 -0
  126. package/dist/mcp/submission-handle.d.ts.map +1 -0
  127. package/dist/mcp/submission-handle.js +173 -0
  128. package/dist/mcp/submission-handle.js.map +1 -0
  129. package/dist/mcp/tools.d.ts +17 -7
  130. package/dist/mcp/tools.d.ts.map +1 -1
  131. package/dist/mcp/tools.js +707 -244
  132. package/dist/mcp/tools.js.map +1 -1
  133. package/dist/open-question.d.ts +7 -0
  134. package/dist/open-question.d.ts.map +1 -0
  135. package/dist/open-question.js +14 -0
  136. package/dist/open-question.js.map +1 -0
  137. package/dist/policy-metrics.d.ts +31 -21
  138. package/dist/policy-metrics.d.ts.map +1 -1
  139. package/dist/policy-metrics.js +117 -64
  140. package/dist/policy-metrics.js.map +1 -1
  141. package/dist/policy.d.ts +11 -39
  142. package/dist/policy.d.ts.map +1 -1
  143. package/dist/policy.js +12 -12
  144. package/dist/policy.js.map +1 -1
  145. package/dist/progress.d.ts +45 -0
  146. package/dist/progress.d.ts.map +1 -0
  147. package/dist/progress.js +136 -0
  148. package/dist/progress.js.map +1 -0
  149. package/dist/prompt-assembler.d.ts +20 -6
  150. package/dist/prompt-assembler.d.ts.map +1 -1
  151. package/dist/prompt-assembler.js +475 -479
  152. package/dist/prompt-assembler.js.map +1 -1
  153. package/dist/prompt-coverage.d.ts +0 -6
  154. package/dist/prompt-coverage.d.ts.map +1 -1
  155. package/dist/prompt-coverage.js +4 -8
  156. package/dist/prompt-coverage.js.map +1 -1
  157. package/dist/prompt-freshness.d.ts +6 -17
  158. package/dist/prompt-freshness.d.ts.map +1 -1
  159. package/dist/prompt-freshness.js +27 -22
  160. package/dist/prompt-freshness.js.map +1 -1
  161. package/dist/prompt-policy.d.ts +14 -7
  162. package/dist/prompt-policy.d.ts.map +1 -1
  163. package/dist/prompt-policy.js +21 -5
  164. package/dist/prompt-policy.js.map +1 -1
  165. package/dist/protocol.d.ts +843 -424
  166. package/dist/protocol.d.ts.map +1 -1
  167. package/dist/protocol.js +73 -102
  168. package/dist/protocol.js.map +1 -1
  169. package/dist/recovery-guidance.d.ts +13 -0
  170. package/dist/recovery-guidance.d.ts.map +1 -0
  171. package/dist/recovery-guidance.js +15 -0
  172. package/dist/recovery-guidance.js.map +1 -0
  173. package/dist/recovery.d.ts +68 -0
  174. package/dist/recovery.d.ts.map +1 -0
  175. package/dist/recovery.js +234 -0
  176. package/dist/recovery.js.map +1 -0
  177. package/dist/replay.d.ts +42 -8
  178. package/dist/replay.d.ts.map +1 -1
  179. package/dist/replay.js +311 -84
  180. package/dist/replay.js.map +1 -1
  181. package/dist/result-facts.d.ts +45 -0
  182. package/dist/result-facts.d.ts.map +1 -0
  183. package/dist/result-facts.js +86 -0
  184. package/dist/result-facts.js.map +1 -0
  185. package/dist/round-contract.d.ts +38 -8
  186. package/dist/round-contract.d.ts.map +1 -1
  187. package/dist/round-contract.js +71 -31
  188. package/dist/round-contract.js.map +1 -1
  189. package/dist/round-coordinator.d.ts +41 -89
  190. package/dist/round-coordinator.d.ts.map +1 -1
  191. package/dist/round-coordinator.js +250 -267
  192. package/dist/round-coordinator.js.map +1 -1
  193. package/dist/round-driver.d.ts +56 -16
  194. package/dist/round-driver.d.ts.map +1 -1
  195. package/dist/round-driver.js +225 -39
  196. package/dist/round-driver.js.map +1 -1
  197. package/dist/round-facts.d.ts +33 -52
  198. package/dist/round-facts.d.ts.map +1 -1
  199. package/dist/round-facts.js +51 -160
  200. package/dist/round-facts.js.map +1 -1
  201. package/dist/round-guidance.d.ts +56 -0
  202. package/dist/round-guidance.d.ts.map +1 -0
  203. package/dist/round-guidance.js +161 -0
  204. package/dist/round-guidance.js.map +1 -0
  205. package/dist/round-outcome.d.ts +21 -0
  206. package/dist/round-outcome.d.ts.map +1 -0
  207. package/dist/round-outcome.js +31 -0
  208. package/dist/round-outcome.js.map +1 -0
  209. package/dist/round-transaction.d.ts +168 -21
  210. package/dist/round-transaction.d.ts.map +1 -1
  211. package/dist/round-transaction.js +837 -38
  212. package/dist/round-transaction.js.map +1 -1
  213. package/dist/self-eval.d.ts +37 -61
  214. package/dist/self-eval.d.ts.map +1 -1
  215. package/dist/self-eval.js +413 -392
  216. package/dist/self-eval.js.map +1 -1
  217. package/dist/storage.d.ts +16 -0
  218. package/dist/storage.d.ts.map +1 -1
  219. package/dist/storage.js +54 -4
  220. package/dist/storage.js.map +1 -1
  221. package/dist/subgoal-state.d.ts +2 -3
  222. package/dist/subgoal-state.d.ts.map +1 -1
  223. package/dist/subgoal-state.js +2 -3
  224. package/dist/subgoal-state.js.map +1 -1
  225. package/dist/submission-guidance.d.ts +29 -21
  226. package/dist/submission-guidance.d.ts.map +1 -1
  227. package/dist/submission-guidance.js +308 -260
  228. package/dist/submission-guidance.js.map +1 -1
  229. package/dist/submission-receipt.d.ts +16 -8
  230. package/dist/submission-receipt.d.ts.map +1 -1
  231. package/dist/submission-receipt.js +145 -51
  232. package/dist/submission-receipt.js.map +1 -1
  233. package/dist/token-utils.d.ts +28 -7
  234. package/dist/token-utils.d.ts.map +1 -1
  235. package/dist/token-utils.js +27 -11
  236. package/dist/token-utils.js.map +1 -1
  237. package/dist/transaction-identity.d.ts +9 -0
  238. package/dist/transaction-identity.d.ts.map +1 -0
  239. package/dist/transaction-identity.js +69 -0
  240. package/dist/transaction-identity.js.map +1 -0
  241. package/dist/verification-actions.d.ts.map +1 -1
  242. package/dist/verification-actions.js +10 -11
  243. package/dist/verification-actions.js.map +1 -1
  244. package/dist/verification-gate.d.ts +23 -47
  245. package/dist/verification-gate.d.ts.map +1 -1
  246. package/dist/verification-gate.js +202 -423
  247. package/dist/verification-gate.js.map +1 -1
  248. package/dist/version.d.ts +1 -1
  249. package/dist/version.js +1 -1
  250. package/dist/working-memory.d.ts +187 -0
  251. package/dist/working-memory.d.ts.map +1 -0
  252. package/dist/working-memory.js +767 -0
  253. package/dist/working-memory.js.map +1 -0
  254. package/dist/workspace-checkpoint.d.ts +88 -0
  255. package/dist/workspace-checkpoint.d.ts.map +1 -0
  256. package/dist/workspace-checkpoint.js +326 -0
  257. package/dist/workspace-checkpoint.js.map +1 -0
  258. package/dist/workspace-restore.d.ts +68 -0
  259. package/dist/workspace-restore.d.ts.map +1 -0
  260. package/dist/workspace-restore.js +604 -0
  261. package/dist/workspace-restore.js.map +1 -0
  262. package/dist/workspace.d.ts +0 -9
  263. package/dist/workspace.d.ts.map +1 -1
  264. package/dist/workspace.js +0 -17
  265. package/dist/workspace.js.map +1 -1
  266. package/loop_policy.json +4 -18
  267. package/package.json +68 -68
  268. package/skills/forge/SKILL.md +212 -0
  269. package/skills/forge/references/evidence-and-contracts.md +204 -0
  270. package/skills/forge/references/recovery-and-views.md +188 -0
  271. package/dist/cognitive-facts.d.ts +0 -30
  272. package/dist/cognitive-facts.d.ts.map +0 -1
  273. package/dist/cognitive-facts.js +0 -113
  274. package/dist/cognitive-facts.js.map +0 -1
  275. package/dist/loop-extras-parser.d.ts +0 -81
  276. package/dist/loop-extras-parser.d.ts.map +0 -1
  277. package/dist/loop-extras-parser.js +0 -213
  278. package/dist/loop-extras-parser.js.map +0 -1
  279. package/dist/loop-projection.d.ts +0 -6
  280. package/dist/loop-projection.d.ts.map +0 -1
  281. package/dist/loop-projection.js +0 -28
  282. package/dist/loop-projection.js.map +0 -1
  283. package/skills/perception/SKILL.md +0 -389
package/dist/mcp/tools.js CHANGED
@@ -1,24 +1,76 @@
1
1
  /** LoopForge MCP — Tool definitions and handlers.
2
2
  *
3
- * 9 tools: start, next, status, stop, pause, resume, replay,
4
- * gate_check, gate_resolve.
5
- * status is the unified inspection tool (view=session|loop|all|audit).
3
+ * 10 tools: start, next, status, stop, pause, resume, replay,
4
+ * checkpoint, gate_check, gate_resolve.
5
+ * status is the unified inspection tool (view=session|loop|all|audit|explain).
6
6
  * Each handler receives SessionManager + parsed input, returns the output object.
7
7
  */
8
- import { StartCriteriaError } from "./round-lifecycle.js";
8
+ import { InvocationInputError, MANUAL_STOP_REASON, StartCriteriaError, } from "./round-lifecycle.js";
9
+ import { PROMPT_CONTEXT_REASONS } from "../protocol.js";
9
10
  import { ProjectionCoverageError } from "../prompt-coverage.js";
11
+ import { LegacyHistoryError, LoopExistsError, OperatorAcceptanceConflictError, StartFailureError, } from "../errors.js";
10
12
  import { validateCoreSelfEvaluation } from "../self-eval.js";
11
13
  import { isRecord } from "../token-utils.js";
12
14
  import { getPolicy, validateLoopId } from "../policy.js";
13
15
  import { deriveEvidenceCapability } from "../evidence-provider.js";
14
- import { fieldGuideDescription } from "../submission-guidance.js";
16
+ import { CORE_SELF_EVALUATION_FIELDS, fieldGuideDescription, } from "../submission-guidance.js";
17
+ import { LOST_HANDLE_ROUTING } from "../recovery-guidance.js";
18
+ import { CONTEXT_MODE_DESCRIPTION } from "../round-guidance.js";
19
+ import { SessionLeaseConflictError } from "../storage.js";
20
+ import { ReplayWindowError } from "../replay.js";
15
21
  // ═══════════════════════════════════════════════════════════════════════════
16
22
  // Tool schemas (MCP JSON Schema format)
17
23
  // ═══════════════════════════════════════════════════════════════════════════
24
+ /** v4.0.0: the ONE held-round anchor. The object is closed so a client cannot
25
+ * smuggle separate anchor fields alongside it. */
26
+ const SUBMISSION_HANDLE_SCHEMA = {
27
+ type: "object",
28
+ description: "The submissionHandle object returned with the held prompt. Copy it " +
29
+ "verbatim; never edit, reconstruct, or derive it. A stale handle never " +
30
+ "submits work. " + LOST_HANDLE_ROUTING,
31
+ properties: {
32
+ sessionId: {
33
+ type: "string",
34
+ description: "The owning session identity.",
35
+ },
36
+ roundId: {
37
+ type: "string",
38
+ description: "The stable logical round identity.",
39
+ },
40
+ transactionId: {
41
+ type: "string",
42
+ description: "The exact held transaction attempt.",
43
+ },
44
+ },
45
+ required: ["sessionId", "roundId", "transactionId"],
46
+ additionalProperties: false,
47
+ };
48
+ /** Output form: absent/null on terminal or non-held results, exact shape when
49
+ * present. */
50
+ const SUBMISSION_HANDLE_OUTPUT_SCHEMA = {
51
+ ...SUBMISSION_HANDLE_SCHEMA,
52
+ type: ["object", "null"],
53
+ };
54
+ /** The closed argument set for `loopforge_start`.
55
+ *
56
+ * The handler's unknown-argument guard reads this list; `schema-sync.test`
57
+ * asserts that the advertised tool schema exposes exactly the same keys. A
58
+ * field removed from the protocol (for example `domain` in 4.0.1) must not
59
+ * survive in only one of them: the schema says what clients may send, and
60
+ * the guard makes a stale caller fail loudly instead of silently dropping
61
+ * the field. */
62
+ export const START_INPUT_KEYS = [
63
+ "task",
64
+ "loopId",
65
+ "maxRounds",
66
+ "planSource",
67
+ "constraints",
68
+ "criteria",
69
+ ];
18
70
  const TOOL_BASE_SCHEMAS = [
19
71
  {
20
72
  name: "loopforge_start",
21
- description: "Start a new LoopForge loop session. Compiles the first-round prompt from the task description and returns it. Use this at the beginning of an autonomous multi-round coding loop.",
73
+ description: "Start a new LoopForge loop for a NEW objective. Compiles the first-round prompt from the task description and returns it. Use loopforge_status/loopforge_resume instead for an objective that already has a loopId.",
22
74
  inputSchema: {
23
75
  type: "object",
24
76
  properties: {
@@ -28,17 +80,16 @@ const TOOL_BASE_SCHEMAS = [
28
80
  },
29
81
  loopId: {
30
82
  type: "string",
31
- description: "Optional loop ID. Generated automatically if not provided.",
83
+ description: "Optional durable loop ID. Generated when omitted and returned " +
84
+ "with the first prompt. Keep it: loopforge_resume and every " +
85
+ "loopforge_status view use loopId after a restart, and a live " +
86
+ "session can re-anchor a lost handle with loopId.",
32
87
  },
33
88
  maxRounds: {
34
89
  type: "integer",
35
90
  minimum: 1,
36
91
  description: "Maximum rounds before auto-stop. Default: 200 from policy.",
37
92
  },
38
- domain: {
39
- type: "string",
40
- description: "Domain hint (e.g. 'solidity', 'react', 'rust') for state context.",
41
- },
42
93
  planSource: {
43
94
  type: "string",
44
95
  description: "Plan reference for constraint extraction (e.g. 'docs/plan.md').",
@@ -51,7 +102,9 @@ const TOOL_BASE_SCHEMAS = [
51
102
  criteria: {
52
103
  type: "array",
53
104
  items: { type: "string" },
54
- description: "Initial success criteria. Each becomes a stable cr-XXXXXXXX anchor " +
105
+ description: "Initial success criteria — the operator's acceptance surface. Phrase " +
106
+ "each as an observable outcome; implementation detail belongs in " +
107
+ "sub-goals or task text. Each becomes a stable cr-XXXXXXXX anchor " +
55
108
  "rendered in every prompt and usable by contract items. Omit to keep " +
56
109
  "the default criterion.",
57
110
  },
@@ -61,30 +114,58 @@ const TOOL_BASE_SCHEMAS = [
61
114
  },
62
115
  {
63
116
  name: "loopforge_next",
64
- description: "Submit the output from the current round and advance to the next. Returns the next-round prompt, or null with a stopReason when the loop ends. A structured evaluation is required; output is optional supporting context.",
117
+ description: "Submit one evaluation for the held attempt. Returns the next prompt (with its derived contextMode/contextReason and a strategy label), or null with stopReason/stopDetail when the loop ends. The submission may be accepted, rejected (same logical round, attempt+1, new handle), replayed, or answered with a rollback directive. A structured evaluation is required unless currentUserInstruction is the only argument: then the held prompt is recompiled for that instruction without submitting or advancing. If the held prompt is same_round_retry and the original invocation is gone, do NOT use next — call loopforge_resume(loopId, invocationLost: true) so LoopForge recompiles the same round/attempt as a cold-context prompt.",
65
118
  inputSchema: {
66
119
  type: "object",
67
120
  properties: {
68
- sessionId: {
121
+ submissionHandle: SUBMISSION_HANDLE_SCHEMA,
122
+ output: {
69
123
  type: "string",
70
- description: "Session ID returned by loopforge_start.",
124
+ description: "Optional. The agent's full output from executing the current round's prompt. The structured evaluation is the authoritative report.",
71
125
  },
72
- roundId: {
126
+ currentUserInstruction: {
73
127
  type: "string",
74
- description: "The roundId from the most recent loopforge_start / loopforge_next / loopforge_resume / loopforge_status response. Anchors this submission to the exact round it reports on: a submission whose roundId no longer matches the current round (the round already committed) is not processed — the current held prompt is returned instead, so lost responses can be recovered without skipping or double-committing a round.",
128
+ description: "v4.0.5: the host's current user instruction for this invocation, " +
129
+ "trimmed and non-empty. It is invocation input, not a task update: " +
130
+ "it never rewrites session.task, the Objective, Success Criteria, " +
131
+ "hard constraints or the Round Contract. With evaluation/recovery " +
132
+ "it is carried into the retry/redo/next prompt compiled after the " +
133
+ "submission; with only the handle it recompiles the held prompt " +
134
+ "without submitting or advancing the round.",
75
135
  },
76
- output: {
77
- type: "string",
78
- description: "Optional. The agent's full output from executing the current round's prompt. The structured evaluation is the authoritative report.",
136
+ recovery: {
137
+ type: "object",
138
+ description: "v3.9.7: Agent-initiated recovery. Abandon the current branch and " +
139
+ "restore a visible workspace checkpoint (the held before-image, a " +
140
+ "mid-round savepoint, or a committed round's before-image). " +
141
+ "Mutually exclusive with evaluation. LoopForge records the directive, " +
142
+ "executes the restore, verifies the full Git boundary, then issues " +
143
+ "the redo prompt. It never chooses a target or judges the reason.",
144
+ properties: {
145
+ target_checkpoint_id: {
146
+ type: "string",
147
+ pattern: "^ck-[a-f0-9]{24}$",
148
+ description: "Checkpoint id (ck-<24 hex>) from the held prompt, a loopforge_checkpoint result, or a committed round's before-image.",
149
+ },
150
+ reason: {
151
+ type: "string",
152
+ minLength: 1,
153
+ maxLength: 500,
154
+ description: "The Agent's reason for abandoning the branch. Recorded verbatim; never judged.",
155
+ },
156
+ },
157
+ required: ["target_checkpoint_id", "reason"],
158
+ additionalProperties: false,
79
159
  },
80
160
  evaluation: {
81
161
  type: "object",
82
- description: "Required structured self-evaluation for this round. Free-text or embedded evaluation blocks are not accepted.",
83
- required: ["success", "output_summary", "should_continue", "constraint_violations"],
162
+ description: "Structured self-evaluation for this round. Required when recovery is absent. Free-text or embedded evaluation blocks are not accepted.",
163
+ required: ["outcome", "output_summary", "should_continue", "constraint_violations"],
84
164
  properties: {
85
- success: {
86
- type: "boolean",
87
- description: fieldGuideDescription("success"),
165
+ outcome: {
166
+ type: "string",
167
+ enum: ["success", "partial", "failed", "blocked"],
168
+ description: fieldGuideDescription("outcome"),
88
169
  },
89
170
  output_summary: {
90
171
  type: "string",
@@ -104,10 +185,6 @@ const TOOL_BASE_SCHEMAS = [
104
185
  items: { type: "string" },
105
186
  description: fieldGuideDescription("discovered_constraints"),
106
187
  },
107
- objective_refinement: {
108
- type: "string",
109
- description: fieldGuideDescription("objective_refinement"),
110
- },
111
188
  emerged_subtasks: {
112
189
  type: "array",
113
190
  items: { type: "string" },
@@ -122,38 +199,21 @@ const TOOL_BASE_SCHEMAS = [
122
199
  items: { type: "string" },
123
200
  description: fieldGuideDescription("execution_report.files_changed"),
124
201
  },
125
- tests_reported: {
126
- type: "object",
127
- description: fieldGuideDescription("execution_report.tests_reported"),
128
- properties: {
129
- passed: { type: "integer", minimum: 0, description: "Number of passing tests." },
130
- failed: { type: "integer", minimum: 0, description: "Number of failing tests." },
131
- skipped: { type: "integer", minimum: 0, description: "Number of skipped tests." },
132
- },
133
- },
134
- criterion_claims: {
202
+ claims: {
135
203
  type: "array",
136
- description: fieldGuideDescription("execution_report.criterion_claims"),
204
+ maxItems: 100,
205
+ description: fieldGuideDescription("execution_report.claims"),
137
206
  items: {
138
207
  type: "object",
139
208
  properties: {
140
- criterion_id: { type: "string", description: "cr-XXXXXXXX criterion id (free text allowed)." },
209
+ id: {
210
+ type: "string",
211
+ description: "cr-XXXXXXXX objective criterion id or rci-XXXXXXXX " +
212
+ "id of an item of the ACTIVE contract.",
213
+ },
141
214
  outcome: { type: "string", enum: ["met", "remaining"] },
142
215
  },
143
- required: ["criterion_id", "outcome"],
144
- additionalProperties: false,
145
- },
146
- },
147
- contract_item_claims: {
148
- type: "array",
149
- description: fieldGuideDescription("execution_report.contract_item_claims"),
150
- items: {
151
- type: "object",
152
- properties: {
153
- item_id: { type: "string", description: "rci-XXXXXXXX id of an item of the ACTIVE contract." },
154
- outcome: { type: "string", enum: ["met", "remaining"], description: "Your claim for that item this round." },
155
- },
156
- required: ["item_id", "outcome"],
216
+ required: ["id", "outcome"],
157
217
  additionalProperties: false,
158
218
  },
159
219
  },
@@ -164,22 +224,26 @@ const TOOL_BASE_SCHEMAS = [
164
224
  items: { type: "string" },
165
225
  description: fieldGuideDescription("retracted_constraints"),
166
226
  },
167
- revised_success_criteria: {
168
- type: "array",
169
- description: fieldGuideDescription("revised_success_criteria"),
170
- items: {
171
- type: "object",
172
- properties: {
173
- old: { type: "string", description: "Original criterion." },
174
- new: { type: "string", description: "Revised criterion." },
175
- },
176
- },
177
- },
178
227
  wrong_assumptions: {
179
228
  type: "array",
180
229
  items: { type: "string" },
181
230
  description: fieldGuideDescription("wrong_assumptions"),
182
231
  },
232
+ open_questions: {
233
+ type: "array",
234
+ maxItems: 3,
235
+ items: { type: "string", maxLength: 300 },
236
+ description: fieldGuideDescription("open_questions"),
237
+ },
238
+ resolved_open_question_ids: {
239
+ type: "array",
240
+ maxItems: 20,
241
+ items: {
242
+ type: "string",
243
+ pattern: "^oq-[a-f0-9]{12}$",
244
+ },
245
+ description: fieldGuideDescription("resolved_open_question_ids"),
246
+ },
183
247
  worker_results: {
184
248
  type: "array",
185
249
  description: fieldGuideDescription("worker_results"),
@@ -187,28 +251,23 @@ const TOOL_BASE_SCHEMAS = [
187
251
  type: "object",
188
252
  properties: {
189
253
  agentId: { type: "string" },
190
- subAgentType: { type: "string" },
191
254
  subTask: { type: "string" },
192
255
  resultSummary: { type: "string" },
193
256
  outcome: {
194
257
  type: "string",
195
258
  enum: ["success", "partial", "failed"],
196
259
  },
197
- discoveredConstraints: {
198
- type: "array",
199
- items: { type: "string" },
200
- },
201
260
  },
202
261
  required: ["agentId", "subTask", "resultSummary", "outcome"],
203
262
  },
204
263
  },
205
- compression_checkpoint: {
264
+ subtask_boundary: {
206
265
  type: "boolean",
207
- description: fieldGuideDescription("compression_checkpoint"),
266
+ description: fieldGuideDescription("subtask_boundary"),
208
267
  },
209
- checkpoint_label: {
268
+ subtask_boundary_label: {
210
269
  type: "string",
211
- description: fieldGuideDescription("checkpoint_label"),
270
+ description: fieldGuideDescription("subtask_boundary_label"),
212
271
  },
213
272
  subgoal_updates: {
214
273
  type: "array",
@@ -233,22 +292,18 @@ const TOOL_BASE_SCHEMAS = [
233
292
  required: ["id", "status"],
234
293
  },
235
294
  },
236
- stop_reason: {
237
- type: "string",
238
- enum: ["gave_up", "blocked", "needs_human_input"],
239
- description: fieldGuideDescription("stop_reason"),
240
- },
241
295
  no_change_reason: {
242
296
  type: "string",
243
297
  description: fieldGuideDescription("no_change_reason"),
244
298
  },
245
- outcome: {
299
+ objective_refinement: {
246
300
  type: "string",
247
- enum: ["success", "partial", "failed", "blocked"],
248
- description: fieldGuideDescription("outcome"),
301
+ maxLength: 1000,
302
+ description: fieldGuideDescription("objective_refinement"),
249
303
  },
250
304
  blocker: {
251
305
  type: "string",
306
+ maxLength: 500,
252
307
  description: fieldGuideDescription("blocker"),
253
308
  },
254
309
  gate_ids: {
@@ -270,54 +325,53 @@ const TOOL_BASE_SCHEMAS = [
270
325
  },
271
326
  description: fieldGuideDescription("retroactiveClaims"),
272
327
  },
273
- prompt_requests: {
274
- type: "object",
275
- description: fieldGuideDescription("prompt_requests"),
276
- properties: {
277
- emphasize: {
278
- type: "array",
279
- items: { type: "string" },
280
- description: "Constraints, discoveries, or decisions that need emphasis in the next prompt. Each entry is matched against active state items by STABLE ID (c-/cr-/sg-XXXXXXXX) or by exact text after normalization — a paraphrase matches nothing and renders nothing. Matched items appear in a 'Critical Context' section. Max 5 entries. Pure reordering — no token overhead.",
281
- },
282
- confusion_points: {
283
- type: "array",
284
- items: { type: "string" },
285
- description: "Things you're confused about. Rendered at the TOP of the next prompt before the objective. Max 3 entries, each ≤ 200 chars recommended. Be specific — 'I don't understand why milestone 3 is marked complete when phase 2 appears unfinished.'",
286
- },
287
- },
288
- },
289
328
  round_contract: {
290
329
  type: "object",
291
330
  description: fieldGuideDescription("round_contract"),
292
331
  properties: {
293
332
  work_item: {
294
333
  type: "string",
295
- description: "Optional. Round focus, one line. Rendered as the active contract's Current Task first line. Max 200 chars.",
334
+ minLength: 1,
335
+ maxLength: 200,
336
+ description: "Required. The bounded deliverable this contract slice " +
337
+ "produces — an outcome, not an activity. Rendered as the " +
338
+ "Current Task heading. 1–200 chars.",
296
339
  },
297
340
  items: {
298
341
  type: "array",
299
- description: "Required, at least one (max 20). Each item is a verification unit bound to evidence commands. The runtime derives a stable rci-XXXXXXXX id per item and renders it in the prompt — cite that id in execution_report.contract_item_claims.",
342
+ minItems: 1,
343
+ maxItems: 20,
344
+ description: "Required, at least one (max 20). Each item is an " +
345
+ "acceptance condition for the deliverable, bound to its own " +
346
+ "evidence commands. The runtime derives a stable " +
347
+ "rci-XXXXXXXX id per item and renders it in the prompt — " +
348
+ "cite that id in execution_report.claims.",
300
349
  items: {
301
350
  type: "object",
302
351
  properties: {
303
352
  description: {
304
353
  type: "string",
305
- description: "What must be true for this item. Max 200 chars.",
354
+ minLength: 1,
355
+ maxLength: 200,
356
+ description: "What must be true for this acceptance condition. " +
357
+ "1–200 chars.",
306
358
  },
307
359
  criterion_refs: {
308
360
  type: "array",
361
+ maxItems: 20,
309
362
  items: { type: "string" },
310
363
  description: "Optional. Real cr-XXXXXXXX ids of ACTIVE criteria rendered in this prompt. Free text and unknown ids are rejected. Max 20.",
311
364
  },
312
- subgoal_refs: {
313
- type: "array",
314
- items: { type: "string" },
315
- description: "Optional. sg-XXXXXXXX ids of ACTIVE sub-goals displayed in this prompt. A sub-goal created by this same submission's emerged_subtasks becomes referenceable next round. Max 20 ids. A machine-verified item produces the derived verified-subgoal fact for exactly the ids it names here.",
316
- },
317
365
  verify_with: {
318
366
  type: "array",
367
+ minItems: 1,
368
+ maxItems: 20,
319
369
  items: { type: "string" },
320
- description: "Required, at least one. Names of configured, enabled, after-capable evidence.commands (loop_policy.json) that verify this item. Unknown or disabled names are rejected.",
370
+ description: "Required, at least one. Names of configured, " +
371
+ "enabled, after-capable evidence.commands " +
372
+ "(loop_policy.json) that check this acceptance " +
373
+ "condition. Unknown or disabled names are rejected. A " +
374
+ "command may be shared by multiple conditions.",
321
375
  },
322
376
  },
323
377
  required: ["description", "verify_with"],
@@ -326,31 +380,87 @@ const TOOL_BASE_SCHEMAS = [
326
380
  },
327
381
  scope: {
328
382
  type: "array",
383
+ maxItems: 50,
329
384
  items: { type: "string" },
330
- description: "Files/directories this round may touch (workspace-relative; './' and trailing slashes accepted; must stay inside the workspace). Out-of-scope git changes trigger round_scope_drift, which is a machine fact and is not waivable by explanation. Max 50 items.",
385
+ description: "Optional, report-only. The files/directories you intend " +
386
+ "this slice to touch. Omission and [] both mean no declared " +
387
+ "intent. It is NOT enforced and is not part of the rc- id: " +
388
+ "out-of-scope changes are reported as an info fact and " +
389
+ "never reject the round. Max 50 items.",
390
+ },
391
+ supersedes: {
392
+ type: "object",
393
+ description: "Optional redirect modifier. Replaces the named ACTIVE " +
394
+ "contract with this proposal's slice; its acceptance items " +
395
+ "must differ. The old contract closes as superseded. Not " +
396
+ "allowed with should_continue=false or outcome=blocked.",
397
+ properties: {
398
+ contract_id: {
399
+ type: "string",
400
+ pattern: "^rc-[a-f0-9]{8}$",
401
+ description: "The rc-XXXXXXXX id of the ACTIVE contract rendered in " +
402
+ "this prompt. A stale/other id is rejected.",
403
+ },
404
+ reason: {
405
+ type: "string",
406
+ maxLength: 500,
407
+ description: "Optional Agent report, recorded verbatim for audit only.",
408
+ },
409
+ },
410
+ required: ["contract_id"],
411
+ additionalProperties: false,
331
412
  },
332
413
  },
333
- required: ["items", "scope"],
414
+ required: ["work_item", "items"],
415
+ additionalProperties: false,
334
416
  },
335
417
  },
336
418
  },
337
419
  },
338
- required: ["sessionId", "roundId", "evaluation"],
420
+ required: ["submissionHandle"],
421
+ },
422
+ },
423
+ {
424
+ name: "loopforge_checkpoint",
425
+ description: "v3.9.7: Create an explicit mid-round workspace checkpoint (savepoint). " +
426
+ "The call itself is the Agent's decision that this state is worth " +
427
+ "restoring to. It creates no round, no commit and no progress; the " +
428
+ "checkpoint becomes a visible recovery target only through the held " +
429
+ "transaction's ref list. Requires the same submissionHandle as " +
430
+ "loopforge_next; the handle is unchanged after a successful checkpoint.",
431
+ inputSchema: {
432
+ type: "object",
433
+ properties: {
434
+ submissionHandle: SUBMISSION_HANDLE_SCHEMA,
435
+ },
436
+ required: ["submissionHandle"],
437
+ additionalProperties: false,
339
438
  },
340
439
  },
341
440
  {
342
441
  name: "loopforge_status",
343
- description: "Inspect loop state. view=session (default): session status, round identity, success trajectory and typed projection. view=loop: loop health (goal alignment, constraints, drift). view=all: summary of every persisted loop. view=audit: read-only end-of-loop verification audit (claims, gates, verdict, sequence integrity).",
442
+ description: "Read-only inspection. view=session (default): live session status, " +
443
+ "round identity, the current held submissionHandle when one exists, " +
444
+ "success trajectory, active contract, current guidance and the latest " +
445
+ "committed receipt. It never returns the held prompt; a paused or " +
446
+ "stalled session must resume before submitting. view=loop: committed " +
447
+ "round counts, unverified-item rounds and policy metrics. view=all: " +
448
+ "summary of every persisted loop. view=audit: end-of-loop verification " +
449
+ "audit. view=explain: per-round \"why\" view over committed facts.",
344
450
  inputSchema: {
345
451
  type: "object",
346
452
  properties: {
347
453
  sessionId: {
348
454
  type: "string",
349
- description: "Session ID returned by loopforge_start (views session/audit).",
455
+ description: "Live session ID returned by loopforge_start or loopforge_resume. " +
456
+ "Resolvable only in the current process; after a restart use loopId. " +
457
+ "view=session returns the current held submissionHandle.",
350
458
  },
351
459
  loopId: {
352
460
  type: "string",
353
- description: "Loop ID (views loop/audit).",
461
+ description: "Durable loop ID. Required for views loop/audit/explain; also " +
462
+ "accepted for view=session, which resolves it to the live session " +
463
+ "and returns its current held submissionHandle.",
354
464
  },
355
465
  view: {
356
466
  type: "string",
@@ -367,7 +477,8 @@ const TOOL_BASE_SCHEMAS = [
367
477
  },
368
478
  {
369
479
  name: "loopforge_stop",
370
- description: "Manually stop a loop session and return its final trajectory.",
480
+ description: "Manually stop a loop session and return its final trajectory. The " +
481
+ "returned `success` is tool-call success, not a task-success verdict.",
371
482
  inputSchema: {
372
483
  type: "object",
373
484
  properties: {
@@ -381,7 +492,7 @@ const TOOL_BASE_SCHEMAS = [
381
492
  },
382
493
  {
383
494
  name: "loopforge_pause",
384
- description: "Pause a running loop session. The loop suspends at the next round boundary and its state is persisted to vault. Paused loops can be resumed with loopforge_resume. Use this to interrupt a long-running loop without losing progress.",
495
+ description: "Pause a running loop session: it immediately stops accepting submissions (an in-flight advance may still finish) and its state is persisted to vault. Paused loops can be resumed with loopforge_resume. Use this to interrupt a long-running loop without losing progress.",
385
496
  inputSchema: {
386
497
  type: "object",
387
498
  properties: {
@@ -395,10 +506,12 @@ const TOOL_BASE_SCHEMAS = [
395
506
  },
396
507
  {
397
508
  name: "loopforge_replay",
398
- description: "Replay a completed or running loop as an auditable round timeline. " +
399
- "Pass sessionId for a live session, or loopId to replay straight from " +
400
- "the vault (works after a process restart, when no session is in " +
401
- "memory) — the timeline is derived from committed round documents.",
509
+ description: "Replay committed rounds as an auditable timeline. Pass sessionId for a " +
510
+ "live session, or loopId to replay straight from the vault (works after " +
511
+ "a process restart). Returns the last 10 committed rounds by default; " +
512
+ "pass start/end for an explicit window (at most 50 rounds). Each round " +
513
+ "carries its context boundary, committed report, assumptions, open " +
514
+ "questions, blocker and contract redirect. Read-only projection.",
402
515
  inputSchema: {
403
516
  type: "object",
404
517
  properties: {
@@ -411,6 +524,16 @@ const TOOL_BASE_SCHEMAS = [
411
524
  description: "Loop ID to replay from the vault — works without a " +
412
525
  "live session, e.g. after a restart.",
413
526
  },
527
+ start: {
528
+ type: "integer",
529
+ minimum: 1,
530
+ description: "First round of the explicit window (inclusive).",
531
+ },
532
+ end: {
533
+ type: "integer",
534
+ minimum: 1,
535
+ description: "Last round of the explicit window (inclusive).",
536
+ },
414
537
  },
415
538
  // v3.3.1: either identifier resolves the timeline; sessionId wins when
416
539
  // both are given (it also disambiguates re-used loopIds).
@@ -419,7 +542,20 @@ const TOOL_BASE_SCHEMAS = [
419
542
  },
420
543
  {
421
544
  name: "loopforge_resume",
422
- description: "Resume a loop from vault state. Works for both: (a) running sessions that were interrupted by a process restart, and (b) paused sessions (via loopforge_pause). Returns the compiled prompt for the next round, or null with a stopReason if the loop is already complete.",
545
+ description: "Resume a loop from vault state. Requires the durable loopId returned by " +
546
+ "loopforge_start (not sessionId). Use this when the held prompt was lost, " +
547
+ "no live session exists, or the session is paused/stalled; if only the " +
548
+ "submissionHandle was lost while the prompt is still in context, prefer " +
549
+ "loopforge_status. Returns the held prompt and its current " +
550
+ "submissionHandle, or a newly compiled prompt when none is held. A " +
551
+ "terminal loop returns no prompt, the committed stopReason when one was " +
552
+ "committed, and/or sessionStatus (paused/stopped/stalled); a stopped " +
553
+ "loop without a committed stop round reports sessionStatus only. " +
554
+ "v4.0.5: pass invocationLost=true when the original in-context retry " +
555
+ "invocation no longer exists; LoopForge recompiles the SAME logical " +
556
+ "round and attempt as a cold-context invocation_recovery prompt. " +
557
+ "currentUserInstruction carries the host's current instruction into " +
558
+ "that recompile or redelivery.",
423
559
  inputSchema: {
424
560
  type: "object",
425
561
  properties: {
@@ -427,6 +563,24 @@ const TOOL_BASE_SCHEMAS = [
427
563
  type: "string",
428
564
  description: "Loop ID to resume. Must have a saved session_state entry from a previous start/run.",
429
565
  },
566
+ invocationLost: {
567
+ type: "boolean",
568
+ description: "v4.0.5: set true when the invocation that received the held " +
569
+ "same_round_retry prompt is gone. A held retry is then recompiled " +
570
+ "as fresh_invocation / invocation_recovery for the SAME round and " +
571
+ "attempt (L2, with Machine Evidence, Working Memory and rejection " +
572
+ "lessons). On an already clean-context prompt this is an " +
573
+ "idempotent no-op.",
574
+ },
575
+ currentUserInstruction: {
576
+ type: "string",
577
+ description: "v4.0.5: the host's current user instruction, trimmed and " +
578
+ "non-empty. It is invocation input only; it never rewrites " +
579
+ "session.task, the Objective, criteria, constraints or the Round " +
580
+ "Contract. An instruction identical (trimmed, case-sensitive) to " +
581
+ "the held prompt's is idempotent; a different one recompiles the " +
582
+ "same round/attempt with a new handle.",
583
+ },
430
584
  },
431
585
  required: ["loopId"],
432
586
  },
@@ -442,8 +596,7 @@ const TOOL_BASE_SCHEMAS = [
442
596
  inputSchema: {
443
597
  type: "object",
444
598
  properties: {
445
- sessionId: { type: "string", description: "Session ID." },
446
- roundId: { type: "string", description: "RoundId from the latest response." },
599
+ submissionHandle: SUBMISSION_HANDLE_SCHEMA,
447
600
  action: {
448
601
  type: "object",
449
602
  description: "The action the agent plans to perform.",
@@ -480,7 +633,7 @@ const TOOL_BASE_SCHEMAS = [
480
633
  required: ["description", "scope", "effects", "reversibility", "authorization"],
481
634
  },
482
635
  },
483
- required: ["sessionId", "roundId", "action"],
636
+ required: ["submissionHandle", "action"],
484
637
  },
485
638
  },
486
639
  {
@@ -519,19 +672,79 @@ const ADVANCE_OUTPUT_SCHEMA = {
519
672
  type: "object",
520
673
  properties: {
521
674
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
522
- error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
675
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, submissionHandle?, details? }" },
523
676
  details: { type: "object", additionalProperties: true },
524
677
  sessionId: { type: "string" },
678
+ loopId: {
679
+ type: "string",
680
+ description: "Durable loop identity. Returned with every held prompt so a restarted " +
681
+ "agent can call loopforge_resume without reconstructing IDs.",
682
+ },
525
683
  round: { type: "number" },
526
- roundId: { type: ["string", "null"] },
684
+ submissionHandle: SUBMISSION_HANDLE_OUTPUT_SCHEMA,
527
685
  prompt: { type: ["string", "null"] },
528
- stopReason: { type: ["string", "null"] },
529
- stopDetail: { type: ["string", "null"], description: "Human-readable context for why the loop stopped. Provides facts without prescribing a specific agent action." },
686
+ stopReason: {
687
+ type: ["string", "null"],
688
+ enum: [
689
+ "completed", "failed", "blocked", "cancelled", "max_rounds",
690
+ "stalled", "enforcement_terminated", "paused", "incomplete", null,
691
+ ],
692
+ description: "v4.0.4: LoopForge's terminal reason. Never a session status, a tool " +
693
+ "error or the Agent's round outcome.",
694
+ },
695
+ sessionStatus: {
696
+ enum: ["running", "stopped", "paused", "stalled"],
697
+ description: "v4.0.4: the live session status when the result is not a committed " +
698
+ "loop terminal reason.",
699
+ },
700
+ stopDetail: { type: ["string", "null"], description: "Human-readable context for why the loop stopped. Three fact blocks: Agent report, LoopForge decision, Machine evidence. Provides facts without prescribing a specific agent action." },
530
701
  level: { type: ["string", "null"] },
531
- roundSuccess: { type: ["boolean", "null"] },
532
- enforcementAction: { enum: ["accept", "reject", "terminate", "backtrack"] },
702
+ contextMode: {
703
+ enum: ["fresh_invocation", "same_round_retry"],
704
+ description: CONTEXT_MODE_DESCRIPTION,
705
+ },
706
+ contextReason: {
707
+ // v4.0.5: the ONE runtime whitelist; a new reason cannot drift between
708
+ // the protocol, the decoder and this output schema.
709
+ enum: [...PROMPT_CONTEXT_REASONS],
710
+ description: "v4.0.2: why the context boundary was selected.",
711
+ },
712
+ agentSuccessClaim: { type: ["boolean", "null"] },
713
+ agentOutcome: {
714
+ type: ["string", "null"],
715
+ enum: ["success", "partial", "failed", "blocked", null],
716
+ description: "v4.0.6: the Agent-declared round outcome.",
717
+ },
718
+ agentShouldContinue: {
719
+ type: ["boolean", "null"],
720
+ description: "v4.0.4: the Agent's declared should_continue for that same round.",
721
+ },
722
+ checkpoints: {
723
+ type: ["array", "null"],
724
+ items: { type: "object", additionalProperties: true },
725
+ description: "v3.9.7: the held transaction's checkpoint refs (before-image + explicit mid savepoints).",
726
+ },
727
+ beforeCheckpointError: {
728
+ type: ["object", "null"],
729
+ additionalProperties: true,
730
+ description: "v3.9.7: explicit machine fact when the automatic before-image could not be captured.",
731
+ },
732
+ enforcementAction: { enum: ["accept", "reject", "terminate", "backtrack", "recovery"] },
533
733
  enforcementReason: { type: ["string", "null"] },
534
- verificationStatus: { enum: ["trusted", "insufficient", "contradicted"] },
734
+ recovery: {
735
+ type: ["object", "null"],
736
+ additionalProperties: true,
737
+ description: "v3.9.7: committed rollback directive facts and the verified restore that produced the redo prompt.",
738
+ },
739
+ evidencePosture: {
740
+ type: ["object", "null"],
741
+ additionalProperties: true,
742
+ description: "v4.0.3: the ONE evidence posture of the round this result is about " +
743
+ "(status, machineBackedSuccess, verificationConfigured, workspace, " +
744
+ "files reconciliation, providers, commands, contract snapshot, " +
745
+ "findings and omitted counts). Accepted rounds carry the persisted " +
746
+ "posture; a rejection carries its transient copy.",
747
+ },
535
748
  submission_guidance: {
536
749
  type: ["object", "null"],
537
750
  additionalProperties: true,
@@ -547,11 +760,6 @@ const ADVANCE_OUTPUT_SCHEMA = {
547
760
  "Returned by loopforge_next only.",
548
761
  },
549
762
  warnings: { type: "array", items: { type: "string" } },
550
- projection: {
551
- type: "object",
552
- additionalProperties: true,
553
- description: "v2.12: Typed cognitive state projection (focus/todo/phase/delegation/handoff).",
554
- },
555
763
  },
556
764
  additionalProperties: true,
557
765
  };
@@ -559,22 +767,59 @@ const ADVANCE_OUTPUT_SCHEMA = {
559
767
  * validates each handler's output against these before returning it —
560
768
  * a mismatch is a contract bug, surfaced as a JSON-RPC error instead of
561
769
  * silently shipping an output that violates the declared schema. */
770
+ const CHECKPOINT_OUTPUT_SCHEMA = {
771
+ type: "object",
772
+ properties: {
773
+ ok: { type: "boolean" },
774
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, submissionHandle?, details? }" },
775
+ sessionId: { type: "string" },
776
+ submissionHandle: SUBMISSION_HANDLE_SCHEMA,
777
+ checkpointId: { type: "string" },
778
+ round: { type: "number" },
779
+ role: { const: "mid" },
780
+ redoRound: { type: "number" },
781
+ createdAt: { type: "number" },
782
+ head: { type: ["string", "null"] },
783
+ fileCount: { type: "number" },
784
+ contentCount: { type: "number" },
785
+ contentHash: { type: "string" },
786
+ },
787
+ additionalProperties: true,
788
+ };
562
789
  export const TOOL_OUTPUT_SCHEMAS = {
563
790
  loopforge_start: ADVANCE_OUTPUT_SCHEMA,
564
791
  loopforge_next: ADVANCE_OUTPUT_SCHEMA,
565
792
  loopforge_resume: ADVANCE_OUTPUT_SCHEMA,
793
+ loopforge_checkpoint: CHECKPOINT_OUTPUT_SCHEMA,
566
794
  loopforge_status: {
567
795
  type: "object",
568
796
  properties: {
569
797
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
570
- error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
798
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, submissionHandle?, details? }" },
571
799
  sessionId: { type: "string" },
572
800
  loopId: { type: "string" },
573
801
  round: { type: "number" },
574
- roundId: { type: ["string", "null"] },
802
+ submissionHandle: SUBMISSION_HANDLE_OUTPUT_SCHEMA,
803
+ checkpoints: { type: ["array", "null"], items: { type: "object", additionalProperties: true } },
804
+ beforeCheckpointError: { type: ["object", "null"], additionalProperties: true },
575
805
  maxRounds: { type: "number" },
576
806
  status: { enum: ["running", "stopped", "stalled", "paused"] },
577
- successTrajectory: { type: "array", items: { type: "boolean" } },
807
+ // v4.0.4: a stopped loop's committed terminal facts are readable from
808
+ // view=session without calling resume.
809
+ loopStopReason: {
810
+ type: ["string", "null"],
811
+ enum: [
812
+ "completed", "failed", "blocked", "cancelled", "max_rounds",
813
+ "stalled", "enforcement_terminated", "paused", "incomplete", null,
814
+ ],
815
+ },
816
+ agentShouldContinue: { type: ["boolean", "null"] },
817
+ stopDetail: { type: ["string", "null"] },
818
+ machineBackedSuccessTrajectory: { type: "array", items: { type: "boolean" } },
819
+ evidencePosture: { type: ["object", "null"], additionalProperties: true },
820
+ machineBackedSuccess: { type: ["boolean", "null"] },
821
+ agentSuccessClaim: { type: ["boolean", "null"] },
822
+ agentOutcome: { type: ["string", "null"] },
578
823
  lease: { type: ["object", "null"], additionalProperties: true },
579
824
  metrics: { type: "object", additionalProperties: true },
580
825
  // v3.5: the ACTIVE Round Contract governing the next round (derived
@@ -592,10 +837,16 @@ export const TOOL_OUTPUT_SCHEMAS = {
592
837
  type: "object",
593
838
  properties: {
594
839
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
595
- error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
596
- success: { type: "boolean" },
597
- roundsCompleted: { type: "number" },
598
- successTrajectory: { type: "array", items: { type: "boolean" } },
840
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, submissionHandle?, details? }" },
841
+ success: { type: "boolean", description: "Tool-call success, not a task-success verdict." },
842
+ sessionId: { type: "string" },
843
+ loopId: { type: "string" },
844
+ roundsCompleted: { type: "number", description: "Deprecated alias of roundCursor (the session round cursor, not a committed count)." },
845
+ roundCursor: { type: "number", description: "The session's current round cursor." },
846
+ sessionStatus: { const: "stopped" },
847
+ loopStopReason: { const: MANUAL_STOP_REASON, description: "The manual-cancel terminal reason sent to the terminal sink; not a committed round fact." },
848
+ machineBackedSuccessRounds: { type: "number", description: "Machine-backed successful rounds (same committed-view source as the trajectory)." },
849
+ machineBackedSuccessTrajectory: { type: "array", items: { type: "boolean" } },
599
850
  },
600
851
  additionalProperties: true,
601
852
  },
@@ -603,7 +854,7 @@ export const TOOL_OUTPUT_SCHEMAS = {
603
854
  type: "object",
604
855
  properties: {
605
856
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
606
- error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
857
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, submissionHandle?, details? }" },
607
858
  sessionId: { type: "string" },
608
859
  round: { type: "number" },
609
860
  status: { type: "string" },
@@ -614,10 +865,16 @@ export const TOOL_OUTPUT_SCHEMAS = {
614
865
  type: "object",
615
866
  properties: {
616
867
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
617
- error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
868
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, submissionHandle?, details? }" },
618
869
  sessionId: { type: "string" },
619
870
  loopId: { type: "string" },
620
871
  timeline: { type: "array", items: { type: "object", additionalProperties: true } },
872
+ window: {
873
+ type: "object",
874
+ additionalProperties: true,
875
+ description: "v4.0.2: the returned round window: { start, end, totalRounds, " +
876
+ "hasBefore, hasAfter }.",
877
+ },
621
878
  legacyRounds: {
622
879
  type: "array",
623
880
  items: { type: "object", additionalProperties: true },
@@ -631,8 +888,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
631
888
  type: "object",
632
889
  properties: {
633
890
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
634
- error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
635
- sessionId: { type: "string" },
891
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, submissionHandle?, details? }" },
892
+ submissionHandle: SUBMISSION_HANDLE_SCHEMA,
636
893
  gateId: { type: "string" },
637
894
  risk: { type: "string", enum: ["low", "high", "unknown"] },
638
895
  decision: { type: "string", enum: ["agent_allowed", "user_required"] },
@@ -648,7 +905,7 @@ export const TOOL_OUTPUT_SCHEMAS = {
648
905
  type: "object",
649
906
  properties: {
650
907
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
651
- error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
908
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, submissionHandle?, details? }" },
652
909
  sessionId: { type: "string" },
653
910
  loopId: { type: "string" },
654
911
  gateId: { type: "string" },
@@ -673,12 +930,6 @@ function closeObjectSchemas(schema) {
673
930
  }
674
931
  return result;
675
932
  }
676
- const CORE_EVALUATION_FIELDS = new Set([
677
- "success",
678
- "output_summary",
679
- "constraint_violations",
680
- "should_continue",
681
- ]);
682
933
  /** Keep the four authoritative fields schema-strict while leaving optional
683
934
  * reporting fields available for buildSelfEvaluation's lossy normalization.
684
935
  * Their descriptions remain in tools/list, but malformed optional values do
@@ -691,7 +942,7 @@ function prepareInputSchema(name, schema) {
691
942
  if (!isRecord(evaluation) || !isRecord(evaluation.properties))
692
943
  return result;
693
944
  evaluation.properties = Object.fromEntries(Object.entries(evaluation.properties).map(([field, value]) => {
694
- if (CORE_EVALUATION_FIELDS.has(field) || !isRecord(value))
945
+ if (CORE_SELF_EVALUATION_FIELDS.has(field) || !isRecord(value))
695
946
  return [field, value];
696
947
  return [field, typeof value.description === "string"
697
948
  ? { description: value.description }
@@ -814,7 +1065,7 @@ export function validateToolDispatchInput(name, input) {
814
1065
  : {};
815
1066
  validateSchema(input, {
816
1067
  ...contract.inputSchema,
817
- required: ["sessionId", "roundId"],
1068
+ required: ["submissionHandle"],
818
1069
  properties: { ...properties, evaluation: {} },
819
1070
  }, "arguments");
820
1071
  if (!isRecord(input.evaluation))
@@ -855,13 +1106,51 @@ function projectionErrorResult(error) {
855
1106
  }
856
1107
  return null;
857
1108
  }
1109
+ /** Stable state-integrity errors. They are never retryable with the same
1110
+ * payload: the loop data or the operator surface must be repaired first. */
1111
+ function stateErrorResult(error) {
1112
+ if (error instanceof LegacyHistoryError) {
1113
+ return {
1114
+ error: error.code,
1115
+ errorMessage: error.message,
1116
+ details: { loopId: error.loopId, rounds: error.rounds },
1117
+ };
1118
+ }
1119
+ if (error instanceof OperatorAcceptanceConflictError) {
1120
+ return {
1121
+ error: error.code,
1122
+ errorMessage: error.message,
1123
+ details: { loopId: error.loopId },
1124
+ };
1125
+ }
1126
+ return null;
1127
+ }
858
1128
  export const TOOL_HANDLERS = {
859
1129
  async loopforge_start(mgr, input) {
1130
+ // A removed or misspelled start argument must not disappear silently: the
1131
+ // schema is permissive about extra top-level keys, and the handler used to
1132
+ // rebuild StartInput from a whitelist. Without this guard a stale manual
1133
+ // (for example one still naming `domain`) produces a successful start with
1134
+ // part of the caller's intent ignored.
1135
+ const unknownStartArgs = Object.keys(input).filter((key) => !START_INPUT_KEYS.includes(key));
1136
+ if (unknownStartArgs.length > 0) {
1137
+ const retired = unknownStartArgs.includes("domain")
1138
+ ? " `domain` was removed in 4.0.1."
1139
+ : "";
1140
+ return {
1141
+ error: "invalid_argument",
1142
+ errorMessage: `unknown loopforge_start argument(s): ${unknownStartArgs.join(", ")}.` +
1143
+ `${retired} Accepted: ${START_INPUT_KEYS.join(", ")}.`,
1144
+ details: {
1145
+ unknown: unknownStartArgs,
1146
+ accepted: [...START_INPUT_KEYS],
1147
+ },
1148
+ };
1149
+ }
860
1150
  const startInput = {
861
1151
  task: String(input.task ?? ""),
862
1152
  loopId: input.loopId,
863
1153
  maxRounds: typeof input.maxRounds === "number" ? input.maxRounds : undefined,
864
- domain: input.domain,
865
1154
  planSource: input.planSource,
866
1155
  constraints: Array.isArray(input.constraints)
867
1156
  ? input.constraints
@@ -920,20 +1209,34 @@ export const TOOL_HANDLERS = {
920
1209
  const projectionFailure = projectionErrorResult(error);
921
1210
  if (projectionFailure)
922
1211
  return projectionFailure;
1212
+ const stateFailure = stateErrorResult(error);
1213
+ if (stateFailure)
1214
+ return stateFailure;
1215
+ if (error instanceof LoopExistsError) {
1216
+ return {
1217
+ error: error.code,
1218
+ errorMessage: error.message,
1219
+ details: { loopId: error.loopId, reason: error.reason },
1220
+ };
1221
+ }
1222
+ if (error instanceof StartFailureError) {
1223
+ return {
1224
+ error: error.code,
1225
+ errorMessage: error.message,
1226
+ details: { retry: "call loopforge_start again" },
1227
+ };
1228
+ }
1229
+ if (error instanceof SessionLeaseConflictError) {
1230
+ return {
1231
+ error: "lease_conflict",
1232
+ errorMessage: `loop "${error.loopId}" is owned by another running LoopForge process`,
1233
+ };
1234
+ }
923
1235
  return {
924
1236
  error: "policy_invalid",
925
1237
  errorMessage: `the runtime policy could not be loaded: ${error.message}`,
926
1238
  };
927
1239
  }
928
- // v2.14: a same-process duplicate loopId surfaces as a clean argument
929
- // error instead of a prompt-less "success" the client cannot act on.
930
- if (typeof result.stopReason === "string" &&
931
- result.stopReason.startsWith("loop_already_running:")) {
932
- return {
933
- error: "loop_already_running",
934
- errorMessage: result.stopDetail ?? "loop already running",
935
- };
936
- }
937
1240
  // v3.8: the static capability warning — a loop with no provider or no
938
1241
  // enabled command can still run; its success claims are recorded as
939
1242
  // insufficient instead of verified, and the agent is told so up front.
@@ -943,57 +1246,86 @@ export const TOOL_HANDLERS = {
943
1246
  : { ...result };
944
1247
  },
945
1248
  async loopforge_next(mgr, input) {
946
- const sessionId = String(input.sessionId ?? "");
947
1249
  const output = String(input.output ?? "");
948
1250
  const rawEval = input.evaluation;
949
- const roundId = input.roundId === undefined ? undefined : String(input.roundId);
950
- if (!sessionId) {
951
- return { error: "invalid_argument", errorMessage: "sessionId is required" };
952
- }
953
- // v3.0.1: every submission is anchored to the round it reports on. A
954
- // missing roundId would silently re-process a stale submission against a
955
- // later round — reject it here so the contract is enforced even when the
956
- // handler is invoked directly (bypassing schema validation).
957
- if (!roundId || !roundId.trim()) {
1251
+ const rawRecovery = input.recovery;
1252
+ const handle = input.submissionHandle;
1253
+ const rawInstruction = input.currentUserInstruction;
1254
+ if (rawRecovery !== undefined && rawEval !== undefined) {
958
1255
  return {
959
- error: "round_id_required",
960
- errorMessage: "roundId is required — pass the roundId from the most recent " +
961
- "loopforge_start / loopforge_next / loopforge_resume / loopforge_status response",
1256
+ error: "recovery_invalid",
1257
+ errorMessage: "evaluation and recovery are mutually exclusive",
962
1258
  };
963
1259
  }
964
- // v3.8.1: the submission boundary belongs to the RUNTIME, not to this
965
- // transport. `mgr.advance` applies all of it — object shape, core fields,
966
- // subgoal_updates structure and references, and the strict Round Contract
967
- // declaration/claims boundary — before touching any state, and reports a
968
- // refusal through `submissionError`. This handler only maps that onto the
969
- // tool envelope, so a non-MCP caller gets exactly the same strictness.
1260
+ // v4.0.0: the handle is the only identity. The runtime validates its
1261
+ // shape, resolves the session, checks the exact held attempt, then runs
1262
+ // the payload boundary — all before its queue/lease heartbeat. This
1263
+ // handler only maps the refusal onto the tool envelope.
1264
+ // v4.0.5: handle + instruction alone is the instruction-recompile mode:
1265
+ // no submission and no round/attempt advance.
970
1266
  let result;
971
1267
  try {
972
- result = await mgr.advance(sessionId, output, rawEval, roundId);
1268
+ if (rawRecovery !== undefined) {
1269
+ result = await mgr.recover(handle, rawRecovery, rawInstruction !== undefined
1270
+ ? { currentUserInstruction: String(rawInstruction) }
1271
+ : undefined);
1272
+ }
1273
+ else if (rawEval !== undefined) {
1274
+ result = await mgr.advance(handle, output, rawEval, rawInstruction !== undefined
1275
+ ? { currentUserInstruction: String(rawInstruction) }
1276
+ : undefined);
1277
+ }
1278
+ else if (rawInstruction !== undefined) {
1279
+ result = await mgr.recompileHeld(handle, String(rawInstruction));
1280
+ }
1281
+ else {
1282
+ result = await mgr.advance(handle, output, rawEval);
1283
+ }
973
1284
  }
974
1285
  catch (error) {
1286
+ if (error instanceof InvocationInputError) {
1287
+ return { error: "invalid_argument", errorMessage: error.message };
1288
+ }
975
1289
  const projectionFailure = projectionErrorResult(error);
976
1290
  if (projectionFailure)
977
1291
  return projectionFailure;
1292
+ const stateFailure = stateErrorResult(error);
1293
+ if (stateFailure)
1294
+ return stateFailure;
978
1295
  throw error;
979
1296
  }
1297
+ const loopId = result.sessionId
1298
+ ? mgr.get(result.sessionId)?.loopId
1299
+ : undefined;
980
1300
  if (result.submissionError) {
981
1301
  return {
982
1302
  error: result.submissionError.code,
983
- errorMessage: result.stopDetail ?? "the submission was rejected",
1303
+ errorMessage: result.stopDetail ??
1304
+ (rawRecovery !== undefined
1305
+ ? "the recovery request was rejected"
1306
+ : "the submission was rejected"),
984
1307
  details: result.submissionError.details,
985
- sessionId,
986
- roundId,
1308
+ ...(result.sessionId ? { sessionId: result.sessionId } : {}),
1309
+ ...(loopId ? { loopId } : {}),
1310
+ ...(result.submissionHandle ? { submissionHandle: result.submissionHandle } : {}),
987
1311
  };
988
1312
  }
989
- // v2.12: Typed projection — runtime facts for the main agent, attached
990
- // on every accepted/continued round (not on errors).
991
- let projection = null;
992
- if (result.enforcementAction !== "reject" && result.enforcementAction !== "terminate" &&
993
- result.stopReason !== "session_not_found" && result.stopReason !== "stalled") {
994
- projection = mgr.getProjection(sessionId);
1313
+ return { ...result, ...(loopId ? { loopId } : {}) };
1314
+ },
1315
+ async loopforge_checkpoint(mgr, input) {
1316
+ const result = await mgr.checkpoint(input.submissionHandle);
1317
+ if (result.error) {
1318
+ return {
1319
+ error: String(result.error),
1320
+ errorMessage: String(result.errorMessage ?? "the checkpoint was rejected"),
1321
+ details: result.details,
1322
+ ...(typeof result.sessionId === "string" ? { sessionId: result.sessionId } : {}),
1323
+ ...(isRecord(result.submissionHandle)
1324
+ ? { submissionHandle: result.submissionHandle }
1325
+ : {}),
1326
+ };
995
1327
  }
996
- return projection ? { ...result, projection } : { ...result };
1328
+ return { ok: true, ...result };
997
1329
  },
998
1330
  async loopforge_status(mgr, input) {
999
1331
  const view = input.view === "loop" || input.view === "all" ||
@@ -1043,52 +1375,125 @@ export const TOOL_HANDLERS = {
1043
1375
  errorMessage: `no health data for loop "${loopId}"`,
1044
1376
  };
1045
1377
  }
1046
- if (!sessionId) {
1378
+ if (!sessionId && !loopId) {
1047
1379
  return {
1048
1380
  error: "invalid_argument",
1049
- errorMessage: "sessionId is required for view=session",
1381
+ errorMessage: "sessionId or loopId is required for view=session",
1050
1382
  };
1051
1383
  }
1052
- const session = mgr.get(sessionId);
1384
+ // sessionId is a live-process key; loopId is the durable identity. Prefer
1385
+ // a live sessionId, then fall back to loopId so a model that kept only the
1386
+ // durable key can still read session state in the same process. A stale
1387
+ // sessionId after a restart resolves through loopId when both are present.
1388
+ const session = (sessionId ? mgr.get(sessionId) : undefined) ??
1389
+ (loopId ? mgr.getByLoopId(loopId) : undefined);
1053
1390
  if (!session) {
1391
+ // v4.0.4: a persisted STOPPED loop has no live session, but its
1392
+ // committed terminal facts are still readable. This is a projection of
1393
+ // committed history — no lease, no session save, no resume side effect.
1394
+ // A running/paused/stalled loop with no live session still goes through
1395
+ // resume; "no live session" is never treated as "stopped".
1396
+ if (loopId) {
1397
+ let stopped = null;
1398
+ try {
1399
+ stopped = mgr.getStoppedLoopSummary(loopId);
1400
+ }
1401
+ catch (error) {
1402
+ const stateFailure = stateErrorResult(error);
1403
+ if (stateFailure)
1404
+ return stateFailure;
1405
+ throw error;
1406
+ }
1407
+ if (stopped) {
1408
+ return {
1409
+ ok: true,
1410
+ loopId,
1411
+ round: stopped.round,
1412
+ status: stopped.sessionStatus ?? "stopped",
1413
+ loopStopReason: stopped.stopReason ?? null,
1414
+ agentSuccessClaim: stopped.agentSuccessClaim ?? null,
1415
+ agentOutcome: stopped.agentOutcome ?? null,
1416
+ agentShouldContinue: stopped.agentShouldContinue ?? null,
1417
+ stopDetail: stopped.stopDetail ?? null,
1418
+ last_submission: stopped.submission_receipt ?? null,
1419
+ };
1420
+ }
1421
+ }
1054
1422
  return {
1055
1423
  error: "session_not_found",
1056
- errorMessage: `session not found: ${sessionId} (process restarted?) — pass loopId for view=audit/loop, or loopforge_resume`,
1057
- sessionId,
1424
+ errorMessage: sessionId
1425
+ ? `session not found: ${sessionId} (process restarted?) — call ` +
1426
+ `loopforge_status with view="all" to find the loopId; if the ` +
1427
+ `held prompt is still in context, call loopforge_status with ` +
1428
+ `that loopId to fetch the current handle; otherwise call ` +
1429
+ `loopforge_resume with it`
1430
+ : `no live session for loop "${loopId}" — call loopforge_resume with this loopId`,
1431
+ ...(sessionId ? { sessionId } : {}),
1432
+ ...(loopId ? { loopId } : {}),
1433
+ details: {
1434
+ next: "loopforge_resume",
1435
+ ...(loopId ? { loopId } : {}),
1436
+ },
1058
1437
  };
1059
1438
  }
1060
- const metrics = session.engine.getMetrics();
1439
+ // Every live-session-derived projection must use the resolved session's
1440
+ // identity. The caller's raw sessionId is empty on a loopId-only query and
1441
+ // may be stale when the loopId fallback resolved the session; using it here
1442
+ // silently nulled the handle/contract/guidance and forced a full resume.
1443
+ const liveSessionId = session.sessionId;
1444
+ const policyMetricsView = mgr.getPolicyMetrics(session.loopId);
1445
+ // v4.0.3: a live held prompt has no result yet; the last committed
1446
+ // receipt is the correct current machine posture then.
1447
+ const lastReceipt = mgr.getLastSubmissionReceiptByLoopId(session.loopId);
1448
+ const posture = session.roundSnapshot?.result?.evidencePosture ??
1449
+ lastReceipt?.verification.evidence_posture ??
1450
+ null;
1061
1451
  return {
1062
- sessionId: session.sessionId,
1452
+ sessionId: liveSessionId,
1063
1453
  loopId: session.loopId,
1064
1454
  round: session.currentRound,
1065
- roundId: session.roundSnapshot?.roundId ?? null,
1455
+ submissionHandle: mgr.getSubmissionHandle(liveSessionId),
1456
+ checkpoints: session.roundSnapshot
1457
+ ? [
1458
+ ...(session.roundSnapshot.beforeCheckpoint
1459
+ ? [session.roundSnapshot.beforeCheckpoint]
1460
+ : []),
1461
+ ...(session.roundSnapshot.checkpoints ?? []),
1462
+ ]
1463
+ : [],
1464
+ beforeCheckpointError: session.roundSnapshot?.beforeCheckpointError ?? null,
1066
1465
  maxRounds: session.maxRounds,
1067
1466
  status: session.status,
1068
- successTrajectory: session.successTrajectory,
1467
+ evidencePosture: posture,
1468
+ // v4.0.4: no committed/held posture is null (unknown), not false.
1469
+ machineBackedSuccess: posture?.machineBackedSuccess ?? null,
1470
+ agentSuccessClaim: session.roundSnapshot?.result?.agentSuccessClaim ??
1471
+ lastReceipt?.agent_success_claim ?? null,
1472
+ agentOutcome: session.roundSnapshot?.result?.agentOutcome ??
1473
+ lastReceipt?.agent_outcome ?? null,
1474
+ machineBackedSuccessTrajectory: mgr.getMachineBackedSuccessTrajectory(session.loopId),
1069
1475
  lease: mgr.getLeaseStatus(session.loopId),
1070
1476
  // v3.5: derived ACTIVE Round Contract (display-only — the compile and
1071
1477
  // verification gates derive the same value from the same committed
1072
1478
  // evals via the shared round-contract walker).
1073
- activeContract: mgr.getActiveContract(sessionId),
1479
+ activeContract: mgr.getActiveContract(liveSessionId),
1074
1480
  // v3.9.1: current-fact submission guidance for the held round. Same
1075
1481
  // derivation as start/next/resume; read-only and never persisted.
1076
- submission_guidance: mgr.getSubmissionGuidance(sessionId),
1077
- last_submission: mgr.getLastSubmissionReceipt(sessionId),
1482
+ submission_guidance: mgr.getSubmissionGuidance(liveSessionId),
1483
+ last_submission: lastReceipt,
1484
+ last_rollback: mgr.replayRollbacks(session.loopId).at(-1) ?? null,
1078
1485
  // v3.8: the ONE capability fact — the static half the compile hashes
1079
1486
  // (what verification is possible) plus the live half rendered from the
1080
1487
  // last baseline. `available` is provider-registry state: reported here
1081
1488
  // and by `doctor`, never hashed.
1082
- capability: deriveEvidenceCapability(getPolicy(), session.evidenceBaseline ?? []),
1083
- projection: mgr.getProjection(sessionId),
1489
+ capability: deriveEvidenceCapability(getPolicy(), session.roundSnapshot?.beforeEvidence ?? []),
1084
1490
  metrics: {
1085
- vaultWriteErrors: metrics.vaultWriteErrors,
1086
- // v3.3.1: engine sessionStart was written but never read — surface it
1087
- // so a status view shows how long this engine session has been alive
1088
- // (observability design: the runtime is the loop's watchdog).
1089
- startedAt: metrics.sessionStart,
1090
- sessionAgeSeconds: Math.max(0, Math.floor((Date.now() - metrics.sessionStart) / 1000)),
1091
- policy: mgr.getPolicyMetrics(session.loopId),
1491
+ // The vault write counters live in the shared policy metrics now;
1492
+ // the engine no longer owns a second copy.
1493
+ vaultWriteErrors: policyMetricsView.vaultWriteErrors,
1494
+ startedAt: session.createdAt,
1495
+ sessionAgeSeconds: Math.max(0, Math.floor((Date.now() - session.createdAt) / 1000)),
1496
+ policy: policyMetricsView,
1092
1497
  },
1093
1498
  };
1094
1499
  },
@@ -1105,10 +1510,27 @@ export const TOOL_HANDLERS = {
1105
1510
  sessionId,
1106
1511
  };
1107
1512
  }
1108
- const roundsCompleted = session.currentRound;
1109
- const successTrajectory = [...session.successTrajectory];
1513
+ const loopId = session.loopId;
1514
+ const roundCursor = session.currentRound;
1515
+ const roundsCompleted = roundCursor;
1516
+ const machineBackedSuccessTrajectory = mgr.getMachineBackedSuccessTrajectory(loopId);
1110
1517
  mgr.delete(sessionId);
1111
- return { success: true, roundsCompleted, successTrajectory };
1518
+ // v4.0.4: make the stop response self-describing. `cancelled` is the
1519
+ // terminal reason the terminal sink just received; it is NOT persisted as
1520
+ // a committed round fact, so resume/status still report "no committed
1521
+ // terminal reason" after a restart. `success` remains tool-call success.
1522
+ return {
1523
+ ok: true,
1524
+ success: true,
1525
+ sessionId,
1526
+ loopId,
1527
+ roundsCompleted,
1528
+ roundCursor,
1529
+ sessionStatus: "stopped",
1530
+ loopStopReason: MANUAL_STOP_REASON,
1531
+ machineBackedSuccessRounds: machineBackedSuccessTrajectory.length,
1532
+ machineBackedSuccessTrajectory,
1533
+ };
1112
1534
  },
1113
1535
  async loopforge_pause(mgr, input) {
1114
1536
  const sessionId = String(input.sessionId ?? "");
@@ -1128,16 +1550,46 @@ export const TOOL_HANDLERS = {
1128
1550
  "after a restart (no live session)",
1129
1551
  };
1130
1552
  }
1553
+ // v4.0.2: an explicit window is validated at the tool boundary. The
1554
+ // backend owns the rule; the boundary only converts the type.
1555
+ const windowOptions = {};
1556
+ for (const name of ["start", "end"]) {
1557
+ const value = input[name];
1558
+ if (value === undefined)
1559
+ continue;
1560
+ if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
1561
+ return {
1562
+ error: "invalid_argument",
1563
+ errorMessage: `${name} must be a positive integer`,
1564
+ };
1565
+ }
1566
+ windowOptions[name] = value;
1567
+ }
1568
+ const pageFor = (resolvePage) => {
1569
+ try {
1570
+ return resolvePage();
1571
+ }
1572
+ catch (error) {
1573
+ if (error instanceof ReplayWindowError) {
1574
+ return { error: "invalid_argument", errorMessage: error.message };
1575
+ }
1576
+ throw error;
1577
+ }
1578
+ };
1131
1579
  // Live session first: it disambiguates re-used loopIds and the session
1132
1580
  // registry is the cheapest lookup.
1133
1581
  if (sessionId) {
1134
1582
  const session = mgr.get(sessionId);
1135
1583
  if (session) {
1136
- const timeline = mgr.replayTimeline(sessionId) ?? [];
1584
+ const page = pageFor(() => mgr.replayPage(sessionId, windowOptions));
1585
+ if ("error" in page)
1586
+ return page;
1137
1587
  return {
1138
1588
  sessionId,
1139
1589
  loopId: session.loopId,
1140
- timeline,
1590
+ timeline: page.timeline,
1591
+ window: page.window,
1592
+ rollbacks: mgr.replayRollbacks(session.loopId),
1141
1593
  legacyRounds: mgr.replayLegacyRounds(session.loopId),
1142
1594
  };
1143
1595
  }
@@ -1154,8 +1606,10 @@ export const TOOL_HANDLERS = {
1154
1606
  }
1155
1607
  // Vault-direct replay — the timeline lives in the committed round
1156
1608
  // documents, not in the session registry.
1157
- const timeline = mgr.replayByLoop(loopId);
1158
- if (!timeline) {
1609
+ const page = pageFor(() => mgr.replayPageByLoop(loopId, windowOptions));
1610
+ if ("error" in page)
1611
+ return page;
1612
+ if (page.window.totalRounds === 0 && page.timeline.length === 0) {
1159
1613
  return {
1160
1614
  error: "state_unavailable",
1161
1615
  errorMessage: `no committed rounds found for loop "${loopId}"`,
@@ -1165,7 +1619,9 @@ export const TOOL_HANDLERS = {
1165
1619
  return {
1166
1620
  ...(sessionId ? { sessionId } : {}),
1167
1621
  loopId,
1168
- timeline,
1622
+ timeline: page.timeline,
1623
+ window: page.window,
1624
+ rollbacks: mgr.replayRollbacks(loopId),
1169
1625
  legacyRounds: mgr.replayLegacyRounds(loopId),
1170
1626
  };
1171
1627
  },
@@ -1183,48 +1639,57 @@ export const TOOL_HANDLERS = {
1183
1639
  errorMessage: `invalid loopId: ${error.message}`,
1184
1640
  };
1185
1641
  }
1642
+ const inputs = {
1643
+ ...(input.invocationLost === true ? { invocationLost: true } : {}),
1644
+ ...(input.currentUserInstruction !== undefined
1645
+ ? { currentUserInstruction: String(input.currentUserInstruction) }
1646
+ : {}),
1647
+ };
1186
1648
  // Paused recovery must run first so the persisted status is atomically
1187
1649
  // changed back to running before a prompt is returned.
1188
1650
  let result;
1189
1651
  try {
1190
- result = await mgr.unpause(loopId);
1652
+ result = await mgr.unpause(loopId, inputs);
1191
1653
  if (!result) {
1192
- result = await mgr.resume(loopId);
1654
+ result = await mgr.resume(loopId, inputs);
1193
1655
  }
1194
1656
  }
1195
1657
  catch (error) {
1658
+ if (error instanceof InvocationInputError) {
1659
+ return { error: "invalid_argument", errorMessage: error.message };
1660
+ }
1661
+ if (error instanceof SessionLeaseConflictError) {
1662
+ return {
1663
+ error: "lease_conflict",
1664
+ errorMessage: `loop "${error.loopId}" is owned by another running LoopForge process`,
1665
+ details: { reason_code: "lease_conflict" },
1666
+ loopId,
1667
+ };
1668
+ }
1196
1669
  const projectionFailure = projectionErrorResult(error);
1197
1670
  if (projectionFailure)
1198
1671
  return projectionFailure;
1672
+ const stateFailure = stateErrorResult(error);
1673
+ if (stateFailure)
1674
+ return stateFailure;
1199
1675
  throw error;
1200
1676
  }
1201
1677
  if (!result) {
1202
1678
  return {
1203
1679
  error: "state_unavailable",
1204
1680
  errorMessage: `no saved session found for loop "${loopId}"`,
1681
+ loopId,
1205
1682
  };
1206
1683
  }
1207
- return { ...result };
1684
+ return { ...result, loopId };
1208
1685
  },
1209
1686
  async loopforge_gate_check(mgr, input) {
1210
1687
  // v3.7.1: hidden when disabled — direct calls get a stable error.
1211
1688
  if (!getPolicy().gate.enabled)
1212
1689
  return { error: "gate_disabled" };
1213
- const sessionId = String(input.sessionId ?? "");
1214
- const roundId = String(input.roundId ?? "");
1215
1690
  const action = input.action;
1216
- if (!sessionId) {
1217
- return { error: "invalid_argument", errorMessage: "sessionId is required" };
1218
- }
1219
- if (!roundId) {
1220
- return {
1221
- error: "round_id_required",
1222
- errorMessage: "roundId is required — pass the roundId from the latest response",
1223
- sessionId,
1224
- };
1225
- }
1226
1691
  if (!action || !isRecord(action)) {
1227
- return { error: "invalid_argument", errorMessage: "action is required", sessionId, roundId };
1692
+ return { error: "invalid_argument", errorMessage: "action is required" };
1228
1693
  }
1229
1694
  const descriptor = {
1230
1695
  description: String(action.description ?? ""),
@@ -1248,11 +1713,9 @@ export const TOOL_HANDLERS = {
1248
1713
  return {
1249
1714
  error: "invalid_argument",
1250
1715
  errorMessage: "action.description is required",
1251
- sessionId,
1252
- roundId,
1253
1716
  };
1254
1717
  }
1255
- return mgr.checkGate(sessionId, roundId, descriptor);
1718
+ return mgr.checkGate(input.submissionHandle, descriptor);
1256
1719
  },
1257
1720
  async loopforge_gate_resolve(mgr, input) {
1258
1721
  // v3.7.1: hidden when disabled — direct calls get a stable error.