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
package/dist/mcp/tools.js CHANGED
@@ -1,22 +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 { InvocationInputError, MANUAL_STOP_REASON, StartCriteriaError, } from "./round-lifecycle.js";
9
+ import { PROMPT_CONTEXT_REASONS } from "../protocol.js";
10
+ import { ProjectionCoverageError } from "../prompt-coverage.js";
11
+ import { LegacyHistoryError, LoopExistsError, OperatorAcceptanceConflictError, StartFailureError, } from "../errors.js";
8
12
  import { validateCoreSelfEvaluation } from "../self-eval.js";
9
13
  import { isRecord } from "../token-utils.js";
10
14
  import { getPolicy, validateLoopId } from "../policy.js";
11
15
  import { deriveEvidenceCapability } from "../evidence-provider.js";
12
- 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";
13
21
  // ═══════════════════════════════════════════════════════════════════════════
14
22
  // Tool schemas (MCP JSON Schema format)
15
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
+ ];
16
70
  const TOOL_BASE_SCHEMAS = [
17
71
  {
18
72
  name: "loopforge_start",
19
- 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.",
20
74
  inputSchema: {
21
75
  type: "object",
22
76
  properties: {
@@ -26,17 +80,16 @@ const TOOL_BASE_SCHEMAS = [
26
80
  },
27
81
  loopId: {
28
82
  type: "string",
29
- 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.",
30
87
  },
31
88
  maxRounds: {
32
89
  type: "integer",
33
90
  minimum: 1,
34
91
  description: "Maximum rounds before auto-stop. Default: 200 from policy.",
35
92
  },
36
- domain: {
37
- type: "string",
38
- description: "Domain hint (e.g. 'solidity', 'react', 'rust') for state context.",
39
- },
40
93
  planSource: {
41
94
  type: "string",
42
95
  description: "Plan reference for constraint extraction (e.g. 'docs/plan.md').",
@@ -46,36 +99,73 @@ const TOOL_BASE_SCHEMAS = [
46
99
  items: { type: "string" },
47
100
  description: "Hard constraints to enforce across all rounds.",
48
101
  },
102
+ criteria: {
103
+ type: "array",
104
+ items: { type: "string" },
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 " +
108
+ "rendered in every prompt and usable by contract items. Omit to keep " +
109
+ "the default criterion.",
110
+ },
49
111
  },
50
112
  required: ["task"],
51
113
  },
52
114
  },
53
115
  {
54
116
  name: "loopforge_next",
55
- 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.",
56
118
  inputSchema: {
57
119
  type: "object",
58
120
  properties: {
59
- sessionId: {
121
+ submissionHandle: SUBMISSION_HANDLE_SCHEMA,
122
+ output: {
60
123
  type: "string",
61
- 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.",
62
125
  },
63
- roundId: {
126
+ currentUserInstruction: {
64
127
  type: "string",
65
- 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.",
66
135
  },
67
- output: {
68
- type: "string",
69
- 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,
70
159
  },
71
160
  evaluation: {
72
161
  type: "object",
73
- description: "Required structured self-evaluation for this round. Free-text or embedded evaluation blocks are not accepted.",
74
- 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"],
75
164
  properties: {
76
- success: {
77
- type: "boolean",
78
- description: fieldGuideDescription("success"),
165
+ outcome: {
166
+ type: "string",
167
+ enum: ["success", "partial", "failed", "blocked"],
168
+ description: fieldGuideDescription("outcome"),
79
169
  },
80
170
  output_summary: {
81
171
  type: "string",
@@ -95,10 +185,6 @@ const TOOL_BASE_SCHEMAS = [
95
185
  items: { type: "string" },
96
186
  description: fieldGuideDescription("discovered_constraints"),
97
187
  },
98
- objective_refinement: {
99
- type: "string",
100
- description: fieldGuideDescription("objective_refinement"),
101
- },
102
188
  emerged_subtasks: {
103
189
  type: "array",
104
190
  items: { type: "string" },
@@ -113,38 +199,21 @@ const TOOL_BASE_SCHEMAS = [
113
199
  items: { type: "string" },
114
200
  description: fieldGuideDescription("execution_report.files_changed"),
115
201
  },
116
- tests_reported: {
117
- type: "object",
118
- description: fieldGuideDescription("execution_report.tests_reported"),
119
- properties: {
120
- passed: { type: "integer", minimum: 0, description: "Number of passing tests." },
121
- failed: { type: "integer", minimum: 0, description: "Number of failing tests." },
122
- skipped: { type: "integer", minimum: 0, description: "Number of skipped tests." },
123
- },
124
- },
125
- criterion_claims: {
202
+ claims: {
126
203
  type: "array",
127
- description: fieldGuideDescription("execution_report.criterion_claims"),
204
+ maxItems: 100,
205
+ description: fieldGuideDescription("execution_report.claims"),
128
206
  items: {
129
207
  type: "object",
130
208
  properties: {
131
- 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
+ },
132
214
  outcome: { type: "string", enum: ["met", "remaining"] },
133
215
  },
134
- required: ["criterion_id", "outcome"],
135
- additionalProperties: false,
136
- },
137
- },
138
- contract_item_claims: {
139
- type: "array",
140
- description: fieldGuideDescription("execution_report.contract_item_claims"),
141
- items: {
142
- type: "object",
143
- properties: {
144
- item_id: { type: "string", description: "rci-XXXXXXXX id of an item of the ACTIVE contract." },
145
- outcome: { type: "string", enum: ["met", "remaining"], description: "Your claim for that item this round." },
146
- },
147
- required: ["item_id", "outcome"],
216
+ required: ["id", "outcome"],
148
217
  additionalProperties: false,
149
218
  },
150
219
  },
@@ -155,22 +224,26 @@ const TOOL_BASE_SCHEMAS = [
155
224
  items: { type: "string" },
156
225
  description: fieldGuideDescription("retracted_constraints"),
157
226
  },
158
- revised_success_criteria: {
159
- type: "array",
160
- description: fieldGuideDescription("revised_success_criteria"),
161
- items: {
162
- type: "object",
163
- properties: {
164
- old: { type: "string", description: "Original criterion." },
165
- new: { type: "string", description: "Revised criterion." },
166
- },
167
- },
168
- },
169
227
  wrong_assumptions: {
170
228
  type: "array",
171
229
  items: { type: "string" },
172
230
  description: fieldGuideDescription("wrong_assumptions"),
173
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
+ },
174
247
  worker_results: {
175
248
  type: "array",
176
249
  description: fieldGuideDescription("worker_results"),
@@ -178,28 +251,23 @@ const TOOL_BASE_SCHEMAS = [
178
251
  type: "object",
179
252
  properties: {
180
253
  agentId: { type: "string" },
181
- subAgentType: { type: "string" },
182
254
  subTask: { type: "string" },
183
255
  resultSummary: { type: "string" },
184
256
  outcome: {
185
257
  type: "string",
186
258
  enum: ["success", "partial", "failed"],
187
259
  },
188
- discoveredConstraints: {
189
- type: "array",
190
- items: { type: "string" },
191
- },
192
260
  },
193
261
  required: ["agentId", "subTask", "resultSummary", "outcome"],
194
262
  },
195
263
  },
196
- compression_checkpoint: {
264
+ subtask_boundary: {
197
265
  type: "boolean",
198
- description: fieldGuideDescription("compression_checkpoint"),
266
+ description: fieldGuideDescription("subtask_boundary"),
199
267
  },
200
- checkpoint_label: {
268
+ subtask_boundary_label: {
201
269
  type: "string",
202
- description: fieldGuideDescription("checkpoint_label"),
270
+ description: fieldGuideDescription("subtask_boundary_label"),
203
271
  },
204
272
  subgoal_updates: {
205
273
  type: "array",
@@ -224,22 +292,18 @@ const TOOL_BASE_SCHEMAS = [
224
292
  required: ["id", "status"],
225
293
  },
226
294
  },
227
- stop_reason: {
228
- type: "string",
229
- enum: ["gave_up", "blocked", "needs_human_input"],
230
- description: fieldGuideDescription("stop_reason"),
231
- },
232
295
  no_change_reason: {
233
296
  type: "string",
234
297
  description: fieldGuideDescription("no_change_reason"),
235
298
  },
236
- outcome: {
299
+ objective_refinement: {
237
300
  type: "string",
238
- enum: ["success", "partial", "failed", "blocked"],
239
- description: fieldGuideDescription("outcome"),
301
+ maxLength: 1000,
302
+ description: fieldGuideDescription("objective_refinement"),
240
303
  },
241
304
  blocker: {
242
305
  type: "string",
306
+ maxLength: 500,
243
307
  description: fieldGuideDescription("blocker"),
244
308
  },
245
309
  gate_ids: {
@@ -261,54 +325,53 @@ const TOOL_BASE_SCHEMAS = [
261
325
  },
262
326
  description: fieldGuideDescription("retroactiveClaims"),
263
327
  },
264
- prompt_requests: {
265
- type: "object",
266
- description: fieldGuideDescription("prompt_requests"),
267
- properties: {
268
- emphasize: {
269
- type: "array",
270
- items: { type: "string" },
271
- 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.",
272
- },
273
- confusion_points: {
274
- type: "array",
275
- items: { type: "string" },
276
- 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.'",
277
- },
278
- },
279
- },
280
328
  round_contract: {
281
329
  type: "object",
282
330
  description: fieldGuideDescription("round_contract"),
283
331
  properties: {
284
332
  work_item: {
285
333
  type: "string",
286
- 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.",
287
339
  },
288
340
  items: {
289
341
  type: "array",
290
- 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.",
291
349
  items: {
292
350
  type: "object",
293
351
  properties: {
294
352
  description: {
295
353
  type: "string",
296
- 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.",
297
358
  },
298
359
  criterion_refs: {
299
360
  type: "array",
361
+ maxItems: 20,
300
362
  items: { type: "string" },
301
- description: "Optional. cr-XXXXXXXX criterion ids this item is evidence for (free text allowed). Max 20.",
302
- },
303
- subgoal_refs: {
304
- type: "array",
305
- items: { type: "string" },
306
- description: "Optional. sg-XXXXXXXX sub-goals THIS ITEM is evidence for. Must name sub-goals that exist (or that this same submission creates via emerged_subtasks). Max 20 ids. A machine-verified item produces the derived verified-subgoal fact for exactly the ids it names here.",
363
+ description: "Optional. Real cr-XXXXXXXX ids of ACTIVE criteria rendered in this prompt. Free text and unknown ids are rejected. Max 20.",
307
364
  },
308
365
  verify_with: {
309
366
  type: "array",
367
+ minItems: 1,
368
+ maxItems: 20,
310
369
  items: { type: "string" },
311
- 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.",
312
375
  },
313
376
  },
314
377
  required: ["description", "verify_with"],
@@ -317,31 +380,87 @@ const TOOL_BASE_SCHEMAS = [
317
380
  },
318
381
  scope: {
319
382
  type: "array",
383
+ maxItems: 50,
320
384
  items: { type: "string" },
321
- 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,
322
412
  },
323
413
  },
324
- required: ["items", "scope"],
414
+ required: ["work_item", "items"],
415
+ additionalProperties: false,
325
416
  },
326
417
  },
327
418
  },
328
419
  },
329
- 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,
330
438
  },
331
439
  },
332
440
  {
333
441
  name: "loopforge_status",
334
- 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.",
335
450
  inputSchema: {
336
451
  type: "object",
337
452
  properties: {
338
453
  sessionId: {
339
454
  type: "string",
340
- 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.",
341
458
  },
342
459
  loopId: {
343
460
  type: "string",
344
- 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.",
345
464
  },
346
465
  view: {
347
466
  type: "string",
@@ -358,7 +477,8 @@ const TOOL_BASE_SCHEMAS = [
358
477
  },
359
478
  {
360
479
  name: "loopforge_stop",
361
- 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.",
362
482
  inputSchema: {
363
483
  type: "object",
364
484
  properties: {
@@ -372,7 +492,7 @@ const TOOL_BASE_SCHEMAS = [
372
492
  },
373
493
  {
374
494
  name: "loopforge_pause",
375
- 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.",
376
496
  inputSchema: {
377
497
  type: "object",
378
498
  properties: {
@@ -386,10 +506,12 @@ const TOOL_BASE_SCHEMAS = [
386
506
  },
387
507
  {
388
508
  name: "loopforge_replay",
389
- description: "Replay a completed or running loop as an auditable round timeline. " +
390
- "Pass sessionId for a live session, or loopId to replay straight from " +
391
- "the vault (works after a process restart, when no session is in " +
392
- "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.",
393
515
  inputSchema: {
394
516
  type: "object",
395
517
  properties: {
@@ -402,6 +524,16 @@ const TOOL_BASE_SCHEMAS = [
402
524
  description: "Loop ID to replay from the vault — works without a " +
403
525
  "live session, e.g. after a restart.",
404
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
+ },
405
537
  },
406
538
  // v3.3.1: either identifier resolves the timeline; sessionId wins when
407
539
  // both are given (it also disambiguates re-used loopIds).
@@ -410,7 +542,20 @@ const TOOL_BASE_SCHEMAS = [
410
542
  },
411
543
  {
412
544
  name: "loopforge_resume",
413
- 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.",
414
559
  inputSchema: {
415
560
  type: "object",
416
561
  properties: {
@@ -418,6 +563,24 @@ const TOOL_BASE_SCHEMAS = [
418
563
  type: "string",
419
564
  description: "Loop ID to resume. Must have a saved session_state entry from a previous start/run.",
420
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
+ },
421
584
  },
422
585
  required: ["loopId"],
423
586
  },
@@ -433,8 +596,7 @@ const TOOL_BASE_SCHEMAS = [
433
596
  inputSchema: {
434
597
  type: "object",
435
598
  properties: {
436
- sessionId: { type: "string", description: "Session ID." },
437
- roundId: { type: "string", description: "RoundId from the latest response." },
599
+ submissionHandle: SUBMISSION_HANDLE_SCHEMA,
438
600
  action: {
439
601
  type: "object",
440
602
  description: "The action the agent plans to perform.",
@@ -471,7 +633,7 @@ const TOOL_BASE_SCHEMAS = [
471
633
  required: ["description", "scope", "effects", "reversibility", "authorization"],
472
634
  },
473
635
  },
474
- required: ["sessionId", "roundId", "action"],
636
+ required: ["submissionHandle", "action"],
475
637
  },
476
638
  },
477
639
  {
@@ -510,19 +672,79 @@ const ADVANCE_OUTPUT_SCHEMA = {
510
672
  type: "object",
511
673
  properties: {
512
674
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
513
- 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? }" },
514
676
  details: { type: "object", additionalProperties: true },
515
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
+ },
516
683
  round: { type: "number" },
517
- roundId: { type: ["string", "null"] },
684
+ submissionHandle: SUBMISSION_HANDLE_OUTPUT_SCHEMA,
518
685
  prompt: { type: ["string", "null"] },
519
- stopReason: { type: ["string", "null"] },
520
- 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." },
521
701
  level: { type: ["string", "null"] },
522
- roundSuccess: { type: ["boolean", "null"] },
523
- 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"] },
524
733
  enforcementReason: { type: ["string", "null"] },
525
- 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
+ },
526
748
  submission_guidance: {
527
749
  type: ["object", "null"],
528
750
  additionalProperties: true,
@@ -538,11 +760,6 @@ const ADVANCE_OUTPUT_SCHEMA = {
538
760
  "Returned by loopforge_next only.",
539
761
  },
540
762
  warnings: { type: "array", items: { type: "string" } },
541
- projection: {
542
- type: "object",
543
- additionalProperties: true,
544
- description: "v2.12: Typed cognitive state projection (focus/todo/phase/delegation/handoff).",
545
- },
546
763
  },
547
764
  additionalProperties: true,
548
765
  };
@@ -550,22 +767,59 @@ const ADVANCE_OUTPUT_SCHEMA = {
550
767
  * validates each handler's output against these before returning it —
551
768
  * a mismatch is a contract bug, surfaced as a JSON-RPC error instead of
552
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
+ };
553
789
  export const TOOL_OUTPUT_SCHEMAS = {
554
790
  loopforge_start: ADVANCE_OUTPUT_SCHEMA,
555
791
  loopforge_next: ADVANCE_OUTPUT_SCHEMA,
556
792
  loopforge_resume: ADVANCE_OUTPUT_SCHEMA,
793
+ loopforge_checkpoint: CHECKPOINT_OUTPUT_SCHEMA,
557
794
  loopforge_status: {
558
795
  type: "object",
559
796
  properties: {
560
797
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
561
- 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? }" },
562
799
  sessionId: { type: "string" },
563
800
  loopId: { type: "string" },
564
801
  round: { type: "number" },
565
- 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 },
566
805
  maxRounds: { type: "number" },
567
806
  status: { enum: ["running", "stopped", "stalled", "paused"] },
568
- 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"] },
569
823
  lease: { type: ["object", "null"], additionalProperties: true },
570
824
  metrics: { type: "object", additionalProperties: true },
571
825
  // v3.5: the ACTIVE Round Contract governing the next round (derived
@@ -583,10 +837,16 @@ export const TOOL_OUTPUT_SCHEMAS = {
583
837
  type: "object",
584
838
  properties: {
585
839
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
586
- error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
587
- success: { type: "boolean" },
588
- roundsCompleted: { type: "number" },
589
- 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" } },
590
850
  },
591
851
  additionalProperties: true,
592
852
  },
@@ -594,7 +854,7 @@ export const TOOL_OUTPUT_SCHEMAS = {
594
854
  type: "object",
595
855
  properties: {
596
856
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
597
- 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? }" },
598
858
  sessionId: { type: "string" },
599
859
  round: { type: "number" },
600
860
  status: { type: "string" },
@@ -605,10 +865,16 @@ export const TOOL_OUTPUT_SCHEMAS = {
605
865
  type: "object",
606
866
  properties: {
607
867
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
608
- 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? }" },
609
869
  sessionId: { type: "string" },
610
870
  loopId: { type: "string" },
611
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
+ },
612
878
  legacyRounds: {
613
879
  type: "array",
614
880
  items: { type: "object", additionalProperties: true },
@@ -622,8 +888,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
622
888
  type: "object",
623
889
  properties: {
624
890
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
625
- error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
626
- sessionId: { type: "string" },
891
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, submissionHandle?, details? }" },
892
+ submissionHandle: SUBMISSION_HANDLE_SCHEMA,
627
893
  gateId: { type: "string" },
628
894
  risk: { type: "string", enum: ["low", "high", "unknown"] },
629
895
  decision: { type: "string", enum: ["agent_allowed", "user_required"] },
@@ -639,7 +905,7 @@ export const TOOL_OUTPUT_SCHEMAS = {
639
905
  type: "object",
640
906
  properties: {
641
907
  ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
642
- 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? }" },
643
909
  sessionId: { type: "string" },
644
910
  loopId: { type: "string" },
645
911
  gateId: { type: "string" },
@@ -664,12 +930,6 @@ function closeObjectSchemas(schema) {
664
930
  }
665
931
  return result;
666
932
  }
667
- const CORE_EVALUATION_FIELDS = new Set([
668
- "success",
669
- "output_summary",
670
- "constraint_violations",
671
- "should_continue",
672
- ]);
673
933
  /** Keep the four authoritative fields schema-strict while leaving optional
674
934
  * reporting fields available for buildSelfEvaluation's lossy normalization.
675
935
  * Their descriptions remain in tools/list, but malformed optional values do
@@ -682,7 +942,7 @@ function prepareInputSchema(name, schema) {
682
942
  if (!isRecord(evaluation) || !isRecord(evaluation.properties))
683
943
  return result;
684
944
  evaluation.properties = Object.fromEntries(Object.entries(evaluation.properties).map(([field, value]) => {
685
- if (CORE_EVALUATION_FIELDS.has(field) || !isRecord(value))
945
+ if (CORE_SELF_EVALUATION_FIELDS.has(field) || !isRecord(value))
686
946
  return [field, value];
687
947
  return [field, typeof value.description === "string"
688
948
  ? { description: value.description }
@@ -805,7 +1065,7 @@ export function validateToolDispatchInput(name, input) {
805
1065
  : {};
806
1066
  validateSchema(input, {
807
1067
  ...contract.inputSchema,
808
- required: ["sessionId", "roundId"],
1068
+ required: ["submissionHandle"],
809
1069
  properties: { ...properties, evaluation: {} },
810
1070
  }, "arguments");
811
1071
  if (!isRecord(input.evaluation))
@@ -835,18 +1095,76 @@ function capabilityWarningList(existing) {
835
1095
  ...deriveEvidenceCapability(getPolicy()).warnings,
836
1096
  ])];
837
1097
  }
1098
+ /** v3.9.4: the stable envelope for a prompt-projection code defect. */
1099
+ function projectionErrorResult(error) {
1100
+ if (error instanceof ProjectionCoverageError) {
1101
+ return {
1102
+ error: "projection_incomplete",
1103
+ errorMessage: error.message,
1104
+ details: { coverage: error.coverage },
1105
+ };
1106
+ }
1107
+ return null;
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
+ }
838
1128
  export const TOOL_HANDLERS = {
839
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
+ }
840
1150
  const startInput = {
841
1151
  task: String(input.task ?? ""),
842
1152
  loopId: input.loopId,
843
1153
  maxRounds: typeof input.maxRounds === "number" ? input.maxRounds : undefined,
844
- domain: input.domain,
845
1154
  planSource: input.planSource,
846
1155
  constraints: Array.isArray(input.constraints)
847
1156
  ? input.constraints
848
1157
  : undefined,
1158
+ criteria: Array.isArray(input.criteria)
1159
+ ? input.criteria
1160
+ : undefined,
849
1161
  };
1162
+ if (input.criteria !== undefined && !Array.isArray(input.criteria)) {
1163
+ return {
1164
+ error: "invalid_argument",
1165
+ errorMessage: "criteria must be an array of non-empty strings",
1166
+ };
1167
+ }
850
1168
  if (!startInput.task.trim()) {
851
1169
  return {
852
1170
  error: "invalid_argument",
@@ -882,20 +1200,43 @@ export const TOOL_HANDLERS = {
882
1200
  result = await mgr.create(startInput);
883
1201
  }
884
1202
  catch (error) {
1203
+ if (error instanceof StartCriteriaError) {
1204
+ return {
1205
+ error: "invalid_argument",
1206
+ errorMessage: error.message,
1207
+ };
1208
+ }
1209
+ const projectionFailure = projectionErrorResult(error);
1210
+ if (projectionFailure)
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
+ }
885
1235
  return {
886
1236
  error: "policy_invalid",
887
1237
  errorMessage: `the runtime policy could not be loaded: ${error.message}`,
888
1238
  };
889
1239
  }
890
- // v2.14: a same-process duplicate loopId surfaces as a clean argument
891
- // error instead of a prompt-less "success" the client cannot act on.
892
- if (typeof result.stopReason === "string" &&
893
- result.stopReason.startsWith("loop_already_running:")) {
894
- return {
895
- error: "loop_already_running",
896
- errorMessage: result.stopDetail ?? "loop already running",
897
- };
898
- }
899
1240
  // v3.8: the static capability warning — a loop with no provider or no
900
1241
  // enabled command can still run; its success claims are recorded as
901
1242
  // insufficient instead of verified, and the agent is told so up front.
@@ -905,48 +1246,86 @@ export const TOOL_HANDLERS = {
905
1246
  : { ...result };
906
1247
  },
907
1248
  async loopforge_next(mgr, input) {
908
- const sessionId = String(input.sessionId ?? "");
909
1249
  const output = String(input.output ?? "");
910
1250
  const rawEval = input.evaluation;
911
- const roundId = input.roundId === undefined ? undefined : String(input.roundId);
912
- if (!sessionId) {
913
- return { error: "invalid_argument", errorMessage: "sessionId is required" };
914
- }
915
- // v3.0.1: every submission is anchored to the round it reports on. A
916
- // missing roundId would silently re-process a stale submission against a
917
- // later round — reject it here so the contract is enforced even when the
918
- // handler is invoked directly (bypassing schema validation).
919
- 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) {
920
1255
  return {
921
- error: "round_id_required",
922
- errorMessage: "roundId is required — pass the roundId from the most recent " +
923
- "loopforge_start / loopforge_next / loopforge_resume / loopforge_status response",
1256
+ error: "recovery_invalid",
1257
+ errorMessage: "evaluation and recovery are mutually exclusive",
924
1258
  };
925
1259
  }
926
- // v3.8.1: the submission boundary belongs to the RUNTIME, not to this
927
- // transport. `mgr.advance` applies all of it — object shape, core fields,
928
- // subgoal_updates structure and references, and the strict Round Contract
929
- // declaration/claims boundary — before touching any state, and reports a
930
- // refusal through `submissionError`. This handler only maps that onto the
931
- // tool envelope, so a non-MCP caller gets exactly the same strictness.
932
- const result = await mgr.advance(sessionId, output, rawEval, roundId);
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.
1266
+ let result;
1267
+ try {
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
+ }
1284
+ }
1285
+ catch (error) {
1286
+ if (error instanceof InvocationInputError) {
1287
+ return { error: "invalid_argument", errorMessage: error.message };
1288
+ }
1289
+ const projectionFailure = projectionErrorResult(error);
1290
+ if (projectionFailure)
1291
+ return projectionFailure;
1292
+ const stateFailure = stateErrorResult(error);
1293
+ if (stateFailure)
1294
+ return stateFailure;
1295
+ throw error;
1296
+ }
1297
+ const loopId = result.sessionId
1298
+ ? mgr.get(result.sessionId)?.loopId
1299
+ : undefined;
933
1300
  if (result.submissionError) {
934
1301
  return {
935
1302
  error: result.submissionError.code,
936
- 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"),
937
1307
  details: result.submissionError.details,
938
- sessionId,
939
- roundId,
1308
+ ...(result.sessionId ? { sessionId: result.sessionId } : {}),
1309
+ ...(loopId ? { loopId } : {}),
1310
+ ...(result.submissionHandle ? { submissionHandle: result.submissionHandle } : {}),
940
1311
  };
941
1312
  }
942
- // v2.12: Typed projection — runtime facts for the main agent, attached
943
- // on every accepted/continued round (not on errors).
944
- let projection = null;
945
- if (result.enforcementAction !== "reject" && result.enforcementAction !== "terminate" &&
946
- result.stopReason !== "session_not_found" && result.stopReason !== "stalled") {
947
- 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
+ };
948
1327
  }
949
- return projection ? { ...result, projection } : { ...result };
1328
+ return { ok: true, ...result };
950
1329
  },
951
1330
  async loopforge_status(mgr, input) {
952
1331
  const view = input.view === "loop" || input.view === "all" ||
@@ -996,52 +1375,125 @@ export const TOOL_HANDLERS = {
996
1375
  errorMessage: `no health data for loop "${loopId}"`,
997
1376
  };
998
1377
  }
999
- if (!sessionId) {
1378
+ if (!sessionId && !loopId) {
1000
1379
  return {
1001
1380
  error: "invalid_argument",
1002
- errorMessage: "sessionId is required for view=session",
1381
+ errorMessage: "sessionId or loopId is required for view=session",
1003
1382
  };
1004
1383
  }
1005
- 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);
1006
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
+ }
1007
1422
  return {
1008
1423
  error: "session_not_found",
1009
- errorMessage: `session not found: ${sessionId} (process restarted?) — pass loopId for view=audit/loop, or loopforge_resume`,
1010
- 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
+ },
1011
1437
  };
1012
1438
  }
1013
- 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;
1014
1451
  return {
1015
- sessionId: session.sessionId,
1452
+ sessionId: liveSessionId,
1016
1453
  loopId: session.loopId,
1017
1454
  round: session.currentRound,
1018
- 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,
1019
1465
  maxRounds: session.maxRounds,
1020
1466
  status: session.status,
1021
- 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),
1022
1475
  lease: mgr.getLeaseStatus(session.loopId),
1023
1476
  // v3.5: derived ACTIVE Round Contract (display-only — the compile and
1024
1477
  // verification gates derive the same value from the same committed
1025
1478
  // evals via the shared round-contract walker).
1026
- activeContract: mgr.getActiveContract(sessionId),
1479
+ activeContract: mgr.getActiveContract(liveSessionId),
1027
1480
  // v3.9.1: current-fact submission guidance for the held round. Same
1028
1481
  // derivation as start/next/resume; read-only and never persisted.
1029
- submission_guidance: mgr.getSubmissionGuidance(sessionId),
1030
- 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,
1031
1485
  // v3.8: the ONE capability fact — the static half the compile hashes
1032
1486
  // (what verification is possible) plus the live half rendered from the
1033
1487
  // last baseline. `available` is provider-registry state: reported here
1034
1488
  // and by `doctor`, never hashed.
1035
- capability: deriveEvidenceCapability(getPolicy(), session.evidenceBaseline ?? []),
1036
- projection: mgr.getProjection(sessionId),
1489
+ capability: deriveEvidenceCapability(getPolicy(), session.roundSnapshot?.beforeEvidence ?? []),
1037
1490
  metrics: {
1038
- vaultWriteErrors: metrics.vaultWriteErrors,
1039
- // v3.3.1: engine sessionStart was written but never read — surface it
1040
- // so a status view shows how long this engine session has been alive
1041
- // (observability design: the runtime is the loop's watchdog).
1042
- startedAt: metrics.sessionStart,
1043
- sessionAgeSeconds: Math.max(0, Math.floor((Date.now() - metrics.sessionStart) / 1000)),
1044
- 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,
1045
1497
  },
1046
1498
  };
1047
1499
  },
@@ -1058,10 +1510,27 @@ export const TOOL_HANDLERS = {
1058
1510
  sessionId,
1059
1511
  };
1060
1512
  }
1061
- const roundsCompleted = session.currentRound;
1062
- const successTrajectory = [...session.successTrajectory];
1513
+ const loopId = session.loopId;
1514
+ const roundCursor = session.currentRound;
1515
+ const roundsCompleted = roundCursor;
1516
+ const machineBackedSuccessTrajectory = mgr.getMachineBackedSuccessTrajectory(loopId);
1063
1517
  mgr.delete(sessionId);
1064
- 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
+ };
1065
1534
  },
1066
1535
  async loopforge_pause(mgr, input) {
1067
1536
  const sessionId = String(input.sessionId ?? "");
@@ -1081,16 +1550,46 @@ export const TOOL_HANDLERS = {
1081
1550
  "after a restart (no live session)",
1082
1551
  };
1083
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
+ };
1084
1579
  // Live session first: it disambiguates re-used loopIds and the session
1085
1580
  // registry is the cheapest lookup.
1086
1581
  if (sessionId) {
1087
1582
  const session = mgr.get(sessionId);
1088
1583
  if (session) {
1089
- const timeline = mgr.replayTimeline(sessionId) ?? [];
1584
+ const page = pageFor(() => mgr.replayPage(sessionId, windowOptions));
1585
+ if ("error" in page)
1586
+ return page;
1090
1587
  return {
1091
1588
  sessionId,
1092
1589
  loopId: session.loopId,
1093
- timeline,
1590
+ timeline: page.timeline,
1591
+ window: page.window,
1592
+ rollbacks: mgr.replayRollbacks(session.loopId),
1094
1593
  legacyRounds: mgr.replayLegacyRounds(session.loopId),
1095
1594
  };
1096
1595
  }
@@ -1107,8 +1606,10 @@ export const TOOL_HANDLERS = {
1107
1606
  }
1108
1607
  // Vault-direct replay — the timeline lives in the committed round
1109
1608
  // documents, not in the session registry.
1110
- const timeline = mgr.replayByLoop(loopId);
1111
- 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) {
1112
1613
  return {
1113
1614
  error: "state_unavailable",
1114
1615
  errorMessage: `no committed rounds found for loop "${loopId}"`,
@@ -1118,7 +1619,9 @@ export const TOOL_HANDLERS = {
1118
1619
  return {
1119
1620
  ...(sessionId ? { sessionId } : {}),
1120
1621
  loopId,
1121
- timeline,
1622
+ timeline: page.timeline,
1623
+ window: page.window,
1624
+ rollbacks: mgr.replayRollbacks(loopId),
1122
1625
  legacyRounds: mgr.replayLegacyRounds(loopId),
1123
1626
  };
1124
1627
  },
@@ -1136,39 +1639,57 @@ export const TOOL_HANDLERS = {
1136
1639
  errorMessage: `invalid loopId: ${error.message}`,
1137
1640
  };
1138
1641
  }
1642
+ const inputs = {
1643
+ ...(input.invocationLost === true ? { invocationLost: true } : {}),
1644
+ ...(input.currentUserInstruction !== undefined
1645
+ ? { currentUserInstruction: String(input.currentUserInstruction) }
1646
+ : {}),
1647
+ };
1139
1648
  // Paused recovery must run first so the persisted status is atomically
1140
1649
  // changed back to running before a prompt is returned.
1141
- let result = await mgr.unpause(loopId);
1142
- if (!result) {
1143
- result = await mgr.resume(loopId);
1650
+ let result;
1651
+ try {
1652
+ result = await mgr.unpause(loopId, inputs);
1653
+ if (!result) {
1654
+ result = await mgr.resume(loopId, inputs);
1655
+ }
1656
+ }
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
+ }
1669
+ const projectionFailure = projectionErrorResult(error);
1670
+ if (projectionFailure)
1671
+ return projectionFailure;
1672
+ const stateFailure = stateErrorResult(error);
1673
+ if (stateFailure)
1674
+ return stateFailure;
1675
+ throw error;
1144
1676
  }
1145
1677
  if (!result) {
1146
1678
  return {
1147
1679
  error: "state_unavailable",
1148
1680
  errorMessage: `no saved session found for loop "${loopId}"`,
1681
+ loopId,
1149
1682
  };
1150
1683
  }
1151
- return { ...result };
1684
+ return { ...result, loopId };
1152
1685
  },
1153
1686
  async loopforge_gate_check(mgr, input) {
1154
1687
  // v3.7.1: hidden when disabled — direct calls get a stable error.
1155
1688
  if (!getPolicy().gate.enabled)
1156
1689
  return { error: "gate_disabled" };
1157
- const sessionId = String(input.sessionId ?? "");
1158
- const roundId = String(input.roundId ?? "");
1159
1690
  const action = input.action;
1160
- if (!sessionId) {
1161
- return { error: "invalid_argument", errorMessage: "sessionId is required" };
1162
- }
1163
- if (!roundId) {
1164
- return {
1165
- error: "round_id_required",
1166
- errorMessage: "roundId is required — pass the roundId from the latest response",
1167
- sessionId,
1168
- };
1169
- }
1170
1691
  if (!action || !isRecord(action)) {
1171
- return { error: "invalid_argument", errorMessage: "action is required", sessionId, roundId };
1692
+ return { error: "invalid_argument", errorMessage: "action is required" };
1172
1693
  }
1173
1694
  const descriptor = {
1174
1695
  description: String(action.description ?? ""),
@@ -1192,11 +1713,9 @@ export const TOOL_HANDLERS = {
1192
1713
  return {
1193
1714
  error: "invalid_argument",
1194
1715
  errorMessage: "action.description is required",
1195
- sessionId,
1196
- roundId,
1197
1716
  };
1198
1717
  }
1199
- return mgr.checkGate(sessionId, roundId, descriptor);
1718
+ return mgr.checkGate(input.submissionHandle, descriptor);
1200
1719
  },
1201
1720
  async loopforge_gate_resolve(mgr, input) {
1202
1721
  // v3.7.1: hidden when disabled — direct calls get a stable error.