loopforge 3.0.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (194) hide show
  1. package/README.md +126 -143
  2. package/dist/audit.d.ts +50 -0
  3. package/dist/audit.d.ts.map +1 -0
  4. package/dist/audit.js +131 -0
  5. package/dist/audit.js.map +1 -0
  6. package/dist/canonical-state.d.ts +98 -23
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +257 -181
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +40 -33
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +17 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -0
  15. package/dist/cognitive-facts.js +99 -0
  16. package/dist/cognitive-facts.js.map +1 -0
  17. package/dist/cognitive-governance.d.ts +25 -33
  18. package/dist/cognitive-governance.d.ts.map +1 -1
  19. package/dist/cognitive-governance.js +38 -138
  20. package/dist/cognitive-governance.js.map +1 -1
  21. package/dist/committed-round.d.ts +59 -0
  22. package/dist/committed-round.d.ts.map +1 -0
  23. package/dist/committed-round.js +222 -0
  24. package/dist/committed-round.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +78 -0
  26. package/dist/enforcement-gate.d.ts.map +1 -0
  27. package/dist/enforcement-gate.js +1109 -0
  28. package/dist/enforcement-gate.js.map +1 -0
  29. package/dist/engine.d.ts +111 -0
  30. package/dist/engine.d.ts.map +1 -0
  31. package/dist/engine.js +887 -0
  32. package/dist/engine.js.map +1 -0
  33. package/dist/evidence-claims.d.ts +50 -0
  34. package/dist/evidence-claims.d.ts.map +1 -0
  35. package/dist/evidence-claims.js +116 -0
  36. package/dist/evidence-claims.js.map +1 -0
  37. package/dist/evidence-provider.d.ts +50 -23
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +167 -50
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/generate-schema.js +1 -84
  42. package/dist/generate-schema.js.map +1 -1
  43. package/dist/index.d.ts +19 -22
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +16 -15
  46. package/dist/index.js.map +1 -1
  47. package/dist/loop-compiler.d.ts +58 -3
  48. package/dist/loop-compiler.d.ts.map +1 -1
  49. package/dist/loop-compiler.js +841 -304
  50. package/dist/loop-compiler.js.map +1 -1
  51. package/dist/loop-extras-parser.d.ts +2 -2
  52. package/dist/loop-extras-parser.d.ts.map +1 -1
  53. package/dist/loop-extras-parser.js +2 -2
  54. package/dist/loop-extras-parser.js.map +1 -1
  55. package/dist/loop-projection.d.ts +6 -0
  56. package/dist/loop-projection.d.ts.map +1 -0
  57. package/dist/loop-projection.js +26 -0
  58. package/dist/loop-projection.js.map +1 -0
  59. package/dist/loop-store.d.ts +115 -24
  60. package/dist/loop-store.d.ts.map +1 -1
  61. package/dist/loop-store.js +365 -218
  62. package/dist/loop-store.js.map +1 -1
  63. package/dist/mcp/round-lifecycle.d.ts +214 -0
  64. package/dist/mcp/round-lifecycle.d.ts.map +1 -0
  65. package/dist/mcp/round-lifecycle.js +935 -0
  66. package/dist/mcp/round-lifecycle.js.map +1 -0
  67. package/dist/mcp/server.d.ts +2 -1
  68. package/dist/mcp/server.d.ts.map +1 -1
  69. package/dist/mcp/server.js +29 -43
  70. package/dist/mcp/server.js.map +1 -1
  71. package/dist/mcp/session.d.ts +64 -101
  72. package/dist/mcp/session.d.ts.map +1 -1
  73. package/dist/mcp/session.js +433 -1110
  74. package/dist/mcp/session.js.map +1 -1
  75. package/dist/mcp/tools.d.ts +20 -11
  76. package/dist/mcp/tools.d.ts.map +1 -1
  77. package/dist/mcp/tools.js +587 -413
  78. package/dist/mcp/tools.js.map +1 -1
  79. package/dist/policy-metrics.d.ts +12 -24
  80. package/dist/policy-metrics.d.ts.map +1 -1
  81. package/dist/policy-metrics.js +78 -184
  82. package/dist/policy-metrics.js.map +1 -1
  83. package/dist/policy.d.ts +61 -50
  84. package/dist/policy.d.ts.map +1 -1
  85. package/dist/policy.js +48 -146
  86. package/dist/policy.js.map +1 -1
  87. package/dist/prompt-assembler.d.ts +37 -6
  88. package/dist/prompt-assembler.d.ts.map +1 -1
  89. package/dist/prompt-assembler.js +487 -278
  90. package/dist/prompt-assembler.js.map +1 -1
  91. package/dist/prompt-policy.d.ts +1 -8
  92. package/dist/prompt-policy.d.ts.map +1 -1
  93. package/dist/prompt-policy.js +8 -26
  94. package/dist/prompt-policy.js.map +1 -1
  95. package/dist/protocol.d.ts +398 -236
  96. package/dist/protocol.d.ts.map +1 -1
  97. package/dist/protocol.js +147 -17
  98. package/dist/protocol.js.map +1 -1
  99. package/dist/replay.d.ts +5 -13
  100. package/dist/replay.d.ts.map +1 -1
  101. package/dist/replay.js +32 -69
  102. package/dist/replay.js.map +1 -1
  103. package/dist/round-contract.d.ts +64 -0
  104. package/dist/round-contract.d.ts.map +1 -0
  105. package/dist/round-contract.js +80 -0
  106. package/dist/round-contract.js.map +1 -0
  107. package/dist/round-coordinator.d.ts +23 -25
  108. package/dist/round-coordinator.d.ts.map +1 -1
  109. package/dist/round-coordinator.js +103 -92
  110. package/dist/round-coordinator.js.map +1 -1
  111. package/dist/round-driver.d.ts +13 -13
  112. package/dist/round-driver.d.ts.map +1 -1
  113. package/dist/round-driver.js +25 -14
  114. package/dist/round-driver.js.map +1 -1
  115. package/dist/round-transaction.d.ts +14 -18
  116. package/dist/round-transaction.d.ts.map +1 -1
  117. package/dist/round-transaction.js +37 -47
  118. package/dist/round-transaction.js.map +1 -1
  119. package/dist/self-eval.d.ts +48 -0
  120. package/dist/self-eval.d.ts.map +1 -0
  121. package/dist/self-eval.js +248 -0
  122. package/dist/self-eval.js.map +1 -0
  123. package/dist/storage.d.ts +9 -4
  124. package/dist/storage.d.ts.map +1 -1
  125. package/dist/storage.js +18 -6
  126. package/dist/storage.js.map +1 -1
  127. package/dist/token-utils.d.ts +15 -3
  128. package/dist/token-utils.d.ts.map +1 -1
  129. package/dist/token-utils.js +24 -5
  130. package/dist/token-utils.js.map +1 -1
  131. package/dist/verification-gate.d.ts +149 -0
  132. package/dist/verification-gate.d.ts.map +1 -0
  133. package/dist/verification-gate.js +1343 -0
  134. package/dist/verification-gate.js.map +1 -0
  135. package/dist/workspace.d.ts +29 -0
  136. package/dist/workspace.d.ts.map +1 -0
  137. package/dist/workspace.js +61 -0
  138. package/dist/workspace.js.map +1 -0
  139. package/loop_policy.json +37 -16
  140. package/package.json +5 -10
  141. package/skills/perception/SKILL.md +184 -202
  142. package/dist/canonical-event-writer.d.ts +0 -20
  143. package/dist/canonical-event-writer.d.ts.map +0 -1
  144. package/dist/canonical-event-writer.js +0 -131
  145. package/dist/canonical-event-writer.js.map +0 -1
  146. package/dist/compiler-subgoals.d.ts +0 -7
  147. package/dist/compiler-subgoals.d.ts.map +0 -1
  148. package/dist/compiler-subgoals.js +0 -93
  149. package/dist/compiler-subgoals.js.map +0 -1
  150. package/dist/derive-id.d.ts +0 -12
  151. package/dist/derive-id.d.ts.map +0 -1
  152. package/dist/derive-id.js +0 -31
  153. package/dist/derive-id.js.map +0 -1
  154. package/dist/mcp/session-projection.d.ts +0 -5
  155. package/dist/mcp/session-projection.d.ts.map +0 -1
  156. package/dist/mcp/session-projection.js +0 -18
  157. package/dist/mcp/session-projection.js.map +0 -1
  158. package/dist/mcp/session-queue.d.ts +0 -3
  159. package/dist/mcp/session-queue.d.ts.map +0 -1
  160. package/dist/mcp/session-queue.js +0 -18
  161. package/dist/mcp/session-queue.js.map +0 -1
  162. package/dist/mcp/session-types.d.ts +0 -72
  163. package/dist/mcp/session-types.d.ts.map +0 -1
  164. package/dist/mcp/session-types.js +0 -2
  165. package/dist/mcp/session-types.js.map +0 -1
  166. package/dist/round-commit-store.d.ts +0 -12
  167. package/dist/round-commit-store.d.ts.map +0 -1
  168. package/dist/round-commit-store.js +0 -13
  169. package/dist/round-commit-store.js.map +0 -1
  170. package/dist/round-gate.d.ts +0 -31
  171. package/dist/round-gate.d.ts.map +0 -1
  172. package/dist/round-gate.js +0 -259
  173. package/dist/round-gate.js.map +0 -1
  174. package/dist/round-recovery.d.ts +0 -17
  175. package/dist/round-recovery.d.ts.map +0 -1
  176. package/dist/round-recovery.js +0 -117
  177. package/dist/round-recovery.js.map +0 -1
  178. package/dist/round-report.d.ts +0 -16
  179. package/dist/round-report.d.ts.map +0 -1
  180. package/dist/round-report.js +0 -401
  181. package/dist/round-report.js.map +0 -1
  182. package/dist/runtime-services.d.ts +0 -59
  183. package/dist/runtime-services.d.ts.map +0 -1
  184. package/dist/runtime-services.js +0 -460
  185. package/dist/runtime-services.js.map +0 -1
  186. package/dist/vault-format.d.ts +0 -4
  187. package/dist/vault-format.d.ts.map +0 -1
  188. package/dist/vault-format.js +0 -4
  189. package/dist/vault-format.js.map +0 -1
  190. package/dist/vault-types.d.ts +0 -24
  191. package/dist/vault-types.d.ts.map +0 -1
  192. package/dist/vault-types.js +0 -2
  193. package/dist/vault-types.js.map +0 -1
  194. package/loopforge-policy.json +0 -340
