loopforge 3.7.0 → 3.8.1

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 (151) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +171 -157
  3. package/dist/audit.d.ts +53 -1
  4. package/dist/audit.d.ts.map +1 -1
  5. package/dist/audit.js +106 -22
  6. package/dist/audit.js.map +1 -1
  7. package/dist/canonical-state.d.ts +75 -69
  8. package/dist/canonical-state.d.ts.map +1 -1
  9. package/dist/canonical-state.js +276 -168
  10. package/dist/canonical-state.js.map +1 -1
  11. package/dist/cli.d.ts.map +1 -1
  12. package/dist/cli.js +111 -12
  13. package/dist/cli.js.map +1 -1
  14. package/dist/cognitive-facts.d.ts +13 -0
  15. package/dist/cognitive-facts.d.ts.map +1 -1
  16. package/dist/cognitive-facts.js +30 -16
  17. package/dist/cognitive-facts.js.map +1 -1
  18. package/dist/cognitive-governance.d.ts +30 -6
  19. package/dist/cognitive-governance.d.ts.map +1 -1
  20. package/dist/cognitive-governance.js +74 -18
  21. package/dist/cognitive-governance.js.map +1 -1
  22. package/dist/committed-round.d.ts +109 -24
  23. package/dist/committed-round.d.ts.map +1 -1
  24. package/dist/committed-round.js +235 -52
  25. package/dist/committed-round.js.map +1 -1
  26. package/dist/contract-items.d.ts +55 -0
  27. package/dist/contract-items.d.ts.map +1 -0
  28. package/dist/contract-items.js +180 -0
  29. package/dist/contract-items.js.map +1 -0
  30. package/dist/enforcement-gate.d.ts +43 -20
  31. package/dist/enforcement-gate.d.ts.map +1 -1
  32. package/dist/enforcement-gate.js +269 -378
  33. package/dist/enforcement-gate.js.map +1 -1
  34. package/dist/engine.d.ts +11 -6
  35. package/dist/engine.d.ts.map +1 -1
  36. package/dist/engine.js +102 -67
  37. package/dist/engine.js.map +1 -1
  38. package/dist/evidence-claims.d.ts +4 -4
  39. package/dist/evidence-claims.d.ts.map +1 -1
  40. package/dist/evidence-claims.js +16 -14
  41. package/dist/evidence-claims.js.map +1 -1
  42. package/dist/evidence-provider.d.ts +91 -90
  43. package/dist/evidence-provider.d.ts.map +1 -1
  44. package/dist/evidence-provider.js +349 -160
  45. package/dist/evidence-provider.js.map +1 -1
  46. package/dist/explain.d.ts +76 -0
  47. package/dist/explain.d.ts.map +1 -0
  48. package/dist/explain.js +127 -0
  49. package/dist/explain.js.map +1 -0
  50. package/dist/generate-schema.js +250 -53
  51. package/dist/generate-schema.js.map +1 -1
  52. package/dist/index.d.ts +8 -7
  53. package/dist/index.d.ts.map +1 -1
  54. package/dist/index.js +7 -6
  55. package/dist/index.js.map +1 -1
  56. package/dist/loop-compiler.d.ts +41 -40
  57. package/dist/loop-compiler.d.ts.map +1 -1
  58. package/dist/loop-compiler.js +275 -515
  59. package/dist/loop-compiler.js.map +1 -1
  60. package/dist/loop-extras-parser.d.ts +0 -1
  61. package/dist/loop-extras-parser.d.ts.map +1 -1
  62. package/dist/loop-extras-parser.js +0 -4
  63. package/dist/loop-extras-parser.js.map +1 -1
  64. package/dist/loop-projection.d.ts.map +1 -1
  65. package/dist/loop-projection.js +3 -1
  66. package/dist/loop-projection.js.map +1 -1
  67. package/dist/mcp/round-lifecycle.d.ts +40 -21
  68. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  69. package/dist/mcp/round-lifecycle.js +173 -120
  70. package/dist/mcp/round-lifecycle.js.map +1 -1
  71. package/dist/mcp/server.d.ts.map +1 -1
  72. package/dist/mcp/server.js +63 -10
  73. package/dist/mcp/server.js.map +1 -1
  74. package/dist/mcp/session.d.ts +53 -7
  75. package/dist/mcp/session.d.ts.map +1 -1
  76. package/dist/mcp/session.js +240 -139
  77. package/dist/mcp/session.js.map +1 -1
  78. package/dist/mcp/submission-boundary.d.ts +89 -0
  79. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  80. package/dist/mcp/submission-boundary.js +203 -0
  81. package/dist/mcp/submission-boundary.js.map +1 -0
  82. package/dist/mcp/tools.d.ts.map +1 -1
  83. package/dist/mcp/tools.js +377 -125
  84. package/dist/mcp/tools.js.map +1 -1
  85. package/dist/policy-metrics.js +2 -2
  86. package/dist/policy-metrics.js.map +1 -1
  87. package/dist/policy.d.ts +65 -95
  88. package/dist/policy.d.ts.map +1 -1
  89. package/dist/policy.js +151 -39
  90. package/dist/policy.js.map +1 -1
  91. package/dist/prompt-assembler.d.ts +7 -33
  92. package/dist/prompt-assembler.d.ts.map +1 -1
  93. package/dist/prompt-assembler.js +313 -473
  94. package/dist/prompt-assembler.js.map +1 -1
  95. package/dist/prompt-policy.d.ts +1 -4
  96. package/dist/prompt-policy.d.ts.map +1 -1
  97. package/dist/prompt-policy.js +6 -7
  98. package/dist/prompt-policy.js.map +1 -1
  99. package/dist/protocol.d.ts +411 -168
  100. package/dist/protocol.d.ts.map +1 -1
  101. package/dist/protocol.js +23 -69
  102. package/dist/protocol.js.map +1 -1
  103. package/dist/replay.js +2 -2
  104. package/dist/replay.js.map +1 -1
  105. package/dist/round-contract.d.ts +69 -57
  106. package/dist/round-contract.d.ts.map +1 -1
  107. package/dist/round-contract.js +104 -64
  108. package/dist/round-contract.js.map +1 -1
  109. package/dist/round-coordinator.d.ts +55 -16
  110. package/dist/round-coordinator.d.ts.map +1 -1
  111. package/dist/round-coordinator.js +179 -57
  112. package/dist/round-coordinator.js.map +1 -1
  113. package/dist/round-driver.d.ts +7 -5
  114. package/dist/round-driver.d.ts.map +1 -1
  115. package/dist/round-driver.js +2 -1
  116. package/dist/round-driver.js.map +1 -1
  117. package/dist/round-facts.d.ts +96 -0
  118. package/dist/round-facts.d.ts.map +1 -0
  119. package/dist/round-facts.js +216 -0
  120. package/dist/round-facts.js.map +1 -0
  121. package/dist/round-transaction.d.ts +23 -9
  122. package/dist/round-transaction.d.ts.map +1 -1
  123. package/dist/round-transaction.js +99 -22
  124. package/dist/round-transaction.js.map +1 -1
  125. package/dist/self-eval.d.ts +127 -11
  126. package/dist/self-eval.d.ts.map +1 -1
  127. package/dist/self-eval.js +517 -36
  128. package/dist/self-eval.js.map +1 -1
  129. package/dist/subgoal-state.d.ts +77 -0
  130. package/dist/subgoal-state.d.ts.map +1 -0
  131. package/dist/subgoal-state.js +198 -0
  132. package/dist/subgoal-state.js.map +1 -0
  133. package/dist/token-utils.d.ts +52 -19
  134. package/dist/token-utils.d.ts.map +1 -1
  135. package/dist/token-utils.js +83 -72
  136. package/dist/token-utils.js.map +1 -1
  137. package/dist/verification-gate.d.ts +27 -46
  138. package/dist/verification-gate.d.ts.map +1 -1
  139. package/dist/verification-gate.js +308 -557
  140. package/dist/verification-gate.js.map +1 -1
  141. package/dist/version.d.ts +5 -0
  142. package/dist/version.d.ts.map +1 -0
  143. package/dist/version.js +5 -0
  144. package/dist/version.js.map +1 -0
  145. package/dist/workspace.d.ts +9 -0
  146. package/dist/workspace.d.ts.map +1 -1
  147. package/dist/workspace.js +17 -0
  148. package/dist/workspace.js.map +1 -1
  149. package/loop_policy.json +17 -25
  150. package/package.json +7 -7
  151. package/skills/perception/SKILL.md +266 -64
