loopforge 3.7.1 → 3.8.2

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 (144) hide show
  1. package/README.md +84 -130
  2. package/dist/audit.d.ts +53 -1
  3. package/dist/audit.d.ts.map +1 -1
  4. package/dist/audit.js +106 -22
  5. package/dist/audit.js.map +1 -1
  6. package/dist/canonical-state.d.ts +52 -67
  7. package/dist/canonical-state.d.ts.map +1 -1
  8. package/dist/canonical-state.js +134 -147
  9. package/dist/canonical-state.js.map +1 -1
  10. package/dist/cli.d.ts.map +1 -1
  11. package/dist/cli.js +103 -4
  12. package/dist/cli.js.map +1 -1
  13. package/dist/cognitive-facts.d.ts +13 -0
  14. package/dist/cognitive-facts.d.ts.map +1 -1
  15. package/dist/cognitive-facts.js +28 -13
  16. package/dist/cognitive-facts.js.map +1 -1
  17. package/dist/committed-round.d.ts +103 -21
  18. package/dist/committed-round.d.ts.map +1 -1
  19. package/dist/committed-round.js +188 -45
  20. package/dist/committed-round.js.map +1 -1
  21. package/dist/contract-items.d.ts +55 -0
  22. package/dist/contract-items.d.ts.map +1 -0
  23. package/dist/contract-items.js +180 -0
  24. package/dist/contract-items.js.map +1 -0
  25. package/dist/enforcement-gate.d.ts +20 -2
  26. package/dist/enforcement-gate.d.ts.map +1 -1
  27. package/dist/enforcement-gate.js +197 -386
  28. package/dist/enforcement-gate.js.map +1 -1
  29. package/dist/engine.d.ts +1 -1
  30. package/dist/engine.d.ts.map +1 -1
  31. package/dist/engine.js +53 -38
  32. package/dist/engine.js.map +1 -1
  33. package/dist/evidence-claims.d.ts +4 -4
  34. package/dist/evidence-claims.d.ts.map +1 -1
  35. package/dist/evidence-claims.js +16 -14
  36. package/dist/evidence-claims.js.map +1 -1
  37. package/dist/evidence-provider.d.ts +85 -89
  38. package/dist/evidence-provider.d.ts.map +1 -1
  39. package/dist/evidence-provider.js +328 -184
  40. package/dist/evidence-provider.js.map +1 -1
  41. package/dist/explain.d.ts +76 -0
  42. package/dist/explain.d.ts.map +1 -0
  43. package/dist/explain.js +127 -0
  44. package/dist/explain.js.map +1 -0
  45. package/dist/generate-schema.js +152 -16
  46. package/dist/generate-schema.js.map +1 -1
  47. package/dist/index.d.ts +6 -6
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +5 -5
  50. package/dist/index.js.map +1 -1
  51. package/dist/loop-compiler.d.ts +40 -53
  52. package/dist/loop-compiler.d.ts.map +1 -1
  53. package/dist/loop-compiler.js +229 -553
  54. package/dist/loop-compiler.js.map +1 -1
  55. package/dist/loop-extras-parser.d.ts +0 -1
  56. package/dist/loop-extras-parser.d.ts.map +1 -1
  57. package/dist/loop-extras-parser.js +0 -4
  58. package/dist/loop-extras-parser.js.map +1 -1
  59. package/dist/loop-projection.d.ts.map +1 -1
  60. package/dist/loop-projection.js +3 -1
  61. package/dist/loop-projection.js.map +1 -1
  62. package/dist/mcp/round-lifecycle.d.ts +30 -21
  63. package/dist/mcp/round-lifecycle.d.ts.map +1 -1
  64. package/dist/mcp/round-lifecycle.js +47 -103
  65. package/dist/mcp/round-lifecycle.js.map +1 -1
  66. package/dist/mcp/server.d.ts.map +1 -1
  67. package/dist/mcp/server.js +51 -7
  68. package/dist/mcp/server.js.map +1 -1
  69. package/dist/mcp/session.d.ts +39 -23
  70. package/dist/mcp/session.d.ts.map +1 -1
  71. package/dist/mcp/session.js +148 -163
  72. package/dist/mcp/session.js.map +1 -1
  73. package/dist/mcp/submission-boundary.d.ts +89 -0
  74. package/dist/mcp/submission-boundary.d.ts.map +1 -0
  75. package/dist/mcp/submission-boundary.js +203 -0
  76. package/dist/mcp/submission-boundary.js.map +1 -0
  77. package/dist/mcp/tools.d.ts.map +1 -1
  78. package/dist/mcp/tools.js +249 -128
  79. package/dist/mcp/tools.js.map +1 -1
  80. package/dist/policy-metrics.js +2 -2
  81. package/dist/policy-metrics.js.map +1 -1
  82. package/dist/policy.d.ts +55 -93
  83. package/dist/policy.d.ts.map +1 -1
  84. package/dist/policy.js +149 -38
  85. package/dist/policy.js.map +1 -1
  86. package/dist/prompt-assembler.d.ts +1 -32
  87. package/dist/prompt-assembler.d.ts.map +1 -1
  88. package/dist/prompt-assembler.js +282 -435
  89. package/dist/prompt-assembler.js.map +1 -1
  90. package/dist/prompt-policy.d.ts +1 -4
  91. package/dist/prompt-policy.d.ts.map +1 -1
  92. package/dist/prompt-policy.js +6 -7
  93. package/dist/prompt-policy.js.map +1 -1
  94. package/dist/protocol.d.ts +365 -142
  95. package/dist/protocol.d.ts.map +1 -1
  96. package/dist/protocol.js +21 -66
  97. package/dist/protocol.js.map +1 -1
  98. package/dist/replay.js +2 -2
  99. package/dist/replay.js.map +1 -1
  100. package/dist/round-contract.d.ts +69 -57
  101. package/dist/round-contract.d.ts.map +1 -1
  102. package/dist/round-contract.js +104 -64
  103. package/dist/round-contract.js.map +1 -1
  104. package/dist/round-coordinator.d.ts +36 -16
  105. package/dist/round-coordinator.d.ts.map +1 -1
  106. package/dist/round-coordinator.js +166 -102
  107. package/dist/round-coordinator.js.map +1 -1
  108. package/dist/round-driver.d.ts +3 -5
  109. package/dist/round-driver.d.ts.map +1 -1
  110. package/dist/round-driver.js +0 -1
  111. package/dist/round-driver.js.map +1 -1
  112. package/dist/round-facts.d.ts +96 -0
  113. package/dist/round-facts.d.ts.map +1 -0
  114. package/dist/round-facts.js +216 -0
  115. package/dist/round-facts.js.map +1 -0
  116. package/dist/round-transaction.d.ts +19 -9
  117. package/dist/round-transaction.d.ts.map +1 -1
  118. package/dist/round-transaction.js +90 -22
  119. package/dist/round-transaction.js.map +1 -1
  120. package/dist/self-eval.d.ts +87 -9
  121. package/dist/self-eval.d.ts.map +1 -1
  122. package/dist/self-eval.js +396 -27
  123. package/dist/self-eval.js.map +1 -1
  124. package/dist/subgoal-state.d.ts +77 -0
  125. package/dist/subgoal-state.d.ts.map +1 -0
  126. package/dist/subgoal-state.js +198 -0
  127. package/dist/subgoal-state.js.map +1 -0
  128. package/dist/token-utils.d.ts +52 -19
  129. package/dist/token-utils.d.ts.map +1 -1
  130. package/dist/token-utils.js +83 -72
  131. package/dist/token-utils.js.map +1 -1
  132. package/dist/verification-gate.d.ts +14 -31
  133. package/dist/verification-gate.d.ts.map +1 -1
  134. package/dist/verification-gate.js +178 -496
  135. package/dist/verification-gate.js.map +1 -1
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.js +1 -1
  138. package/dist/workspace.d.ts +9 -0
  139. package/dist/workspace.d.ts.map +1 -1
  140. package/dist/workspace.js +17 -0
  141. package/dist/workspace.js.map +1 -1
  142. package/loop_policy.json +59 -83
  143. package/package.json +67 -67
  144. package/skills/perception/SKILL.md +218 -204
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
- import { parseSubGoalUpdates, validateCoreSelfEvaluation, validateSubGoalUpdatesShape, } from "../self-eval.js";
8
+ import { validateCoreSelfEvaluation } from "../self-eval.js";
10
9
  import { isRecord } from "../token-utils.js";