package/dist/mcp/tools.js CHANGED
@@ -1,106 +1,17 @@
1
1
  /** LoopForge MCP — Tool definitions and handlers.
2
2
  *
3
- * Public tools for round execution, recovery, gates, and audit.
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).
4
6
  * Each handler receives SessionManager + parsed input, returns the output object.
5
7
  */
8
+ import { buildSelfEvaluation } from "../engine.js";
9
+ import { validateCoreSelfEvaluation } from "../self-eval.js";
6
10
  import { isRecord } from "../token-utils.js";
7
- import { parseRoundReport } from "../round-report.js";
8
- import { getPolicy } from "../policy.js";
11
+ import { validateLoopId } from "../policy.js";
9
12
  // ═══════════════════════════════════════════════════════════════════════════
10
13
  // Tool schemas (MCP JSON Schema format)
11
14
  // ═══════════════════════════════════════════════════════════════════════════
12
- const GATE_ACTION_SCHEMA = {
13
- type: "object",
14
- properties: {
15
- description: { type: "string" },
16
- scope: { type: "array", items: { type: "string" } },
17
- effects: { type: "array", items: { type: "string", enum: ["workspace_write", "production", "credentials", "data_migration", "public_api", "publish", "payment", "external_communication", "network"] } },
18
- reversibility: { type: "string", enum: ["reversible", "recoverable", "irreversible", "unknown"] },
19
- authorization: { type: "string", enum: ["agent_allowed", "user_required", "unknown"] },
20
- },
21
- required: ["description", "scope", "effects", "reversibility", "authorization"],
22
- };
23
- const ROUND_REPORT_SCHEMA = {
24
- type: "object",
25
- required: ["format", "outcome", "continuation", "summary", "violations"],
26
- properties: {
27
- format: { type: "string", const: "loopforge.round-report" },
28
- outcome: { type: "string", enum: ["success", "partial", "failed", "blocked"] },
29
- continuation: { type: "string", enum: ["continue", "stop"] },
30
- summary: { type: "string" },
31
- violations: { type: "array", items: { type: "string" } },
32
- agentIntent: { type: "string" },
33
- driftClarification: {
34
- type: "object",
35
- properties: {
36
- explanation: { type: "string", minLength: 1, maxLength: 2000 },
37
- anchors: { type: "array", maxItems: 8, items: { type: "string", maxLength: 240 } },
38
- },
39
- required: ["explanation", "anchors"],
40
- },
41
- evidence: {
42
- type: "object",
43
- properties: {
44
- files: { type: "array", items: { type: "string" } },
45
- checks: { type: "array", items: { type: "object", properties: {
46
- name: { type: "string" }, status: { type: "string", enum: ["passed", "failed", "not_run"] },
47
- summary: { type: "string" }, counts: { type: "object", properties: {
48
- passed: { type: "integer", minimum: 0 }, failed: { type: "integer", minimum: 0 }, skipped: { type: "integer", minimum: 0 },
49
- } },
50
- }, required: ["name", "status"] } },
51
- claims: { type: "array", items: { type: "object", properties: {
52
- id: { type: "string", pattern: "^claim-[a-f0-9]{12}$" }, targetId: { type: "string" }, evidenceRefs: { type: "array", items: { type: "string" } },
53
- }, required: ["id", "targetId", "evidenceRefs"] } },
54
- provenance: {
55
- type: "object",
56
- description: "Runtime-owned evidence provenance keyed by evidence reference.",
57
- additionalProperties: {
58
- type: "object",
59
- properties: {
60
- source: { type: "string", enum: ["agent", "command", "provider", "verification"] },
61
- observedAt: { type: "string" },
62
- status: { type: "string", enum: ["verified", "claimed", "contradicted", "unavailable"] },
63
- detail: { type: "string" },
64
- },
65
- required: ["source", "observedAt", "status"],
66
- },
67
- },
68
- noChangeReason: { type: "string" },
69
- },
70
- },
71
- blocker: { type: "object", properties: {
72
- kind: { type: "string", enum: ["dependency", "external", "needs_human_input", "agent"] },
73
- reason: { type: "string" }, references: { type: "array", items: { type: "string" } },
74
- action: GATE_ACTION_SCHEMA,
75
- }, required: ["kind", "reason"] },
76
- discoveries: { type: "object", properties: {
77
- wrongAssumptions: { type: "array", items: { type: "string" } },
78
- emergedWork: { type: "array", items: { type: "string" } },
79
- facts: { type: "array", items: { type: "string" } },
80
- newConstraints: { type: "array", items: { type: "string" } },
81
- } },
82
- subGoalEvents: { type: "object", properties: {
83
- completed: { type: "array", items: { type: "string" } },
84
- blocked: { type: "array", items: { type: "string" } },
85
- canceled: { type: "array", items: { type: "string" } },
86
- } },
87
- constraintEvents: { type: "object", properties: {
88
- retired: { type: "array", items: { type: "string" } },
89
- } },
90
- criterionRevisions: { type: "array", items: { type: "object", properties: {
91
- old: { type: "string" }, new: { type: "string" },
92
- }, required: ["old", "new"] } },
93
- objectiveRefinement: { type: "string" },
94
- phaseBoundaryProposal: { type: "object", properties: {
95
- label: { type: "string" }, reason: { type: "string" },
96
- }, required: ["label", "reason"] },
97
- contextRequest: { type: "object", properties: {
98
- emphasize: { type: "array", items: { type: "string" } },
99
- expand: { type: "array", items: { type: "string" } },
100
- confusion_points: { type: "array", items: { type: "string" } },
101
- } },
102
- },
103
- };
104
15
  const TOOL_BASE_SCHEMAS = [
105
16
  {
106
17
  name: "loopforge_start",
@@ -117,10 +28,9 @@ const TOOL_BASE_SCHEMAS = [
117
28
  description: "Optional loop ID. Generated automatically if not provided.",
118
29
  },
119
30
  maxRounds: {
120
- type: "number",
31
+ type: "integer",
121
32
  minimum: 1,
122
- multipleOf: 1,
123
- description: "Maximum rounds before auto-stop. Default: 100 from policy.",
33
+ description: "Maximum rounds before auto-stop. Default: 20 from policy.",
124
34
  },
125
35
  domain: {
126
36
  type: "string",
@@ -135,125 +45,328 @@ const TOOL_BASE_SCHEMAS = [
135
45
  items: { type: "string" },
136
46
  description: "Hard constraints to enforce across all rounds.",
137
47
  },
138
- successCriteria: {
139
- type: "array",
140
- items: { type: "string" },
141
- description: "Explicit success criteria to verify across the loop.",
142
- },
143
48
  },
144
49
  required: ["task"],
145
50
  },
146
51
  },