package/dist/mcp/tools.js CHANGED
@@ -5,10 +5,10 @@
5
5
  * status is the unified inspection tool (view=session|loop|all|audit).
6
6
  * Each handler receives SessionManager + parsed input, returns the output object.
7
7
  */
8
- import { buildSelfEvaluation } from "../engine.js";
9
8
  import { validateCoreSelfEvaluation } from "../self-eval.js";
10
9
  import { isRecord } from "../token-utils.js";
11
- import { validateLoopId } from "../policy.js";
10
+ import { getPolicy, validateLoopId } from "../policy.js";
11
+ import { deriveEvidenceCapability } from "../evidence-provider.js";
12
12
  // ═══════════════════════════════════════════════════════════════════════════
13
13
  // Tool schemas (MCP JSON Schema format)
14
14
  // ═══════════════════════════════════════════════════════════════════════════
@@ -30,7 +30,7 @@ const TOOL_BASE_SCHEMAS = [
30
30
  maxRounds: {
31
31
  type: "integer",
32
32
  minimum: 1,
33
- description: "Maximum rounds before auto-stop. Default: 20 from policy.",
33
+ description: "Maximum rounds before auto-stop. Default: 200 from policy.",
34
34
  },
35
35
  domain: {
36
36
  type: "string",
@@ -101,9 +101,10 @@ const TOOL_BASE_SCHEMAS = [
101
101
  emerged_subtasks: {
102
102
  type: "array",
103
103
  items: { type: "string" },
104
- description: "Optional. Sub-problems that surfaced during execution.",
104
+ description: "Optional. Sub-problems that surfaced during execution. The only " +
105
+ "sub-goal creation channel — sg-XXXXXXXX ID literals are rejected here.",
105
106
  },
106
- execution_evidence: {
107
+ execution_report: {
107
108
  type: "object",
108
109
  description: "Optional. Structured record of what actually happened this round.",
109
110
  properties: {
@@ -112,7 +113,7 @@ const TOOL_BASE_SCHEMAS = [
112
113
  items: { type: "string" },
113
114
  description: "Files modified this round.",
114
115
  },
115
- test_results: {
116
+ tests_reported: {
116
117
  type: "object",
117
118
  description: "Test results from this round.",
118
119
  properties: {
@@ -121,15 +122,31 @@ const TOOL_BASE_SCHEMAS = [
121
122
  skipped: { type: "integer", minimum: 0, description: "Number of skipped tests." },
122
123
  },
123
124
  },
124
- success_criteria_met: {
125
+ criterion_claims: {
125
126
  type: "array",
126
- items: { type: "string" },
127
- description: "Success criteria satisfied this round.",
127
+ description: "v3.8: advisory claims about objective criteria. LENIENT — unknown or malformed ids are dropped with a warning, never a rejection.",
128
+ items: {
129
+ type: "object",
130
+ properties: {
131
+ criterion_id: { type: "string", description: "cr-XXXXXXXX criterion id (free text allowed)." },
132
+ outcome: { type: "string", enum: ["met", "remaining"] },
133
+ },
134
+ required: ["criterion_id", "outcome"],
135
+ additionalProperties: false,
136
+ },
128
137
  },
129
- success_criteria_remaining: {
138
+ contract_item_claims: {
130
139
  type: "array",
131
- items: { type: "string" },
132
- description: "Success criteria still outstanding.",
140
+ description: "Claims about items of the ACTIVE contract, cited by the rci-XXXXXXXX id the prompt rendered. LENIENT at declaration: an unknown/malformed id is reported as contract_invalid so you can correct the payload and resubmit the same roundId. A claim never verifies anything by itself — the runtime only marks an item verified when its bound commands are observed passing.",
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"],
148
+ additionalProperties: false,
149
+ },
133
150
  },
134
151
  progress_estimate: {
135
152
  type: "number",
@@ -162,7 +179,10 @@ const TOOL_BASE_SCHEMAS = [
162
179
  },
163
180
  worker_results: {
164
181
  type: "array",
165
- description: "Optional. Results of sub-agent / Worker delegations this round.",
182
+ description: "Optional. Results of sub-agent / Worker delegations this round. " +
183
+ "Each entry must declare an outcome — the single reported fact. " +
184
+ "Entries without a valid outcome are dropped by the runtime. " +
185
+ "Omit or empty when nothing was delegated.",
166
186
  items: {
167
187
  type: "object",
168
188
  properties: {
@@ -170,18 +190,16 @@ const TOOL_BASE_SCHEMAS = [
170
190
  subAgentType: { type: "string" },
171
191
  subTask: { type: "string" },
172
192
  resultSummary: { type: "string" },
173
- success: { type: "boolean" },
174
193
  outcome: {
175
194
  type: "string",
176
195
  enum: ["success", "partial", "failed"],
177
- description: "Optional. Worker outcome; derived from success when absent.",
178
196
  },
179
197
  discoveredConstraints: {
180
198
  type: "array",
181
199
  items: { type: "string" },
182
200
  },
183
201
  },
184
- required: ["agentId", "subAgentType", "subTask", "resultSummary", "success"],
202
+ required: ["agentId", "subTask", "resultSummary", "outcome"],
185
203
  },
186
204
  },
187
205
  compression_checkpoint: {
@@ -192,24 +210,33 @@ const TOOL_BASE_SCHEMAS = [
192
210
  type: "string",
193
211
  description: "Optional. Human-readable label for this checkpoint (e.g. 'core-functions-complete'). Only meaningful when compression_checkpoint is true.",
194
212
  },
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: {
213
+ subgoal_updates: {
200
214
  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.",
215
+ maxItems: 20,
216
+ description: "Optional. Explicit status transitions for EXISTING sub-goals. " +
217
+ "Each entry references an ACTIVE sub-goal ID (sg-XXXXXXXX) from " +
218
+ "the dashboard with a target status. Unknown IDs, done/canceled " +
219
+ "(terminal) references, and illegal migrations are rejected " +
220
+ "before the round advances. done/canceled items must never be " +
221
+ "referenced again — re-open via a NEW emerged_subtasks item.",
222
+ items: {
223
+ type: "object",
224
+ properties: {
225
+ id: {
226
+ type: "string",
227
+ description: "Active sub-goal ID (sg-XXXXXXXX).",
228
+ },
229
+ status: {
230
+ type: "string",
231
+ enum: ["in_progress", "done", "blocked", "canceled"],
232
+ },
233
+ note: {
234
+ type: "string",
235
+ description: "Optional free-text note (≤ 300 chars).",
236
+ },
237
+ },
238
+ required: ["id", "status"],
239
+ },
213
240
  },
214
241
  stop_reason: {
215
242
  type: "string",
@@ -229,6 +256,14 @@ const TOOL_BASE_SCHEMAS = [
229
256
  type: "string",
230
257
  description: "Optional. Flat blocker description, required only when outcome=blocked. Also feeds the user/agent gate classification. Max 500 chars.",
231
258
  },
259
+ gate_ids: {
260
+ type: "array",
261
+ maxItems: 20,
262
+ description: "Optional. gate_opened ids this round's work depended on (from " +
263
+ "loopforge_gate_check). Enforced only when policy.gate.enabled: " +
264
+ "an unapproved cited gate rejects the round.",
265
+ items: { type: "string" },
266
+ },
232
267
  retroactiveClaims: {
233
268
  type: "array",
234
269
  items: {
@@ -242,10 +277,6 @@ const TOOL_BASE_SCHEMAS = [
242
277
  },
243
278
  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
279
  },
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
280
  prompt_requests: {
250
281
  type: "object",
251
282
  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.",
@@ -253,7 +284,7 @@ const TOOL_BASE_SCHEMAS = [
253
284
  emphasize: {
254
285
  type: "array",
255
286
  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.",
287
+ 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.",
257
288
  },
258
289
  confusion_points: {
259
290
  type: "array",
@@ -264,29 +295,49 @@ const TOOL_BASE_SCHEMAS = [
264
295
  },
265
296
  round_contract: {
266
297
  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.",
298
+ description: "Optional. Proposal for the NEXT round's contract (v3.8 item model). Declared here, it becomes the ACTIVE contract (rendered as the Current Task) only after this round commits. It closes when EVERY item is machine-verified (its bound commands observed passing, untampered, with the declared config), or when a round reports outcome=blocked. While it is open, restate it unchanged here — a different proposal is ignored. DECLARATION IS STRICT: an item with no bound command, one naming an unknown/disabled/non-after-capable command, a scope entry leaving the workspace, a malformed reference, or a count over the declared limits returns contract_invalid and the round may be retried with the same roundId.",
268
299
  properties: {
269
300
  work_item: {
270
301
  type: "string",
271
302
  description: "Optional. Round focus, one line. Rendered as the active contract's Current Task first line. Max 200 chars.",
272
303
  },
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: {
304
+ items: {
279
305
  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.",
306
+ 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.",
307
+ items: {
308
+ type: "object",
309
+ properties: {
310
+ description: {
311
+ type: "string",
312
+ description: "What must be true for this item. Max 200 chars.",
313
+ },
314
+ criterion_refs: {
315
+ type: "array",
316
+ items: { type: "string" },
317
+ description: "Optional. cr-XXXXXXXX criterion ids this item is evidence for (free text allowed). Max 20.",
318
+ },
319
+ subgoal_refs: {
320
+ type: "array",
321
+ items: { type: "string" },
322
+ 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.",
323
+ },
324
+ verify_with: {
325
+ type: "array",
326
+ items: { type: "string" },
327
+ 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.",
328
+ },
329
+ },
330
+ required: ["description", "verify_with"],
331
+ additionalProperties: false,
332
+ },
282
333
  },
283
334
  scope: {
284
335
  type: "array",
285
336
  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.",
337
+ 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.",
287
338
  },
288
339
  },
289
- required: ["done_when", "verification_plan", "scope"],
340
+ required: ["items", "scope"],
290
341
  },
291
342
  },
292
343
  },
@@ -310,8 +361,12 @@ const TOOL_BASE_SCHEMAS = [
310
361
  },
311
362
  view: {
312
363
  type: "string",
313
- enum: ["session", "loop", "all", "audit"],
314
- description: "Which view to return. Defaults to session.",
364
+ enum: ["session", "loop", "all", "audit", "explain"],
365
+ description: "Which view to return. Defaults to session. explain (v3.8) is the read-only per-round \"why\" view over committed facts.",
366
+ },
367
+ round: {
368
+ type: "number",
369
+ description: "view=explain only: return that single round's \"why\" view. Omit for the whole timeline.",
315
370
  },
316
371
  },
317
372
  required: [],
@@ -385,21 +440,64 @@ const TOOL_BASE_SCHEMAS = [
385
440
  },
386
441
  {
387
442
  name: "loopforge_gate_check",
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.",
443
+ description: "v3.7.1: Structured preflight for a high-risk action. Classifies a " +
444
+ "GateActionDescriptor as user_required (needs human authorization — " +
445
+ "persists a gate_opened record you must cite via evaluation.gate_ids " +
446
+ "once approved) or agent_allowed (no record, no human needed). " +
447
+ "Never executes the action, never approves it, never advances a round. " +
448
+ "Hidden when policy.gate.enabled=false (direct calls return gate_disabled).",
389
449
  inputSchema: {
390
450
  type: "object",
391
451
  properties: {
392
- gateText: {
393
- type: "string",
394
- description: "The action or blocker text to classify, e.g. 'Deploy to production'.",
452
+ sessionId: { type: "string", description: "Session ID." },
453
+ roundId: { type: "string", description: "RoundId from the latest response." },
454
+ action: {
455
+ type: "object",
456
+ description: "The action the agent plans to perform.",
457
+ properties: {
458
+ description: {
459
+ type: "string",
460
+ description: "What the action does, e.g. 'Deploy to production'.",
461
+ },
462
+ scope: {
463
+ type: "array",
464
+ items: { type: "string" },
465
+ description: "Files / systems / services the action touches.",
466
+ },
467
+ effects: {
468
+ type: "array",
469
+ items: {
470
+ type: "string",
471
+ enum: [
472
+ "workspace_write", "production", "credentials", "data_migration",
473
+ "public_api", "publish", "payment", "external_communication",
474
+ "network",
475
+ ],
476
+ },
477
+ },
478
+ reversibility: {
479
+ type: "string",
480
+ enum: ["reversible", "recoverable", "irreversible", "unknown"],
481
+ },
482
+ authorization: {
483
+ type: "string",
484
+ enum: ["agent_allowed", "user_required", "unknown"],
485
+ },
486
+ },
487
+ required: ["description", "scope", "effects", "reversibility", "authorization"],
395
488
  },
396
489
  },
397
- required: ["gateText"],
490
+ required: ["sessionId", "roundId", "action"],
398
491
  },
399
492
  },
400
493
  {
401
494
  name: "loopforge_gate_resolve",
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).",
495
+ description: "v3.7.1: Record a human decision for a previously opened user gate. " +
496
+ "The caller is still the Agent — LoopForge cannot machine-verify that a " +
497
+ "human is present; the decision is recorded and audited, and the gate's " +
498
+ "approval binds to the canonical action (any change expires it). " +
499
+ "Agent gates are resolved by submitting evidence, never here. " +
500
+ "Hidden when policy.gate.enabled=false (direct calls return gate_disabled).",
403
501
  inputSchema: {
404
502
  type: "object",
405
503
  properties: {
@@ -409,7 +507,7 @@ const TOOL_BASE_SCHEMAS = [
409
507
  },
410
508
  gateId: {
411
509
  type: "string",
412
- description: "Gate id returned by loopforge_gate_check or recorded from a blocked round.",
510
+ description: "Gate id returned by loopforge_gate_check.",
413
511
  },
414
512
  approved: {
415
513
  type: "boolean",
@@ -427,7 +525,8 @@ const TOOL_BASE_SCHEMAS = [
427
525
  const ADVANCE_OUTPUT_SCHEMA = {
428
526
  type: "object",
429
527
  properties: {
430
- error: { type: "string" },
528
+ ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
529
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
431
530
  details: { type: "object", additionalProperties: true },
432
531
  sessionId: { type: "string" },
433
532
  round: { type: "number" },
@@ -459,7 +558,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
459
558
  loopforge_status: {
460
559
  type: "object",
461
560
  properties: {
462
- error: { type: "string" },
561
+ ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
562
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
463
563
  sessionId: { type: "string" },
464
564
  loopId: { type: "string" },
465
565
  round: { type: "number" },
@@ -479,7 +579,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
479
579
  loopforge_stop: {
480
580
  type: "object",
481
581
  properties: {
482
- error: { type: "string" },
582
+ ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
583
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
483
584
  success: { type: "boolean" },
484
585
  roundsCompleted: { type: "number" },
485
586
  successTrajectory: { type: "array", items: { type: "boolean" } },
@@ -489,7 +590,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
489
590
  loopforge_pause: {
490
591
  type: "object",
491
592
  properties: {
492
- error: { type: "string" },
593
+ ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
594
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
493
595
  sessionId: { type: "string" },
494
596
  round: { type: "number" },
495
597
  status: { type: "string" },
@@ -499,7 +601,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
499
601
  loopforge_replay: {
500
602
  type: "object",
501
603
  properties: {
502
- error: { type: "string" },
604
+ ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
605
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
503
606
  sessionId: { type: "string" },
504
607
  loopId: { type: "string" },
505
608
  timeline: { type: "array", items: { type: "object", additionalProperties: true } },
@@ -509,22 +612,25 @@ export const TOOL_OUTPUT_SCHEMAS = {
509
612
  loopforge_gate_check: {
510
613
  type: "object",
511
614
  properties: {
512
- error: { type: "string" },
513
- id: { type: "string" },
514
- kind: { type: "string" },
515
- question: { type: "string" },
615
+ ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
616
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
617
+ sessionId: { type: "string" },
618
+ gateId: { type: "string" },
619
+ risk: { type: "string", enum: ["low", "high", "unknown"] },
620
+ decision: { type: "string", enum: ["agent_allowed", "user_required"] },
621
+ reasonCodes: { type: "array", items: { type: "string" } },
516
622
  blockedScope: { type: "array", items: { type: "string" } },
517
- allowedWork: { type: "array", items: { type: "string" } },
518
- problem: { type: "string" },
623
+ allowedBeforeApproval: { type: "array", items: { type: "string" } },
519
624
  requiredEvidence: { type: "array", items: { type: "string" } },
520
- suggestedResolution: { type: "string" },
625
+ approvalQuestion: { type: "string" },
521
626
  },
522
627
  additionalProperties: true,
523
628
  },
524
629
  loopforge_gate_resolve: {
525
630
  type: "object",
526
631
  properties: {
527
- error: { type: "string" },
632
+ ok: { type: "boolean", description: "v3.8: uniform result envelope — false means the error object describes a rejected call." },
633
+ error: { type: "object", additionalProperties: true, description: "v3.8 ToolError: { code, message, retryable, sessionId?, roundId?, details? }" },
528
634
  sessionId: { type: "string" },
529
635
  loopId: { type: "string" },
530
636
  gateId: { type: "string" },
@@ -705,6 +811,16 @@ export function validateToolOutput(name, output) {
705
811
  return;
706
812
  validateSchema(output, schema, `output.${name}`);
707
813
  }
814
+ /** v3.8: capability warnings for the current policy, merged with the compile's
815
+ * own warnings. Derived through the SAME function `RoundDriver.prepare()`
816
+ * uses, so the start/resume banner and the prepared round cannot tell two
817
+ * different stories about what the runtime can observe. Never persisted. */
818
+ function capabilityWarningList(existing) {
819
+ return [...new Set([
820
+ ...(existing ?? []),
821
+ ...deriveEvidenceCapability(getPolicy()).warnings,
822
+ ])];
823
+ }
708
824
  export const TOOL_HANDLERS = {
709
825
  async loopforge_start(mgr, input) {
710
826
  const startInput = {
@@ -718,7 +834,10 @@ export const TOOL_HANDLERS = {
718
834
  : undefined,
719
835
  };
720
836
  if (!startInput.task.trim()) {
721
- return { error: "task is required and must be non-empty" };
837
+ return {
838
+ error: "invalid_argument",
839
+ errorMessage: "task is required and must be non-empty",
840
+ };
722
841
  }
723
842
  // v2.14: entry-point validation — a bad maxRounds previously passed
724
843
  // through and stopped the loop on the first round; an invalid loopId
@@ -726,61 +845,86 @@ export const TOOL_HANDLERS = {
726
845
  // argument error.
727
846
  if (startInput.maxRounds !== undefined &&
728
847
  (!Number.isInteger(startInput.maxRounds) || startInput.maxRounds < 1)) {
729
- return { error: "maxRounds must be a positive integer" };
848
+ return {
849
+ error: "invalid_argument",
850
+ errorMessage: "maxRounds must be a positive integer",
851
+ };
730
852
  }
731
853
  if (startInput.loopId !== undefined) {
732
854
  try {
733
855
  validateLoopId(startInput.loopId);
734
856
  }
735
857
  catch (error) {
736
- return { error: `invalid loopId: ${error.message}` };
858
+ return {
859
+ error: "invalid_argument",
860
+ errorMessage: `invalid loopId: ${error.message}`,
861
+ };
737
862
  }
738
863
  }
739
- const result = await mgr.create(startInput);
864
+ // A malformed loop_policy.json is a configuration defect, not a bad
865
+ // argument — it must not escape as an opaque JSON-RPC internal error.
866
+ let result;
867
+ try {
868
+ result = await mgr.create(startInput);
869
+ }
870
+ catch (error) {
871
+ return {
872
+ error: "policy_invalid",
873
+ errorMessage: `the runtime policy could not be loaded: ${error.message}`,
874
+ };
875
+ }
740
876
  // v2.14: a same-process duplicate loopId surfaces as a clean argument
741
877
  // error instead of a prompt-less "success" the client cannot act on.
742
878
  if (typeof result.stopReason === "string" &&
743
879
  result.stopReason.startsWith("loop_already_running:")) {
744
- return { error: result.stopDetail ?? "loop already running" };
880
+ return {
881
+ error: "loop_already_running",
882
+ errorMessage: result.stopDetail ?? "loop already running",
883
+ };
745
884
  }
746
- return { ...result };
885
+ // v3.8: the static capability warning — a loop with no provider or no
886
+ // enabled command can still run; its success claims are recorded as
887
+ // insufficient instead of verified, and the agent is told so up front.
888
+ const capabilityWarnings = capabilityWarningList(result.warnings);
889
+ return capabilityWarnings.length > 0
890
+ ? { ...result, warnings: capabilityWarnings }
891
+ : { ...result };
747
892
  },
748
893
  async loopforge_next(mgr, input) {
749
894
  const sessionId = String(input.sessionId ?? "");
750
895
  const output = String(input.output ?? "");
751
896
  const rawEval = input.evaluation;
752
897
  const roundId = input.roundId === undefined ? undefined : String(input.roundId);
753
- if (!sessionId)
754
- return { error: "sessionId is required" };
898
+ if (!sessionId) {
899
+ return { error: "invalid_argument", errorMessage: "sessionId is required" };
900
+ }
755
901
  // v3.0.1: every submission is anchored to the round it reports on. A
756
902
  // missing roundId would silently re-process a stale submission against a
757
903
  // later round — reject it here so the contract is enforced even when the
758
904
  // handler is invoked directly (bypassing schema validation).
759
905
  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
906
  return {
764
- error: "evaluation_invalid",
765
- details: {
766
- missing: ["evaluation"],
767
- invalid: [{ field: "evaluation", expected: "object" }],
768
- },
769
- sessionId,
770
- roundId,
907
+ error: "round_id_required",
908
+ errorMessage: "roundId is required — pass the roundId from the most recent " +
909
+ "loopforge_start / loopforge_next / loopforge_resume / loopforge_status response",
771
910
  };
772
911
  }
773
- const validation = validateCoreSelfEvaluation(rawEval);
774
- if (validation.missing.length > 0 || validation.invalid.length > 0) {
912
+ // v3.8.1: the submission boundary belongs to the RUNTIME, not to this
913
+ // transport. `mgr.advance` applies all of it — object shape, core fields,
914
+ // subgoal_updates structure and references, and the strict Round Contract
915
+ // declaration/claims boundary — before touching any state, and reports a
916
+ // refusal through `submissionError`. This handler only maps that onto the
917
+ // tool envelope, so a non-MCP caller gets exactly the same strictness.
918
+ const result = await mgr.advance(sessionId, output, rawEval, roundId);
919
+ if (result.submissionError) {
775
920
  return {
776
- error: "evaluation_invalid",
777
- details: validation,
921
+ error: result.submissionError.code,
922
+ errorMessage: result.stopDetail ?? "the submission was rejected",
923
+ details: result.submissionError.details,
778
924
  sessionId,
779
925
  roundId,
780
926
  };
781
927
  }
782
- const preExtractedEval = buildSelfEvaluation(rawEval);
783
- const result = await mgr.advance(sessionId, output, preExtractedEval, roundId);
784
928
  // v2.12: Typed projection — runtime facts for the main agent, attached
785
929
  // on every accepted/continued round (not on errors).
786
930
  let projection = null;
@@ -791,7 +935,8 @@ export const TOOL_HANDLERS = {
791
935
  return projection ? { ...result, projection } : { ...result };
792
936
  },
793
937
  async loopforge_status(mgr, input) {
794
- const view = input.view === "loop" || input.view === "all" || input.view === "audit"
938
+ const view = input.view === "loop" || input.view === "all" ||
939
+ input.view === "audit" || input.view === "explain"
795
940
  ? input.view
796
941
  : "session";
797
942
  const sessionId = String(input.sessionId ?? "");
@@ -802,25 +947,55 @@ export const TOOL_HANDLERS = {
802
947
  }
803
948
  // v2.12: view=audit — read-only verification audit.
804
949
  if (view === "audit") {
805
- if (!loopId)
806
- return { error: "loopId is required for view=audit" };
950
+ if (!loopId) {
951
+ return { error: "invalid_argument", errorMessage: "loopId is required for view=audit" };
952
+ }
807
953
  const audit = mgr.getAudit(loopId);
808
- if (!audit)
809
- return { error: `no audit data found for loop "${loopId}"` };
954
+ if (!audit) {
955
+ return {
956
+ error: "state_unavailable",
957
+ errorMessage: `no audit data found for loop "${loopId}"`,
958
+ sessionId,
959
+ };
960
+ }
810
961
  return audit;
811
962
  }
963
+ // v3.8: view=explain — the read-only per-round "why" view.
964
+ if (view === "explain") {
965
+ if (!loopId) {
966
+ return { error: "invalid_argument", errorMessage: "loopId is required for view=explain" };
967
+ }
968
+ const roundValue = input.round;
969
+ const round = typeof roundValue === "number" && Number.isInteger(roundValue) && roundValue >= 1
970
+ ? roundValue
971
+ : undefined;
972
+ return mgr.getExplain(loopId, round);
973
+ }
812
974
  // v2.12: view=loop — loop health (formerly loopforge_health).
813
975
  if (view === "loop") {
814
- if (!loopId)
815
- return { error: "loopId is required for view=loop" };
976
+ if (!loopId) {
977
+ return { error: "invalid_argument", errorMessage: "loopId is required for view=loop" };
978
+ }
816
979
  const health = mgr.getHealth(loopId);
817
- return health ?? { error: `no health data for loop "${loopId}"` };
980
+ return health ?? {
981
+ error: "state_unavailable",
982
+ errorMessage: `no health data for loop "${loopId}"`,
983
+ };
984
+ }
985
+ if (!sessionId) {
986
+ return {
987
+ error: "invalid_argument",
988
+ errorMessage: "sessionId is required for view=session",
989
+ };
818
990
  }
819
- if (!sessionId)
820
- return { error: "sessionId is required for view=session" };
821
991
  const session = mgr.get(sessionId);
822
- if (!session)
823
- return { error: `session not found: ${sessionId}` };
992
+ if (!session) {
993
+ return {
994
+ error: "session_not_found",
995
+ errorMessage: `session not found: ${sessionId} (process restarted?) — pass loopId for view=audit/loop, or loopforge_resume`,
996
+ sessionId,
997
+ };
998
+ }
824
999
  const metrics = session.engine.getMetrics();
825
1000
  return {
826
1001
  sessionId: session.sessionId,
@@ -835,6 +1010,11 @@ export const TOOL_HANDLERS = {
835
1010
  // verification gates derive the same value from the same committed
836
1011
  // evals via the shared round-contract walker).
837
1012
  activeContract: mgr.getActiveContract(sessionId),
1013
+ // v3.8: the ONE capability fact — the static half the compile hashes
1014
+ // (what verification is possible) plus the live half rendered from the
1015
+ // last baseline. `available` is provider-registry state: reported here
1016
+ // and by `doctor`, never hashed.
1017
+ capability: deriveEvidenceCapability(getPolicy(), session.evidenceBaseline ?? []),
838
1018
  projection: mgr.getProjection(sessionId),
839
1019
  metrics: {
840
1020
  vaultWriteErrors: metrics.vaultWriteErrors,
@@ -849,11 +1029,17 @@ export const TOOL_HANDLERS = {
849
1029
  },
850
1030
  async loopforge_stop(mgr, input) {
851
1031
  const sessionId = String(input.sessionId ?? "");
852
- if (!sessionId)
853
- return { error: "sessionId is required" };
1032
+ if (!sessionId) {
1033
+ return { error: "invalid_argument", errorMessage: "sessionId is required" };
1034
+ }
854
1035
  const session = mgr.get(sessionId);
855
- if (!session)
856
- return { error: `session not found: ${sessionId}` };
1036
+ if (!session) {
1037
+ return {
1038
+ error: "session_not_found",
1039
+ errorMessage: `session not found: ${sessionId}`,
1040
+ sessionId,
1041
+ };
1042
+ }
857
1043
  const roundsCompleted = session.currentRound;
858
1044
  const successTrajectory = [...session.successTrajectory];
859
1045
  mgr.delete(sessionId);
@@ -861,8 +1047,9 @@ export const TOOL_HANDLERS = {
861
1047
  },
862
1048
  async loopforge_pause(mgr, input) {
863
1049
  const sessionId = String(input.sessionId ?? "");
864
- if (!sessionId)
865
- return { error: "sessionId is required" };
1050
+ if (!sessionId) {
1051
+ return { error: "invalid_argument", errorMessage: "sessionId is required" };
1052
+ }
866
1053
  const result = mgr.pause(sessionId);
867
1054
  return { ...result };
868
1055
  },
@@ -870,7 +1057,11 @@ export const TOOL_HANDLERS = {
870
1057
  const sessionId = String(input.sessionId ?? "");
871
1058
  const loopId = String(input.loopId ?? "");
872
1059
  if (!sessionId && !loopId) {
873
- return { error: "sessionId or loopId is required — pass loopId to replay a loop after a restart (no live session)" };
1060
+ return {
1061
+ error: "invalid_argument",
1062
+ errorMessage: "sessionId or loopId is required — pass loopId to replay a loop " +
1063
+ "after a restart (no live session)",
1064
+ };
874
1065
  }
875
1066
  // Live session first: it disambiguates re-used loopIds and the session
876
1067
  // registry is the cheapest lookup.
@@ -883,27 +1074,39 @@ export const TOOL_HANDLERS = {
883
1074
  // v3.3.1: session not in memory (restart). Fall through to the vault
884
1075
  // path when a loopId was also provided; otherwise say so plainly.
885
1076
  if (!loopId) {
886
- return { error: `session not found: ${sessionId} (process restarted?) — pass loopId to replay from the vault` };
1077
+ return {
1078
+ error: "session_not_found",
1079
+ errorMessage: `session not found: ${sessionId} (process restarted?) — ` +
1080
+ "pass loopId to replay from the vault",
1081
+ sessionId,
1082
+ };
887
1083
  }
888
1084
  }
889
1085
  // Vault-direct replay — the timeline lives in the committed round
890
1086
  // documents, not in the session registry.
891
1087
  const timeline = mgr.replayByLoop(loopId);
892
1088
  if (!timeline) {
893
- return { error: `no committed rounds found for loop "${loopId}"` };
1089
+ return {
1090
+ error: "state_unavailable",
1091
+ errorMessage: `no committed rounds found for loop "${loopId}"`,
1092
+ };
894
1093
  }
895
1094
  // sessionId omitted on the vault path (its output schema is string-only).
896
1095
  return { ...(sessionId ? { sessionId } : {}), loopId, timeline };
897
1096
  },
898
1097
  async loopforge_resume(mgr, input) {
899
1098
  const loopId = String(input.loopId ?? "");
900
- if (!loopId)
901
- return { error: "loopId is required" };
1099
+ if (!loopId) {
1100
+ return { error: "invalid_argument", errorMessage: "loopId is required" };
1101
+ }
902
1102
  try {
903
1103
  validateLoopId(loopId);
904
1104
  }
905
1105
  catch (error) {
906
- return { error: `invalid loopId: ${error.message}` };
1106
+ return {
1107
+ error: "invalid_argument",
1108
+ errorMessage: `invalid loopId: ${error.message}`,
1109
+ };
907
1110
  }
908
1111
  // Paused recovery must run first so the persisted status is atomically
909
1112
  // changed back to running before a prompt is returned.
@@ -911,17 +1114,66 @@ export const TOOL_HANDLERS = {
911
1114
  if (!result) {
912
1115
  result = await mgr.resume(loopId);
913
1116
  }
914
- if (!result)
915
- return { error: `no saved session found for loop "${loopId}"` };
1117
+ if (!result) {
1118
+ return {
1119
+ error: "state_unavailable",
1120
+ errorMessage: `no saved session found for loop "${loopId}"`,
1121
+ };
1122
+ }
916
1123
  return { ...result };
917
1124
  },
918
1125
  async loopforge_gate_check(mgr, input) {
919
- const gateText = String(input.gateText ?? "");
920
- if (!gateText.trim())
921
- return { error: "gateText is required" };
922
- return mgr.checkGate(gateText);
1126
+ // v3.7.1: hidden when disabled — direct calls get a stable error.
1127
+ if (!getPolicy().gate.enabled)
1128
+ return { error: "gate_disabled" };
1129
+ const sessionId = String(input.sessionId ?? "");
1130
+ const roundId = String(input.roundId ?? "");
1131
+ const action = input.action;
1132
+ if (!sessionId) {
1133
+ return { error: "invalid_argument", errorMessage: "sessionId is required" };
1134
+ }
1135
+ if (!roundId) {
1136
+ return {
1137
+ error: "round_id_required",
1138
+ errorMessage: "roundId is required — pass the roundId from the latest response",
1139
+ sessionId,
1140
+ };
1141
+ }
1142
+ if (!action || !isRecord(action)) {
1143
+ return { error: "invalid_argument", errorMessage: "action is required", sessionId, roundId };
1144
+ }
1145
+ const descriptor = {
1146
+ description: String(action.description ?? ""),
1147
+ scope: Array.isArray(action.scope)
1148
+ ? action.scope.filter((v) => typeof v === "string")
1149
+ : [],
1150
+ effects: Array.isArray(action.effects)
1151
+ ? action.effects.filter((v) => typeof v === "string")
1152
+ .filter((_v) => true)
1153
+ : [],
1154
+ reversibility: (["reversible", "recoverable", "irreversible", "unknown"]
1155
+ .includes(String(action.reversibility))
1156
+ ? String(action.reversibility)
1157
+ : "unknown"),
1158
+ authorization: (["agent_allowed", "user_required", "unknown"]
1159
+ .includes(String(action.authorization))
1160
+ ? String(action.authorization)
1161
+ : "unknown"),
1162
+ };
1163
+ if (!descriptor.description.trim()) {
1164
+ return {
1165
+ error: "invalid_argument",
1166
+ errorMessage: "action.description is required",
1167
+ sessionId,
1168
+ roundId,
1169
+ };
1170
+ }
1171
+ return mgr.checkGate(sessionId, roundId, descriptor);
923
1172
  },
924
1173
  async loopforge_gate_resolve(mgr, input) {
1174
+ // v3.7.1: hidden when disabled — direct calls get a stable error.
1175
+ if (!getPolicy().gate.enabled)
1176
+ return { error: "gate_disabled" };
925
1177
  return mgr.resolveGate(String(input.sessionId ?? ""), String(input.gateId ?? ""), input.approved === true, input.note === undefined ? undefined : String(input.note));
926
1178
  },
927
1179
  };