11
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
  // ═══════════════════════════════════════════════════════════════════════════
@@ -104,7 +104,7 @@ const TOOL_BASE_SCHEMAS = [
104
104
  description: "Optional. Sub-problems that surfaced during execution. The only " +
105
105
  "sub-goal creation channel — sg-XXXXXXXX ID literals are rejected here.",
106
106
  },
107
- execution_evidence: {
107
+ execution_report: {
108
108
  type: "object",
109
109
  description: "Optional. Structured record of what actually happened this round.",
110
110
  properties: {
@@ -113,7 +113,7 @@ const TOOL_BASE_SCHEMAS = [
113
113
  items: { type: "string" },
114
114
  description: "Files modified this round.",
115
115
  },
116
- test_results: {
116
+ tests_reported: {
117
117
  type: "object",
118
118
  description: "Test results from this round.",
119
119
  properties: {
@@ -122,15 +122,31 @@ const TOOL_BASE_SCHEMAS = [
122
122
  skipped: { type: "integer", minimum: 0, description: "Number of skipped tests." },
123
123
  },
124
124
  },
125
- success_criteria_met: {
125
+ criterion_claims: {
126
126
  type: "array",
127
- items: { type: "string" },
128
- 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
+ },
129
137
  },
130
- success_criteria_remaining: {
138
+ contract_item_claims: {
131
139
  type: "array",
132
- items: { type: "string" },
133
- 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
+ },
134
150
  },
135
151
  progress_estimate: {
136
152
  type: "number",
@@ -194,10 +210,6 @@ const TOOL_BASE_SCHEMAS = [
194
210
  type: "string",
195
211
  description: "Optional. Human-readable label for this checkpoint (e.g. 'core-functions-complete'). Only meaningful when compression_checkpoint is true.",
196
212
  },
197
- next_action: {
198
- type: "string",
199
- 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.",
200
- },
201
213
  subgoal_updates: {
202
214
  type: "array",
203
215
  maxItems: 20,
@@ -265,10 +277,6 @@ const TOOL_BASE_SCHEMAS = [
265
277
  },
266
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.",
267
279
  },
268
- drift_clarification: {
269
- type: "string",
270
- 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.",
271
- },
272
280
  prompt_requests: {
273
281
  type: "object",
274
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.",
@@ -276,7 +284,7 @@ const TOOL_BASE_SCHEMAS = [
276
284
  emphasize: {
277
285
  type: "array",
278
286
  items: { type: "string" },
279
- 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.",
280
288
  },
281
289
  confusion_points: {
282
290
  type: "array",
@@ -287,29 +295,49 @@ const TOOL_BASE_SCHEMAS = [
287
295
  },
288
296
  round_contract: {
289
297
  type: "object",
290
- 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.",
291
299
  properties: {
292
300
  work_item: {
293
301
  type: "string",
294
302
  description: "Optional. Round focus, one line. Rendered as the active contract's Current Task first line. Max 200 chars.",
295
303
  },
296
- done_when: {
297
- type: "array",
298
- items: { type: "string" },
299
- 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.",
300
- },
301
- verification_plan: {
304
+ items: {
302
305
  type: "array",
303
- items: { type: "string" },
304
- 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
+ },
305
333
  },
306
334
  scope: {
307
335
  type: "array",
308
336
  items: { type: "string" },
309
- 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.",
310
338
  },
311
339
  },
312
- required: ["done_when", "verification_plan", "scope"],
340
+ required: ["items", "scope"],
313
341
  },
314
342
  },
315
343
  },
@@ -333,8 +361,12 @@ const TOOL_BASE_SCHEMAS = [
333
361
  },
334
362
  view: {
335
363
  type: "string",
336
- enum: ["session", "loop", "all", "audit"],
337
- 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.",
338
370
  },
339
371
  },
340
372
  required: [],
@@ -493,7 +525,8 @@ const TOOL_BASE_SCHEMAS = [
493
525
  const ADVANCE_OUTPUT_SCHEMA = {
494
526
  type: "object",
495
527
  properties: {
496
- 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? }" },
497
530
  details: { type: "object", additionalProperties: true },
498
531
  sessionId: { type: "string" },
499
532
  round: { type: "number" },
@@ -525,7 +558,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
525
558
  loopforge_status: {
526
559
  type: "object",
527
560
  properties: {
528
- 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? }" },
529
563
  sessionId: { type: "string" },
530
564
  loopId: { type: "string" },
531
565
  round: { type: "number" },
@@ -545,7 +579,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
545
579
  loopforge_stop: {
546
580
  type: "object",
547
581
  properties: {
548
- 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? }" },
549
584
  success: { type: "boolean" },
550
585
  roundsCompleted: { type: "number" },
551
586
  successTrajectory: { type: "array", items: { type: "boolean" } },
@@ -555,7 +590,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
555
590
  loopforge_pause: {
556
591
  type: "object",
557
592
  properties: {
558
- 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? }" },
559
595
  sessionId: { type: "string" },
560
596
  round: { type: "number" },
561
597
  status: { type: "string" },
@@ -565,7 +601,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
565
601
  loopforge_replay: {
566
602
  type: "object",
567
603
  properties: {
568
- 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? }" },
569
606
  sessionId: { type: "string" },
570
607
  loopId: { type: "string" },
571
608
  timeline: { type: "array", items: { type: "object", additionalProperties: true } },
@@ -575,7 +612,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
575
612
  loopforge_gate_check: {
576
613
  type: "object",
577
614
  properties: {
578
- error: { 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? }" },
579
617
  sessionId: { type: "string" },
580
618
  gateId: { type: "string" },
581
619
  risk: { type: "string", enum: ["low", "high", "unknown"] },
@@ -591,7 +629,8 @@ export const TOOL_OUTPUT_SCHEMAS = {
591
629
  loopforge_gate_resolve: {
592
630
  type: "object",
593
631
  properties: {
594
- 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? }" },
595
634
  sessionId: { type: "string" },
596
635
  loopId: { type: "string" },
597
636
  gateId: { type: "string" },
@@ -772,6 +811,16 @@ export function validateToolOutput(name, output) {
772
811
  return;
773
812
  validateSchema(output, schema, `output.${name}`);
774
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
+ }
775
824
  export const TOOL_HANDLERS = {
776
825
  async loopforge_start(mgr, input) {
777
826
  const startInput = {
@@ -785,7 +834,10 @@ export const TOOL_HANDLERS = {
785
834
  : undefined,
786
835
  };
787
836
  if (!startInput.task.trim()) {
788
- 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
+ };
789
841
  }
790
842
  // v2.14: entry-point validation — a bad maxRounds previously passed
791
843
  // through and stopped the loop on the first round; an invalid loopId
@@ -793,92 +845,86 @@ export const TOOL_HANDLERS = {
793
845
  // argument error.
794
846
  if (startInput.maxRounds !== undefined &&
795
847
  (!Number.isInteger(startInput.maxRounds) || startInput.maxRounds < 1)) {
796
- return { error: "maxRounds must be a positive integer" };
848
+ return {
849
+ error: "invalid_argument",
850
+ errorMessage: "maxRounds must be a positive integer",
851
+ };
797
852
  }
798
853
  if (startInput.loopId !== undefined) {
799
854
  try {
800
855
  validateLoopId(startInput.loopId);
801
856
  }
802
857
  catch (error) {
803
- return { error: `invalid loopId: ${error.message}` };
858
+ return {
859
+ error: "invalid_argument",
860
+ errorMessage: `invalid loopId: ${error.message}`,
861
+ };
804
862
  }
805
863
  }
806
- 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
+ }
807
876
  // v2.14: a same-process duplicate loopId surfaces as a clean argument
808
877
  // error instead of a prompt-less "success" the client cannot act on.
809
878
  if (typeof result.stopReason === "string" &&
810
879
  result.stopReason.startsWith("loop_already_running:")) {
811
- return { error: result.stopDetail ?? "loop already running" };
880
+ return {
881
+ error: "loop_already_running",
882
+ errorMessage: result.stopDetail ?? "loop already running",
883
+ };
812
884
  }
813
- 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 };
814
892
  },
815
893
  async loopforge_next(mgr, input) {
816
894
  const sessionId = String(input.sessionId ?? "");
817
895
  const output = String(input.output ?? "");
818
896
  const rawEval = input.evaluation;
819
897
  const roundId = input.roundId === undefined ? undefined : String(input.roundId);
820
- if (!sessionId)
821
- return { error: "sessionId is required" };
898
+ if (!sessionId) {
899
+ return { error: "invalid_argument", errorMessage: "sessionId is required" };
900
+ }
822
901
  // v3.0.1: every submission is anchored to the round it reports on. A
823
902
  // missing roundId would silently re-process a stale submission against a
824
903
  // later round — reject it here so the contract is enforced even when the
825
904
  // handler is invoked directly (bypassing schema validation).
826
905
  if (!roundId || !roundId.trim()) {
827
- return { error: "roundId is required — pass the roundId from the most recent loopforge_start / loopforge_next / loopforge_resume / loopforge_status response" };
828
- }
829
- if (!rawEval || typeof rawEval !== "object" || Array.isArray(rawEval)) {
830
906
  return {
831
- error: "evaluation_invalid",
832
- details: {
833
- missing: ["evaluation"],
834
- invalid: [{ field: "evaluation", expected: "object" }],
835
- },
836
- sessionId,
837
- 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",
838
910
  };
839
911
  }
840
- const validation = validateCoreSelfEvaluation(rawEval);
841
- 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) {
842
920
  return {
843
- error: "evaluation_invalid",
844
- details: validation,
921
+ error: result.submissionError.code,
922
+ errorMessage: result.stopDetail ?? "the submission was rejected",
923
+ details: result.submissionError.details,
845
924
  sessionId,
846
925
  roundId,
847
926
  };
848
927
  }
849
- // v3.7.1: subgoal_updates is a strict structural boundary. Shape errors
850
- // (bad entries, sg- literals in the creation channel) and referential
851
- // errors (unknown / terminal / illegal transitions, checked against the
852
- // compiled sub-goal set) return evaluation_invalid BEFORE advance — no
853
- // state change, no gates, no rejection counters; retry same roundId.
854
- const subgoalErrors = validateSubGoalUpdatesShape(rawEval);
855
- if (subgoalErrors.length > 0) {
856
- return {
857
- error: "evaluation_invalid",
858
- details: { missing: [], invalid: [], subgoal_errors: subgoalErrors },
859
- sessionId,
860
- roundId,
861
- };
862
- }
863
- const updates = parseSubGoalUpdates(rawEval.subgoal_updates);
864
- if (updates.length > 0) {
865
- const emerged = Array.isArray(rawEval.emerged_subtasks)
866
- ? rawEval.emerged_subtasks
867
- .filter((v) => typeof v === "string")
868
- .map((s) => s.slice(0, 500))
869
- : [];
870
- const referentialErrors = mgr.preflightSubGoalUpdates(sessionId, roundId, updates, emerged);
871
- if (referentialErrors.length > 0) {
872
- return {
873
- error: "evaluation_invalid",
874
- details: { missing: [], invalid: [], subgoal_errors: referentialErrors },
875
- sessionId,
876
- roundId,
877
- };
878
- }
879
- }
880
- const preExtractedEval = buildSelfEvaluation(rawEval);
881
- const result = await mgr.advance(sessionId, output, preExtractedEval, roundId);
882
928
  // v2.12: Typed projection — runtime facts for the main agent, attached
883
929
  // on every accepted/continued round (not on errors).
884
930
  let projection = null;
@@ -889,7 +935,8 @@ export const TOOL_HANDLERS = {
889
935
  return projection ? { ...result, projection } : { ...result };
890
936
  },
891
937
  async loopforge_status(mgr, input) {
892
- 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"
893
940
  ? input.view
894
941
  : "session";
895
942
  const sessionId = String(input.sessionId ?? "");
@@ -900,25 +947,55 @@ export const TOOL_HANDLERS = {
900
947
  }
901
948
  // v2.12: view=audit — read-only verification audit.
902
949
  if (view === "audit") {
903
- if (!loopId)
904
- return { error: "loopId is required for view=audit" };
950
+ if (!loopId) {
951
+ return { error: "invalid_argument", errorMessage: "loopId is required for view=audit" };
952
+ }
905
953
  const audit = mgr.getAudit(loopId);
906
- if (!audit)
907
- 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
+ }
908
961
  return audit;
909
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
+ }
910
974
  // v2.12: view=loop — loop health (formerly loopforge_health).
911
975
  if (view === "loop") {
912
- if (!loopId)
913
- return { error: "loopId is required for view=loop" };
976
+ if (!loopId) {
977
+ return { error: "invalid_argument", errorMessage: "loopId is required for view=loop" };
978
+ }
914
979
  const health = mgr.getHealth(loopId);
915
- 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
+ };
916
990
  }
917
- if (!sessionId)
918
- return { error: "sessionId is required for view=session" };
919
991
  const session = mgr.get(sessionId);
920
- if (!session)
921
- 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
+ }
922
999
  const metrics = session.engine.getMetrics();
923
1000
  return {
924
1001
  sessionId: session.sessionId,
@@ -933,6 +1010,11 @@ export const TOOL_HANDLERS = {
933
1010
  // verification gates derive the same value from the same committed
934
1011
  // evals via the shared round-contract walker).
935
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 ?? []),
936
1018
  projection: mgr.getProjection(sessionId),
937
1019
  metrics: {
938
1020
  vaultWriteErrors: metrics.vaultWriteErrors,
@@ -947,11 +1029,17 @@ export const TOOL_HANDLERS = {
947
1029
  },
948
1030
  async loopforge_stop(mgr, input) {
949
1031
  const sessionId = String(input.sessionId ?? "");
950
- if (!sessionId)
951
- return { error: "sessionId is required" };
1032
+ if (!sessionId) {
1033
+ return { error: "invalid_argument", errorMessage: "sessionId is required" };
1034
+ }
952
1035
  const session = mgr.get(sessionId);
953
- if (!session)
954
- 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
+ }
955
1043
  const roundsCompleted = session.currentRound;
956
1044
  const successTrajectory = [...session.successTrajectory];
957
1045
  mgr.delete(sessionId);
@@ -959,8 +1047,9 @@ export const TOOL_HANDLERS = {
959
1047
  },
960
1048
  async loopforge_pause(mgr, input) {
961
1049
  const sessionId = String(input.sessionId ?? "");
962
- if (!sessionId)
963
- return { error: "sessionId is required" };
1050
+ if (!sessionId) {
1051
+ return { error: "invalid_argument", errorMessage: "sessionId is required" };
1052
+ }
964
1053
  const result = mgr.pause(sessionId);
965
1054
  return { ...result };
966
1055
  },
@@ -968,7 +1057,11 @@ export const TOOL_HANDLERS = {
968
1057
  const sessionId = String(input.sessionId ?? "");
969
1058
  const loopId = String(input.loopId ?? "");
970
1059
  if (!sessionId && !loopId) {
971
- 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
+ };
972
1065
  }
973
1066
  // Live session first: it disambiguates re-used loopIds and the session
974
1067
  // registry is the cheapest lookup.
@@ -981,27 +1074,39 @@ export const TOOL_HANDLERS = {
981
1074
  // v3.3.1: session not in memory (restart). Fall through to the vault
982
1075
  // path when a loopId was also provided; otherwise say so plainly.
983
1076
  if (!loopId) {
984
- 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
+ };
985
1083
  }
986
1084
  }
987
1085
  // Vault-direct replay — the timeline lives in the committed round
988
1086
  // documents, not in the session registry.
989
1087
  const timeline = mgr.replayByLoop(loopId);
990
1088
  if (!timeline) {
991
- 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
+ };
992
1093
  }
993
1094
  // sessionId omitted on the vault path (its output schema is string-only).
994
1095
  return { ...(sessionId ? { sessionId } : {}), loopId, timeline };
995
1096
  },
996
1097
  async loopforge_resume(mgr, input) {
997
1098
  const loopId = String(input.loopId ?? "");
998
- if (!loopId)
999
- return { error: "loopId is required" };
1099
+ if (!loopId) {
1100
+ return { error: "invalid_argument", errorMessage: "loopId is required" };
1101
+ }
1000
1102
  try {
1001
1103
  validateLoopId(loopId);
1002
1104
  }
1003
1105
  catch (error) {
1004
- return { error: `invalid loopId: ${error.message}` };
1106
+ return {
1107
+ error: "invalid_argument",
1108
+ errorMessage: `invalid loopId: ${error.message}`,
1109
+ };
1005
1110
  }
1006
1111
  // Paused recovery must run first so the persisted status is atomically
1007
1112
  // changed back to running before a prompt is returned.
@@ -1009,8 +1114,12 @@ export const TOOL_HANDLERS = {
1009
1114
  if (!result) {
1010
1115
  result = await mgr.resume(loopId);
1011
1116
  }
1012
- if (!result)
1013
- 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
+ }
1014
1123
  return { ...result };
1015
1124
  },
1016
1125
  async loopforge_gate_check(mgr, input) {
@@ -1020,12 +1129,19 @@ export const TOOL_HANDLERS = {
1020
1129
  const sessionId = String(input.sessionId ?? "");
1021
1130
  const roundId = String(input.roundId ?? "");
1022
1131
  const action = input.action;
1023
- if (!sessionId)
1024
- return { error: "sessionId is required" };
1025
- if (!roundId)
1026
- return { error: "roundId is required — pass the roundId from the latest response" };
1027
- if (!action || !isRecord(action))
1028
- return { error: "action is required" };
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
+ }
1029
1145
  const descriptor = {
1030
1146
  description: String(action.description ?? ""),
1031
1147
  scope: Array.isArray(action.scope)
@@ -1033,7 +1149,7 @@ export const TOOL_HANDLERS = {
1033
1149
  : [],
1034
1150
  effects: Array.isArray(action.effects)
1035
1151
  ? action.effects.filter((v) => typeof v === "string")
1036
- .filter((v) => true)
1152
+ .filter((_v) => true)
1037
1153
  : [],
1038
1154
  reversibility: (["reversible", "recoverable", "irreversible", "unknown"]
1039
1155
  .includes(String(action.reversibility))
@@ -1045,7 +1161,12 @@ export const TOOL_HANDLERS = {
1045
1161
  : "unknown"),
1046
1162
  };
1047
1163
  if (!descriptor.description.trim()) {
1048
- return { error: "action.description is required" };
1164
+ return {
1165
+ error: "invalid_argument",
1166
+ errorMessage: "action.description is required",
1167
+ sessionId,
1168
+ roundId,
1169
+ };
1049
1170
  }
1050
1171
  return mgr.checkGate(sessionId, roundId, descriptor);
1051
1172
  },