147
52
  {
148
53
  name: "loopforge_next",
149
- description: "Submit the canonical round report for the current round and advance to the next. Returns the next-round prompt, or null with a stopReason when the loop ends.",
54
+ 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.",
150
55
  inputSchema: {
151
56
  type: "object",
152
- additionalProperties: false,
153
57
  properties: {
154
58
  sessionId: {
155
59
  type: "string",
156
60
  description: "Session ID returned by loopforge_start.",
157
61
  },
158
- report: {
159
- ...ROUND_REPORT_SCHEMA,
160
- description: "Canonical round report. This is the only execution report accepted by loopforge_next.",
62
+ roundId: {
63
+ type: "string",
64
+ 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.",
65
+ },
66
+ output: {
67
+ type: "string",
68
+ description: "Optional. The agent's full output from executing the current round's prompt. The structured evaluation is the authoritative report.",
69
+ },
70
+ evaluation: {
71
+ type: "object",
72
+ description: "Required structured self-evaluation for this round. Free-text or embedded evaluation blocks are not accepted.",
73
+ required: ["success", "output_summary", "should_continue", "constraint_violations"],
74
+ properties: {
75
+ success: {
76
+ type: "boolean",
77
+ description: "true ONLY if all hard constraints met AND the task goal achieved.",
78
+ },
79
+ output_summary: {
80
+ type: "string",
81
+ description: "Specific, actionable summary of what was DONE this round — not what was attempted.",
82
+ },
83
+ should_continue: {
84
+ type: "boolean",
85
+ description: "false ONLY when the ENTIRE task is complete. Partial progress = true.",
86
+ },
87
+ constraint_violations: {
88
+ type: "array",
89
+ items: { type: "string" },
90
+ description: "Constraints the agent actually violated this round. Be honest.",
91
+ },
92
+ discovered_constraints: {
93
+ type: "array",
94
+ items: { type: "string" },
95
+ description: "Optional. New constraints discovered this round.",
96
+ },
97
+ objective_refinement: {
98
+ type: "string",
99
+ description: "Optional. If this round deepened understanding of the task objective.",
100
+ },
101
+ emerged_subtasks: {
102
+ type: "array",
103
+ items: { type: "string" },
104
+ description: "Optional. Sub-problems that surfaced during execution.",
105
+ },
106
+ execution_evidence: {
107
+ type: "object",
108
+ description: "Optional. Structured record of what actually happened this round.",
109
+ properties: {
110
+ files_changed: {
111
+ type: "array",
112
+ items: { type: "string" },
113
+ description: "Files modified this round.",
114
+ },
115
+ test_results: {
116
+ type: "object",
117
+ description: "Test results from this round.",
118
+ properties: {
119
+ passed: { type: "integer", minimum: 0, description: "Number of passing tests." },
120
+ failed: { type: "integer", minimum: 0, description: "Number of failing tests." },
121
+ skipped: { type: "integer", minimum: 0, description: "Number of skipped tests." },
122
+ },
123
+ },
124
+ success_criteria_met: {
125
+ type: "array",
126
+ items: { type: "string" },
127
+ description: "Success criteria satisfied this round.",
128
+ },
129
+ success_criteria_remaining: {
130
+ type: "array",
131
+ items: { type: "string" },
132
+ description: "Success criteria still outstanding.",
133
+ },
134
+ progress_estimate: {
135
+ type: "number",
136
+ minimum: 0,
137
+ maximum: 1,
138
+ description: "Estimated progress toward task completion (0.0–1.0).",
139
+ },
140
+ },
141
+ },
142
+ retracted_constraints: {
143
+ type: "array",
144
+ items: { type: "string" },
145
+ description: "Optional. Constraints the agent now believes are wrong.",
146
+ },
147
+ revised_success_criteria: {
148
+ type: "array",
149
+ description: "Optional. Success criteria that need reformulation.",
150
+ items: {
151
+ type: "object",
152
+ properties: {
153
+ old: { type: "string", description: "Original criterion." },
154
+ new: { type: "string", description: "Revised criterion." },
155
+ },
156
+ },
157
+ },
158
+ wrong_assumptions: {
159
+ type: "array",
160
+ items: { type: "string" },
161
+ description: "Optional. Assumptions from earlier rounds that were incorrect.",
162
+ },
163
+ worker_results: {
164
+ type: "array",
165
+ description: "Optional. Results of sub-agent / Worker delegations this round.",
166
+ items: {
167
+ type: "object",
168
+ properties: {
169
+ agentId: { type: "string" },
170
+ subAgentType: { type: "string" },
171
+ subTask: { type: "string" },
172
+ resultSummary: { type: "string" },
173
+ success: { type: "boolean" },
174
+ outcome: {
175
+ type: "string",
176
+ enum: ["success", "partial", "failed"],
177
+ description: "Optional. Worker outcome; derived from success when absent.",
178
+ },
179
+ discoveredConstraints: {
180
+ type: "array",
181
+ items: { type: "string" },
182
+ },
183
+ },
184
+ required: ["agentId", "subAgentType", "subTask", "resultSummary", "success"],
185
+ },
186
+ },
187
+ compression_checkpoint: {
188
+ type: "boolean",
189
+ description: "Optional. Set true at subtask boundaries to trigger a full-state checkpoint (L2 prompt) that survives rolling-window eviction. The checkpoint label is carried into future prompts.",
190
+ },
191
+ checkpoint_label: {
192
+ type: "string",
193
+ description: "Optional. Human-readable label for this checkpoint (e.g. 'core-functions-complete'). Only meaningful when compression_checkpoint is true.",
194
+ },
195
+ next_action: {
196
+ type: "string",
197
+ description: "Optional. What the agent plans to do in the next round. Helps LoopForge detect task drift early by comparing declared intent with actual work.",
198
+ },
199
+ completed_subtasks: {
200
+ type: "array",
201
+ items: { type: "string" },
202
+ description: "Optional. Sub-tasks completed this round. Matched to previously emerged sub-tasks by description similarity.",
203
+ },
204
+ blocked_subtasks: {
205
+ type: "array",
206
+ items: { type: "string" },
207
+ description: "Optional. Sub-tasks that are now blocked. Describe what is blocking and why.",
208
+ },
209
+ canceled_subtasks: {
210
+ type: "array",
211
+ items: { type: "string" },
212
+ description: "Optional. Sub-tasks no longer needed. Removes them from the active sub-goal list.",
213
+ },
214
+ stop_reason: {
215
+ type: "string",
216
+ enum: ["gave_up", "blocked", "needs_human_input"],
217
+ description: "Optional. Why the agent is stopping. Only meaningful when should_continue=false and success=false. 'blocked' means work cannot proceed under current constraints.",
218
+ },
219
+ no_change_reason: {
220
+ type: "string",
221
+ description: "Optional. Declared reason for claiming success with no machine-verifiable evidence (e.g. documentation-only round). Downgrades the success_without_verified_evidence check from error to info. Max 200 chars.",
222
+ },
223
+ outcome: {
224
+ type: "string",
225
+ enum: ["success", "partial", "failed", "blocked"],
226
+ description: "Optional. Tri-state outcome. When absent, derived from success (true→success, false→failed). 'partial' and 'blocked' must be declared explicitly. A declared non-success outcome suppresses the success-class checks even when success=true.",
227
+ },
228
+ blocker: {
229
+ type: "string",
230
+ description: "Optional. Flat blocker description, required only when outcome=blocked. Also feeds the user/agent gate classification. Max 500 chars.",
231
+ },
232
+ retroactiveClaims: {
233
+ type: "array",
234
+ items: {
235
+ type: "object",
236
+ properties: {
237
+ round: { type: "integer", minimum: 1 },
238
+ claim: { type: "string" },
239
+ },
240
+ required: ["round", "claim"],
241
+ additionalProperties: false,
242
+ },
243
+ description: "Optional. Claims that a PRIOR round satisfied a criterion. The runtime verifies them against the prior round's git evidence. Max 20 entries.",
244
+ },
245
+ drift_clarification: {
246
+ type: "string",
247
+ description: "Optional. Explain why this round's actions diverged from your previous round's next_action. Only required when the previous round was flagged for intent_drift or subgoal_drift. Mention concrete anchors: sub-goal IDs (sg-XXXXXXXX), constraint IDs (c-XXXXXXXX), criterion IDs (cr-XXXXXXXX), or file paths.",
248
+ },
249
+ prompt_requests: {
250
+ type: "object",
251
+ description: "Optional. Information needs for the next round's prompt. Use when you know what context you'll need to succeed in the next round.",
252
+ properties: {
253
+ emphasize: {
254
+ type: "array",
255
+ items: { type: "string" },
256
+ description: "Constraints, discoveries, or decisions that need emphasis in the next prompt. Matched by meaning — not exact text. Matched items appear in a 'Critical Context' section. Max 5 entries. Pure reordering — no token overhead.",
257
+ },
258
+ confusion_points: {
259
+ type: "array",
260
+ items: { type: "string" },
261
+ 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.'",
262
+ },
263
+ },
264
+ },
265
+ round_contract: {
266
+ type: "object",
267
+ description: "Optional. Proposal for the NEXT round's contract (v3.4). Declared here, it becomes the ACTIVE contract (rendered as the Current Task) only after this round commits, and stays active until a later round lists every done_when item in success_criteria_met or reports outcome=blocked. While it is active, restate it unchanged here; on completion or block, declare the next contract instead (or omit the field — the Current Task reverts to the original task). Checks: round_underspecified / round_unverifiable target this proposal; round_scope_drift and premature_boundary target the ACTIVE contract.",
268
+ properties: {
269
+ work_item: {
270
+ type: "string",
271
+ description: "Optional. Round focus, one line. Rendered as the active contract's Current Task first line. Max 200 chars.",
272
+ },
273
+ done_when: {
274
+ type: "array",
275
+ items: { type: "string" },
276
+ description: "Completion conditions: cr-XXXXXXXX criterion IDs or free text. Satisfied = list in success_criteria_met; still open = list in success_criteria_remaining. Max 20 items.",
277
+ },
278
+ verification_plan: {
279
+ type: "array",
280
+ items: { type: "string" },
281
+ description: "Names of configured, enabled evidence.commands (see loop_policy.json evidence.commands) that verify the done_when claims. Max 20 items.",
282
+ },
283
+ scope: {
284
+ type: "array",
285
+ items: { type: "string" },
286
+ description: "Files/directories this round may touch (workspace-relative; './' and trailing slashes accepted). Out-of-scope git changes trigger round_scope_drift. Max 50 items.",
287
+ },
288
+ },
289
+ required: ["done_when", "verification_plan", "scope"],
290
+ },
291
+ },
161
292
  },
162
293
  },
163
- required: ["sessionId", "report"],
294
+ required: ["sessionId", "roundId", "evaluation"],
164
295
  },
165
296
  },
166
297
  {
167
298
  name: "loopforge_status",
168
- description: "Read session, loop health, or all persisted loops through one canonical status view.",
299
+ 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).",
169
300
  inputSchema: {
170
301
  type: "object",
171
302
  properties: {
172
- scope: { type: "string", enum: ["session", "loop", "all"] },
173
303
  sessionId: {
174
304
  type: "string",
175
- description: "Session ID returned by loopforge_start.",
305
+ description: "Session ID returned by loopforge_start (views session/audit).",
176
306
  },
177
- loopId: { type: "string" },
178
- detail: { type: "string", enum: ["summary", "full"] },
179
- },
180
- required: ["scope"],
181
- },
182
- },
183
- {
184
- name: "loopforge_retry",
185
- description: "Submit a corrected round report for the current rejected logical round. The round ID and pre-round evidence baseline remain unchanged.",
186
- inputSchema: {
187
- type: "object",
188
- properties: {
189
- sessionId: {
307
+ loopId: {
190
308
  type: "string",
191
- description: "Session ID returned by loopforge_start.",
309
+ description: "Loop ID (views loop/audit).",
192
310
  },
193
- report: {
194
- ...ROUND_REPORT_SCHEMA,
195
- description: "Corrected canonical report for the rejected attempt.",
311
+ view: {
312
+ type: "string",
313
+ enum: ["session", "loop", "all", "audit"],
314
+ description: "Which view to return. Defaults to session.",
196
315
  },
197
316
  },
198
- required: ["sessionId", "report"],
317
+ required: [],
199
318
  },
200
319
  },
201
320
  {
202
- name: "loopforge_backtrack",
203
- description: "Move an active session to the round after its nearest clean restore point. Returns restore instructions but never changes workspace files.",
321
+ name: "loopforge_stop",
322
+ description: "Manually stop a loop session and return its final trajectory.",
204
323
  inputSchema: {
205
324
  type: "object",
206
325
  properties: {
207
326
  sessionId: {
208
327
  type: "string",
209
- description: "Session ID returned by loopforge_start.",
328
+ description: "Session ID to stop.",
210
329
  },
211
330
  },
212
331
  required: ["sessionId"],
213
332
  },
214
333
  },
215
334
  {
216
- name: "loopforge_history",
217
- description: "Read one persisted round or a deterministic event timeline.",
218
- inputSchema: {
219
- type: "object",
220
- properties: {
221
- loopId: { type: "string" },
222
- view: { type: "string", enum: ["round", "timeline"] },
223
- round: { type: "integer", minimum: 1 },
224
- fromSequence: { type: "integer", minimum: 1 },
225
- toSequence: { type: "integer", minimum: 1 },
226
- limit: { type: "integer", minimum: 1, maximum: 200 },
227
- },
228
- required: ["loopId", "view"],
229
- },
230
- },
231
- {
232
- name: "loopforge_stop",
233
- description: "Manually stop a loop session and return its final trajectory.",
335
+ name: "loopforge_pause",
336
+ 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.",
234
337
  inputSchema: {
235
338
  type: "object",
236
339
  properties: {
237
340
  sessionId: {
238
341
  type: "string",
239
- description: "Session ID to stop.",
342
+ description: "Session ID to pause.",
240
343
  },
241
344
  },
242
345
  required: ["sessionId"],
243
346
  },
244
347
  },
245
348
  {
246
- name: "loopforge_pause",
247
- 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.",
349
+ name: "loopforge_replay",
350
+ description: "Replay a completed or running loop as an auditable round timeline. " +
351
+ "Pass sessionId for a live session, or loopId to replay straight from " +
352
+ "the vault (works after a process restart, when no session is in " +
353
+ "memory) — the timeline is derived from committed round documents.",
248
354
  inputSchema: {
249
355
  type: "object",
250
356
  properties: {
251
357
  sessionId: {
252
358
  type: "string",
253
- description: "Session ID to pause.",
359
+ description: "Session ID to replay (live sessions).",
360
+ },
361
+ loopId: {
362
+ type: "string",
363
+ description: "Loop ID to replay from the vault — works without a " +
364
+ "live session, e.g. after a restart.",
254
365
  },
255
366
  },
256
- required: ["sessionId"],
367
+ // v3.3.1: either identifier resolves the timeline; sessionId wins when
368
+ // both are given (it also disambiguates re-used loopIds).
369
+ required: [],
257
370
  },
258
371
  },
259
372
  {
@@ -272,37 +385,42 @@ const TOOL_BASE_SCHEMAS = [
272
385
  },
273
386
  {
274
387
  name: "loopforge_gate_check",
275
- description: "Classify whether an action is an agent-resolvable gate or requires explicit user authority. Read-only.",
388
+ description: "v2.12: Classify a high-risk action or blocker text as a user gate (needs human authorization) or an agent gate (resolvable with evidence). Read-only — nothing is recorded. Use the returned gate id with loopforge_gate_resolve after a human decision.",
276
389
  inputSchema: {
277
390
  type: "object",
278
391
  properties: {
279
- action: GATE_ACTION_SCHEMA,
392
+ gateText: {
393
+ type: "string",
394
+ description: "The action or blocker text to classify, e.g. 'Deploy to production'.",
395
+ },
280
396
  },
281
- required: ["action"],
397
+ required: ["gateText"],
282
398
  },
283
399
  },
284
400
  {
285
401
  name: "loopforge_gate_resolve",
286
- description: "Record an explicit user decision for a previously classified gate.",
402
+ description: "v2.12: Record a human decision for a previously opened user gate. The gate id embeds the action hash — if the action text changed, the approval expires automatically. Read-only for agent gates (they are resolved by submitting evidence).",
287
403
  inputSchema: {
288
404
  type: "object",
289
405
  properties: {
290
- sessionId: { type: "string" },
291
- gateId: { type: "string" },
292
- action: GATE_ACTION_SCHEMA,
293
- approved: { type: "boolean" },
294
- note: { type: "string" },
406
+ sessionId: {
407
+ type: "string",
408
+ description: "Session ID whose loop owns the gate.",
409
+ },
410
+ gateId: {
411
+ type: "string",
412
+ description: "Gate id returned by loopforge_gate_check or recorded from a blocked round.",
413
+ },
414
+ approved: {
415
+ type: "boolean",
416
+ description: "Whether the human authorizes the action.",
417
+ },
418
+ note: {
419
+ type: "string",
420
+ description: "Optional human note recorded with the decision.",
421
+ },
295
422
  },
296
- required: ["sessionId", "gateId", "action", "approved", "note"],
297
- },
298
- },
299
- {
300
- name: "loopforge_audit",
301
- description: "Run a read-only final audit over accepted vault events, evidence claims, checks, and gate decisions.",
302
- inputSchema: {
303
- type: "object",
304
- properties: { loopId: { type: "string" } },
305
- required: ["loopId"],
423
+ required: ["sessionId", "gateId", "approved"],
306
424
  },
307
425
  },
308
426
  ];
@@ -310,33 +428,34 @@ const ADVANCE_OUTPUT_SCHEMA = {
310
428
  type: "object",
311
429
  properties: {
312
430
  error: { type: "string" },
431
+ details: { type: "object", additionalProperties: true },
313
432
  sessionId: { type: "string" },
314
433
  round: { type: "number" },
315
434
  roundId: { type: ["string", "null"] },
316
435
  prompt: { type: ["string", "null"] },
317
- decision: { enum: ["accepted", "rejected", "backtracked", "stopped", "terminated", "errored"] },
318
- attempt: { type: "number" },
319
- stopReason: { type: "string" },
320
- stopDetail: { type: "string", description: "Human-readable context for why the loop stopped. Provides facts without prescribing a specific agent action." },
321
- level: { type: "string" },
322
- roundSuccess: { type: "boolean" },
436
+ stopReason: { type: ["string", "null"] },
437
+ stopDetail: { type: ["string", "null"], description: "Human-readable context for why the loop stopped. Provides facts without prescribing a specific agent action." },
438
+ level: { type: ["string", "null"] },
439
+ roundSuccess: { type: ["boolean", "null"] },
323
440
  enforcementAction: { enum: ["accept", "reject", "terminate", "backtrack"] },
324
- enforcementReason: { type: "string" },
325
- projection: { type: "object", additionalProperties: true },
326
- evidenceSummary: { type: "object", additionalProperties: true },
327
- stateHash: { type: "string" },
328
- promptHash: { type: "string" },
441
+ enforcementReason: { type: ["string", "null"] },
329
442
  warnings: { type: "array", items: { type: "string" } },
443
+ projection: {
444
+ type: "object",
445
+ additionalProperties: true,
446
+ description: "v2.12: Typed cognitive state projection (focus/todo/phase/delegation/handoff).",
447
+ },
330
448
  },
331
449
  additionalProperties: true,
332
450
  };
333
- const TOOL_OUTPUT_SCHEMAS = {
451
+ /** Declared structured-output contracts for every tool. The MCP server
452
+ * validates each handler's output against these before returning it —
453
+ * a mismatch is a contract bug, surfaced as a JSON-RPC error instead of
454
+ * silently shipping an output that violates the declared schema. */
455
+ export const TOOL_OUTPUT_SCHEMAS = {
334
456
  loopforge_start: ADVANCE_OUTPUT_SCHEMA,
335
457
  loopforge_next: ADVANCE_OUTPUT_SCHEMA,
336
- loopforge_retry: ADVANCE_OUTPUT_SCHEMA,
337
- loopforge_backtrack: ADVANCE_OUTPUT_SCHEMA,
338
458
  loopforge_resume: ADVANCE_OUTPUT_SCHEMA,
339
- loopforge_history: { type: "object", additionalProperties: true },
340
459
  loopforge_status: {
341
460
  type: "object",
342
461
  properties: {
@@ -350,6 +469,10 @@ const TOOL_OUTPUT_SCHEMAS = {
350
469
  successTrajectory: { type: "array", items: { type: "boolean" } },
351
470
  lease: { type: ["object", "null"], additionalProperties: true },
352
471
  metrics: { type: "object", additionalProperties: true },
472
+ // v3.5: the ACTIVE Round Contract governing the next round (derived
473
+ // from committed rounds — null when the whole task is the Current
474
+ // Task).
475
+ activeContract: { type: ["object", "null"] },
353
476
  },
354
477
  additionalProperties: true,
355
478
  },
@@ -373,17 +496,47 @@ const TOOL_OUTPUT_SCHEMAS = {
373
496
  },
374
497
  additionalProperties: true,
375
498
  },
376
- loopforge_gate_check: { type: "object", additionalProperties: true },
377
- loopforge_gate_resolve: { type: "object", additionalProperties: true },
378
- loopforge_audit: { type: "object", additionalProperties: true },
499
+ loopforge_replay: {
500
+ type: "object",
501
+ properties: {
502
+ error: { type: "string" },
503
+ sessionId: { type: "string" },
504
+ loopId: { type: "string" },
505
+ timeline: { type: "array", items: { type: "object", additionalProperties: true } },
506
+ },
507
+ additionalProperties: true,
508
+ },
509
+ loopforge_gate_check: {
510
+ type: "object",
511
+ properties: {
512
+ error: { type: "string" },
513
+ id: { type: "string" },
514
+ kind: { type: "string" },
515
+ question: { type: "string" },
516
+ blockedScope: { type: "array", items: { type: "string" } },
517
+ allowedWork: { type: "array", items: { type: "string" } },
518
+ problem: { type: "string" },
519
+ requiredEvidence: { type: "array", items: { type: "string" } },
520
+ suggestedResolution: { type: "string" },
521
+ },
522
+ additionalProperties: true,
523
+ },
524
+ loopforge_gate_resolve: {
525
+ type: "object",
526
+ properties: {
527
+ error: { type: "string" },
528
+ sessionId: { type: "string" },
529
+ loopId: { type: "string" },
530
+ gateId: { type: "string" },
531
+ approved: { type: "boolean" },
532
+ },
533
+ additionalProperties: true,
534
+ },
379
535
  };
380
536
  function closeObjectSchemas(schema) {
381
537
  const result = { ...schema };
382
538
  if (result.type === "object") {
383
- // Preserve explicitly open maps such as EvidenceEnvelope.provenance.
384
- // Fixed-shape objects remain closed by default.
385
- if (!("additionalProperties" in result))
386
- result.additionalProperties = false;
539
+ result.additionalProperties = false;
387
540
  if (isRecord(result.properties)) {
388
541
  result.properties = Object.fromEntries(Object.entries(result.properties).map(([key, value]) => [
389
542
  key,
@@ -396,10 +549,36 @@ function closeObjectSchemas(schema) {
396
549
  }
397
550
  return result;
398
551
  }
552
+ const CORE_EVALUATION_FIELDS = new Set([
553
+ "success",
554
+ "output_summary",
555
+ "constraint_violations",
556
+ "should_continue",
557
+ ]);
558
+ /** Keep the four authoritative fields schema-strict while leaving optional
559
+ * reporting fields available for buildSelfEvaluation's lossy normalization.
560
+ * Their descriptions remain in tools/list, but malformed optional values do
561
+ * not become JSON-RPC argument failures before the runtime can ignore them. */
562
+ function prepareInputSchema(name, schema) {
563
+ const result = closeObjectSchemas(schema);
564
+ if (name !== "loopforge_next" || !isRecord(result.properties))
565
+ return result;
566
+ const evaluation = result.properties.evaluation;
567
+ if (!isRecord(evaluation) || !isRecord(evaluation.properties))
568
+ return result;
569
+ evaluation.properties = Object.fromEntries(Object.entries(evaluation.properties).map(([field, value]) => {
570
+ if (CORE_EVALUATION_FIELDS.has(field) || !isRecord(value))
571
+ return [field, value];
572
+ return [field, typeof value.description === "string"
573
+ ? { description: value.description }
574
+ : {}];
575
+ }));
576
+ return result;
577
+ }
399
578
  /** MCP tool contracts include strict input and structured output schemas. */
400
579
  export const TOOL_SCHEMAS = TOOL_BASE_SCHEMAS.map((schema) => ({
401
580
  ...schema,
402
- inputSchema: closeObjectSchemas(schema.inputSchema),
581
+ inputSchema: prepareInputSchema(schema.name, schema.inputSchema),
403
582
  outputSchema: TOOL_OUTPUT_SCHEMAS[schema.name] ?? {
404
583
  type: "object",
405
584
  additionalProperties: true,
@@ -411,12 +590,28 @@ export class ToolInputValidationError extends Error {
411
590
  this.name = "ToolInputValidationError";
412
591
  }
413
592
  }
593
+ function typeMatches(value, type) {
594
+ switch (type) {
595
+ case "object": return isRecord(value);
596
+ case "array": return Array.isArray(value);
597
+ case "string": return typeof value === "string";
598
+ case "boolean": return typeof value === "boolean";
599
+ case "number": return typeof value === "number" && Number.isFinite(value);
600
+ case "integer": return typeof value === "number" && Number.isInteger(value);
601
+ case "null": return value === null;
602
+ default: return true; // undeclared type — no constraint
603
+ }
604
+ }
414
605
  function validateSchema(value, schema, path) {
415
- const type = schema.type;
416
- if (type === "object") {
417
- if (!isRecord(value)) {
418
- throw new ToolInputValidationError(`${path} must be an object`);
419
- }
606
+ // v2.14: an explicitly-undefined optional field is the same as absent —
607
+ // output objects carry `key: undefined` after spreads of optional fields.
608
+ if (value === undefined)
609
+ return;
610
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
611
+ if (!types.some((type) => typeMatches(value, type))) {
612
+ throw new ToolInputValidationError(`${path} must be ${types.join(" or ")}`);
613
+ }
614
+ if (types.some((type) => type === "object") && isRecord(value)) {
420
615
  const properties = isRecord(schema.properties) ? schema.properties : {};
421
616
  const required = Array.isArray(schema.required)
422
617
  ? schema.required.filter((item) => typeof item === "string")
@@ -440,34 +635,23 @@ function validateSchema(value, schema, path) {
440
635
  }
441
636
  return;
442
637
  }
443
- if (type === "array") {
444
- if (!Array.isArray(value)) {
445
- throw new ToolInputValidationError(`${path} must be an array`);
446
- }
638
+ if (types.some((type) => type === "array") && Array.isArray(value)) {
447
639
  if (isRecord(schema.items)) {
448
640
  value.forEach((item, index) => validateSchema(item, schema.items, `${path}[${index}]`));
449
641
  }
450
642
  return;
451
643
  }
452
- if (type === "string" && typeof value !== "string") {
453
- throw new ToolInputValidationError(`${path} must be a string`);
454
- }
455
- if (Array.isArray(schema.enum) && !schema.enum.some((candidate) => candidate === value)) {
456
- throw new ToolInputValidationError(`${path} must be one of ${schema.enum.join(", ")}`);
457
- }
458
- if (type === "string" && typeof value === "string" &&
459
- typeof schema.pattern === "string" && !new RegExp(schema.pattern).test(value)) {
460
- throw new ToolInputValidationError(`${path} must match ${schema.pattern}`);
461
- }
462
- if (type === "boolean" && typeof value !== "boolean") {
463
- throw new ToolInputValidationError(`${path} must be a boolean`);
464
- }
465
- if (type === "number" || type === "integer") {
466
- if (typeof value !== "number" ||
467
- !Number.isFinite(value) ||
468
- (type === "integer" && !Number.isInteger(value))) {
469
- throw new ToolInputValidationError(`${path} must be ${type === "integer" ? "an integer" : "a number"}`);
644
+ if (types.some((type) => type === "string") && typeof value === "string") {
645
+ // Enforce declared enums — the schemas advertise them, so validation
646
+ // must too (v2.12: view/gateText enums were previously unchecked).
647
+ if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
648
+ throw new ToolInputValidationError(`${path} must be one of: ${schema.enum.join(", ")}`);
470
649
  }
650
+ return;
651
+ }
652
+ if ((types.some((type) => type === "number") ||
653
+ types.some((type) => type === "integer")) &&
654
+ typeof value === "number") {
471
655
  if (typeof schema.minimum === "number" && value < schema.minimum) {
472
656
  throw new ToolInputValidationError(`${path} must be >= ${schema.minimum}`);
473
657
  }
@@ -482,54 +666,44 @@ export function validateToolInput(name, input) {
482
666
  throw new ToolInputValidationError(`Unknown tool: ${name}`);
483
667
  validateSchema(input, contract.inputSchema, "arguments");
484
668
  }
485
- function enrichAdvanceResult(mgr, result) {
486
- const sessionId = typeof result.sessionId === "string" ? result.sessionId : "";
487
- const session = sessionId ? mgr.get(sessionId) : undefined;
488
- const projection = sessionId ? mgr.cognitiveProjection(sessionId) : null;
489
- const audit = session ? mgr.audit(session.loopId) : null;
490
- const enforcement = String(result.enforcementAction ?? "");
491
- const stopReason = String(result.stopReason ?? "");
492
- const decision = enforcement === "reject" ? "rejected"
493
- : enforcement === "backtrack" ? "backtracked"
494
- : enforcement === "terminate" ? "terminated"
495
- : stopReason ? "stopped" : "accepted";
496
- const artifact = session?.roundSnapshot?.promptArtifact;
497
- const criteria = isRecord(audit?.criteria) ? audit.criteria : {};
498
- const checks = isRecord(audit?.checks) ? audit.checks : {};
499
- return {
500
- ...result,
501
- decision,
502
- attempt: session?.roundSnapshot?.attempt ?? result.attempt,
503
- projection: projection ? {
504
- phase: Array.isArray(projection.phaseHistory)
505
- ? projection.phaseHistory.at(-1)?.label ?? ""
506
- : "",
507
- focus: projection.currentFocus ?? "",
508
- todo: projection.todoProjection ?? null,
509
- gate: projection.activeGate ?? null,
510
- } : undefined,
511
- evidenceSummary: {
512
- verified: Array.isArray(criteria.evidenced) ? criteria.evidenced : [],
513
- contradicted: Array.isArray(checks.failed) ? checks.failed : [],
514
- unavailable: Array.isArray(checks.unavailable) ? checks.unavailable : [],
515
- },
516
- stateHash: artifact?.stateHash,
517
- promptHash: artifact?.promptHash,
518
- };
519
- }
520
- /** Uniform decision enrichment for advance-family tools.
521
- *
522
- * Handler-level `{error}` responses and session-level lease conflicts are
523
- * not loop decisions — without this guard the enforcement-only derivation
524
- * above would label them "stopped" (any stopReason) or even "accepted"
525
- * (a bare error with no enforcement fields). */
526
- function finalizeAdvanceOutput(mgr, result) {
527
- const stopReason = String(result.stopReason ?? "");
528
- if ((result.error !== undefined && result.error !== null) ||
529
- stopReason.startsWith("session_owned_elsewhere")) {
530
- return { ...result, decision: "errored" };
669
+ /** Validate a server dispatch without turning a malformed evaluation into a
670
+ * JSON-RPC transport error. The advertised schema stays strict so clients can
671
+ * construct valid calls; the runtime envelope validates every outer argument,
672
+ * then lets loopforge_next return its retryable evaluation_invalid payload for
673
+ * core evaluation mistakes. Once the core is valid, the full schema still
674
+ * rejects unknown fields and other contract violations. */
675
+ export function validateToolDispatchInput(name, input) {
676
+ if (name !== "loopforge_next") {
677
+ validateToolInput(name, input);
678
+ return;
531
679
  }
532
- return enrichAdvanceResult(mgr, result);
680
+ const contract = TOOL_SCHEMAS.find((schema) => schema.name === name);
681
+ if (!contract)
682
+ throw new ToolInputValidationError(`Unknown tool: ${name}`);
683
+ const properties = isRecord(contract.inputSchema.properties)
684
+ ? contract.inputSchema.properties
685
+ : {};
686
+ validateSchema(input, {
687
+ ...contract.inputSchema,
688
+ required: ["sessionId", "roundId"],
689
+ properties: { ...properties, evaluation: {} },
690
+ }, "arguments");
691
+ if (!isRecord(input.evaluation))
692
+ return;
693
+ const validation = validateCoreSelfEvaluation(input.evaluation);
694
+ if (validation.missing.length > 0 || validation.invalid.length > 0)
695
+ return;
696
+ validateSchema(input, contract.inputSchema, "arguments");
697
+ }
698
+ /** Validate a handler's output against the tool's declared output schema.
699
+ * v2.14: the schemas were advertised in tools/list but never enforced —
700
+ * a mismatched output silently violated the declared contract. Throws
701
+ * ToolInputValidationError on mismatch. */
702
+ export function validateToolOutput(name, output) {
703
+ const schema = TOOL_OUTPUT_SCHEMAS[name];
704
+ if (!schema)
705
+ return;
706
+ validateSchema(output, schema, `output.${name}`);
533
707
  }
534
708
  export const TOOL_HANDLERS = {
535
709
  async loopforge_start(mgr, input) {
@@ -542,92 +716,113 @@ export const TOOL_HANDLERS = {
542
716
  constraints: Array.isArray(input.constraints)
543
717
  ? input.constraints
544
718
  : undefined,
545
- successCriteria: Array.isArray(input.successCriteria)
546
- ? input.successCriteria
547
- : undefined,
548
719
  };
549
720
  if (!startInput.task.trim()) {
550
- return { error: "task is required and must be non-empty", decision: "errored" };
721
+ return { error: "task is required and must be non-empty" };
551
722
  }
552
- const policyMaxRounds = getPolicy().engine.max_rounds;
723
+ // v2.14: entry-point validation — a bad maxRounds previously passed
724
+ // through and stopped the loop on the first round; an invalid loopId
725
+ // exploded later as an internal storage error instead of a clean
726
+ // argument error.
553
727
  if (startInput.maxRounds !== undefined &&
554
- (!Number.isInteger(startInput.maxRounds) || startInput.maxRounds < 1 || startInput.maxRounds > policyMaxRounds)) {
555
- return { error: `maxRounds must be an integer between 1 and ${policyMaxRounds}`, decision: "errored" };
728
+ (!Number.isInteger(startInput.maxRounds) || startInput.maxRounds < 1)) {
729
+ return { error: "maxRounds must be a positive integer" };
730
+ }
731
+ if (startInput.loopId !== undefined) {
732
+ try {
733
+ validateLoopId(startInput.loopId);
734
+ }
735
+ catch (error) {
736
+ return { error: `invalid loopId: ${error.message}` };
737
+ }
556
738
  }
557
739
  const result = await mgr.create(startInput);
558
- const enriched = finalizeAdvanceOutput(mgr, { ...result });
559
- const session = typeof result.sessionId === "string" ? mgr.get(result.sessionId) : undefined;
560
- return {
561
- ...enriched,
562
- loopId: session?.loopId,
563
- goalId: session?.runtime.state?.task_id ?? session?.loopId,
564
- };
740
+ // v2.14: a same-process duplicate loopId surfaces as a clean argument
741
+ // error instead of a prompt-less "success" the client cannot act on.
742
+ if (typeof result.stopReason === "string" &&
743
+ result.stopReason.startsWith("loop_already_running:")) {
744
+ return { error: result.stopDetail ?? "loop already running" };
745
+ }
746
+ return { ...result };
565
747
  },
566
748
  async loopforge_next(mgr, input) {
567
749
  const sessionId = String(input.sessionId ?? "");
750
+ const output = String(input.output ?? "");
751
+ const rawEval = input.evaluation;
752
+ const roundId = input.roundId === undefined ? undefined : String(input.roundId);
568
753
  if (!sessionId)
569
- return { error: "sessionId is required", decision: "errored" };
570
- const report = parseRoundReport(input.report);
571
- if (!report)
572
- return { error: "report is not a valid RoundReport", decision: "errored" };
573
- const result = await mgr.advance(sessionId, report);
574
- return finalizeAdvanceOutput(mgr, { ...result });
575
- },
576
- async loopforge_retry(mgr, input) {
577
- const sessionId = String(input.sessionId ?? "");
578
- if (!sessionId)
579
- return { error: "sessionId is required", decision: "errored" };
580
- const report = parseRoundReport(input.report);
581
- if (!report)
582
- return { error: "report is not a valid RoundReport", decision: "errored" };
583
- return finalizeAdvanceOutput(mgr, { ...await mgr.retry(sessionId, report) });
584
- },
585
- async loopforge_backtrack(mgr, input) {
586
- const sessionId = String(input.sessionId ?? "");
587
- if (!sessionId)
588
- return { error: "sessionId is required", decision: "errored" };
589
- // Backtrack is an enforcement action: enrich it like the other advance
590
- // tools so the output honors the shared advance contract.
591
- return finalizeAdvanceOutput(mgr, { ...await mgr.backtrack(sessionId) });
754
+ return { error: "sessionId is required" };
755
+ // v3.0.1: every submission is anchored to the round it reports on. A
756
+ // missing roundId would silently re-process a stale submission against a
757
+ // later round — reject it here so the contract is enforced even when the
758
+ // handler is invoked directly (bypassing schema validation).
759
+ if (!roundId || !roundId.trim()) {
760
+ return { error: "roundId is required — pass the roundId from the most recent loopforge_start / loopforge_next / loopforge_resume / loopforge_status response" };
761
+ }
762
+ if (!rawEval || typeof rawEval !== "object" || Array.isArray(rawEval)) {
763
+ return {
764
+ error: "evaluation_invalid",
765
+ details: {
766
+ missing: ["evaluation"],
767
+ invalid: [{ field: "evaluation", expected: "object" }],
768
+ },
769
+ sessionId,
770
+ roundId,
771
+ };
772
+ }
773
+ const validation = validateCoreSelfEvaluation(rawEval);
774
+ if (validation.missing.length > 0 || validation.invalid.length > 0) {
775
+ return {
776
+ error: "evaluation_invalid",
777
+ details: validation,
778
+ sessionId,
779
+ roundId,
780
+ };
781
+ }
782
+ const preExtractedEval = buildSelfEvaluation(rawEval);
783
+ const result = await mgr.advance(sessionId, output, preExtractedEval, roundId);
784
+ // v2.12: Typed projection — runtime facts for the main agent, attached
785
+ // on every accepted/continued round (not on errors).
786
+ let projection = null;
787
+ if (result.enforcementAction !== "reject" && result.enforcementAction !== "terminate" &&
788
+ result.stopReason !== "session_not_found" && result.stopReason !== "stalled") {
789
+ projection = mgr.getProjection(sessionId);
790
+ }
791
+ return projection ? { ...result, projection } : { ...result };
592
792
  },
593
793
  async loopforge_status(mgr, input) {
594
- const scope = String(input.scope ?? "");
595
- const hasSessionId = typeof input.sessionId === "string" && input.sessionId.trim().length > 0;
596
- const hasLoopId = typeof input.loopId === "string" && input.loopId.trim().length > 0;
597
- if (scope === "all") {
598
- if (hasSessionId || hasLoopId)
599
- return { error: "scope=all does not accept sessionId or loopId" };
600
- return { scope, sessions: mgr.list() };
794
+ const view = input.view === "loop" || input.view === "all" || input.view === "audit"
795
+ ? input.view
796
+ : "session";
797
+ const sessionId = String(input.sessionId ?? "");
798
+ const loopId = String(input.loopId ?? "");
799
+ // v2.12: view=all — every persisted loop (formerly loopforge_list).
800
+ if (view === "all") {
801
+ return { sessions: mgr.list() };
601
802
  }
602
- if (scope === "loop") {
603
- if (hasSessionId)
604
- return { error: "scope=loop does not accept sessionId" };
605
- const loopId = String(input.loopId ?? "").trim();
803
+ // v2.12: view=audit — read-only verification audit.
804
+ if (view === "audit") {
606
805
  if (!loopId)
607
- return { error: "loopId is required for loop scope" };
806
+ return { error: "loopId is required for view=audit" };
807
+ const audit = mgr.getAudit(loopId);
808
+ if (!audit)
809
+ return { error: `no audit data found for loop "${loopId}"` };
810
+ return audit;
811
+ }
812
+ // v2.12: view=loop — loop health (formerly loopforge_health).
813
+ if (view === "loop") {
814
+ if (!loopId)
815
+ return { error: "loopId is required for view=loop" };
608
816
  const health = mgr.getHealth(loopId);
609
- if (!health)
610
- return { error: `no data found for loop "${loopId}"` };
611
- const audit = mgr.audit(loopId);
612
- return input.detail === "full"
613
- ? { scope, loopId, health, audit }
614
- : { scope, loopId, health };
817
+ return health ?? { error: `no health data for loop "${loopId}"` };
615
818
  }
616
- if (scope !== "session")
617
- return { error: "scope must be session, loop, or all" };
618
- if (hasLoopId)
619
- return { error: "scope=session does not accept loopId" };
620
- const sessionId = String(input.sessionId ?? "");
621
819
  if (!sessionId)
622
- return { error: "sessionId is required" };
820
+ return { error: "sessionId is required for view=session" };
623
821
  const session = mgr.get(sessionId);
624
822
  if (!session)
625
823
  return { error: `session not found: ${sessionId}` };
626
- const metrics = session.runtime.getMetrics();
627
- const state = mgr.cognitiveProjection(sessionId);
628
- const artifact = session.roundSnapshot?.promptArtifact;
629
- const audit = mgr.audit(session.loopId);
630
- const summary = {
824
+ const metrics = session.engine.getMetrics();
825
+ return {
631
826
  sessionId: session.sessionId,
632
827
  loopId: session.loopId,
633
828
  round: session.currentRound,
@@ -636,40 +831,19 @@ export const TOOL_HANDLERS = {
636
831
  status: session.status,
637
832
  successTrajectory: session.successTrajectory,
638
833
  lease: mgr.getLeaseStatus(session.loopId),
639
- };
640
- if (input.detail !== "full")
641
- return summary;
642
- return {
643
- ...summary,
644
- cognition: state ? {
645
- phaseHistory: state.phaseHistory ?? [],
646
- currentFocus: state.currentFocus ?? "",
647
- todoProjection: state.todoProjection ?? null,
648
- suggestedNextAction: state.suggestedNextAction ?? "",
649
- activeGate: state.activeGate ?? null,
650
- handoff: state.handoff ?? null,
651
- stateFreshness: state.stateFreshness ?? "missing",
652
- regressionObligations: state.regressionObligations ?? [],
653
- } : null,
654
- promptDiagnostics: artifact ? {
655
- level: artifact.level,
656
- reasons: artifact.levelReasons,
657
- includedSections: artifact.includedSections,
658
- omittedSections: artifact.omittedSections,
659
- stateHash: artifact.stateHash,
660
- promptHash: artifact.promptHash,
661
- lastL2Round: artifact.lastL2Round,
662
- lastMeaningfulProgressRound: artifact.lastMeaningfulProgressRound,
663
- stateAgeRounds: artifact.stateAgeRounds,
664
- } : null,
665
- audit: {
666
- verdict: audit.verdict,
667
- missingCriteria: isRecord(audit.criteria) ? audit.criteria.missing : [],
668
- unresolvedUserGates: audit.unresolvedUserGates ?? [],
669
- },
834
+ // v3.5: derived ACTIVE Round Contract (display-only — the compile and
835
+ // verification gates derive the same value from the same committed
836
+ // evals via the shared round-contract walker).
837
+ activeContract: mgr.getActiveContract(sessionId),
838
+ projection: mgr.getProjection(sessionId),
670
839
  metrics: {
671
840
  vaultWriteErrors: metrics.vaultWriteErrors,
672
- policy: mgr.getLoopPolicyMetrics(session.loopId),
841
+ // v3.3.1: engine sessionStart was written but never read — surface it
842
+ // so a status view shows how long this engine session has been alive
843
+ // (observability design: the runtime is the loop's watchdog).
844
+ startedAt: metrics.sessionStart,
845
+ sessionAgeSeconds: Math.max(0, Math.floor((Date.now() - metrics.sessionStart) / 1000)),
846
+ policy: mgr.getPolicyMetrics(session.loopId),
673
847
  },
674
848
  };
675
849
  },
@@ -683,17 +857,7 @@ export const TOOL_HANDLERS = {
683
857
  const roundsCompleted = session.currentRound;
684
858
  const successTrajectory = [...session.successTrajectory];
685
859
  mgr.delete(sessionId);
686
- const audit = mgr.audit(session.loopId);
687
- return {
688
- success: true,
689
- sessionId,
690
- loopId: session.loopId,
691
- stopReason: "cancelled",
692
- stopDetail: "The loop was manually stopped. Vault state was retained for audit and inspection.",
693
- roundsCompleted,
694
- successTrajectory,
695
- audit,
696
- };
860
+ return { success: true, roundsCompleted, successTrajectory };
697
861
  },
698
862
  async loopforge_pause(mgr, input) {
699
863
  const sessionId = String(input.sessionId ?? "");
@@ -702,53 +866,63 @@ export const TOOL_HANDLERS = {
702
866
  const result = mgr.pause(sessionId);
703
867
  return { ...result };
704
868
  },
705
- async loopforge_history(mgr, input) {
706
- const loopId = String(input.loopId ?? "").trim();
707
- const view = String(input.view ?? "");
708
- if (!loopId)
709
- return { error: "loopId is required" };
710
- if (view !== "round" && view !== "timeline")
711
- return { error: "view must be round or timeline" };
712
- if (view === "round") {
713
- const round = Number(input.round);
714
- if (!Number.isInteger(round) || round < 1)
715
- return { error: "round must be a positive integer" };
716
- const inspected = mgr.inspect(loopId, round);
717
- return inspected ?? { error: `round not found: ${loopId}#${round}` };
869
+ async loopforge_replay(mgr, input) {
870
+ const sessionId = String(input.sessionId ?? "");
871
+ const loopId = String(input.loopId ?? "");
872
+ if (!sessionId && !loopId) {
873
+ return { error: "sessionId or loopId is required — pass loopId to replay a loop after a restart (no live session)" };
874
+ }
875
+ // Live session first: it disambiguates re-used loopIds and the session
876
+ // registry is the cheapest lookup.
877
+ if (sessionId) {
878
+ const session = mgr.get(sessionId);
879
+ if (session) {
880
+ const timeline = mgr.replayTimeline(sessionId) ?? [];
881
+ return { sessionId, loopId: session.loopId, timeline };
882
+ }
883
+ // v3.3.1: session not in memory (restart). Fall through to the vault
884
+ // path when a loopId was also provided; otherwise say so plainly.
885
+ if (!loopId) {
886
+ return { error: `session not found: ${sessionId} (process restarted?) — pass loopId to replay from the vault` };
887
+ }
888
+ }
889
+ // Vault-direct replay — the timeline lives in the committed round
890
+ // documents, not in the session registry.
891
+ const timeline = mgr.replayByLoop(loopId);
892
+ if (!timeline) {
893
+ return { error: `no committed rounds found for loop "${loopId}"` };
718
894
  }
719
- const fromSequence = typeof input.fromSequence === "number" ? input.fromSequence : undefined;
720
- const toSequence = typeof input.toSequence === "number" ? input.toSequence : undefined;
721
- const limit = typeof input.limit === "number" ? input.limit : 50;
722
- const result = mgr.replayTimelineByLoop(loopId, fromSequence, toSequence, limit);
723
- return { loopId, view, timeline: result.timeline, nextSequence: result.nextSequence };
895
+ // sessionId omitted on the vault path (its output schema is string-only).
896
+ return { ...(sessionId ? { sessionId } : {}), loopId, timeline };
724
897
  },
725
898
  async loopforge_resume(mgr, input) {
726
899
  const loopId = String(input.loopId ?? "");
727
900
  if (!loopId)
728
- return { error: "loopId is required", decision: "errored" };
901
+ return { error: "loopId is required" };
902
+ try {
903
+ validateLoopId(loopId);
904
+ }
905
+ catch (error) {
906
+ return { error: `invalid loopId: ${error.message}` };
907
+ }
729
908
  // Paused recovery must run first so the persisted status is atomically
730
909
  // changed back to running before a prompt is returned.
731
910
  let result = await mgr.unpause(loopId);
732
911
  if (!result) {
733
- result = await mgr.resume(loopId);
912
+ result = mgr.resume(loopId);
734
913
  }
735
914
  if (!result)
736
- return { error: `no saved session found for loop "${loopId}"`, decision: "errored" };
737
- return finalizeAdvanceOutput(mgr, { ...result });
915
+ return { error: `no saved session found for loop "${loopId}"` };
916
+ return { ...result };
738
917
  },
739
918
  async loopforge_gate_check(mgr, input) {
740
- if (!isRecord(input.action))
741
- return { error: "action descriptor is required" };
742
- return mgr.checkGate(input.action);
919
+ const gateText = String(input.gateText ?? "");
920
+ if (!gateText.trim())
921
+ return { error: "gateText is required" };
922
+ return mgr.checkGate(gateText);
743
923
  },
744
924
  async loopforge_gate_resolve(mgr, input) {
745
- return mgr.resolveGate(String(input.sessionId ?? ""), String(input.gateId ?? ""), isRecord(input.action) ? input.action : {}, input.approved === true, String(input.note ?? ""));
746
- },
747
- async loopforge_audit(mgr, input) {
748
- const loopId = String(input.loopId ?? "").trim();
749
- if (!loopId)
750
- return { error: "loopId is required" };
751
- return mgr.audit(loopId);
925
+ return mgr.resolveGate(String(input.sessionId ?? ""), String(input.gateId ?? ""), input.approved === true, input.note === undefined ? undefined : String(input.note));
752
926
  },
753
927
  };
754
928
  //# sourceMappingURL=tools.js.map