@eir-labs/coltrane 0.4.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 (279) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +85 -0
  3. package/agents/anticipation-mapper.json +34 -0
  4. package/agents/claim-amender.json +34 -0
  5. package/agents/claim-architect.json +34 -0
  6. package/agents/claim-rewriter.json +32 -0
  7. package/agents/delivery-finalizer.json +40 -0
  8. package/agents/diamond-cutter.json +33 -0
  9. package/agents/disclosure-analyst.json +26 -0
  10. package/agents/domain-explorer.json +37 -0
  11. package/agents/e2e-runner.json +34 -0
  12. package/agents/e2e-suite-judge.json +26 -0
  13. package/agents/event-clusterer.json +29 -0
  14. package/agents/novelty-searcher.json +40 -0
  15. package/agents/patent-browser-scout.json +49 -0
  16. package/agents/patent-examiner.json +36 -0
  17. package/agents/phase_agents/claim-rewriter.md +44 -0
  18. package/agents/phase_agents/delivery-finalizer.md +30 -0
  19. package/agents/phase_agents/diamond-cutter.md +47 -0
  20. package/agents/phase_agents/domain-explorer.md +27 -0
  21. package/agents/phase_agents/novelty-searcher.md +50 -0
  22. package/agents/phase_agents/problem-definer.md +32 -0
  23. package/agents/phase_agents/solution-developer.md +26 -0
  24. package/agents/phase_agents/verdict-judger.md +70 -0
  25. package/agents/players/audience-modeler.md +40 -0
  26. package/agents/players/chain-audit-keeper.md +41 -0
  27. package/agents/players/illumination-reviewer.md +42 -0
  28. package/agents/players/methodology-cadence-keeper.md +42 -0
  29. package/agents/players/substrate-edge-keeper.md +45 -0
  30. package/agents/prior-art-scout.json +45 -0
  31. package/agents/problem-definer.json +35 -0
  32. package/agents/seeds/steve_seed_a.json +12 -0
  33. package/agents/seeds/steve_seed_b.json +12 -0
  34. package/agents/seeds/steve_seed_c.json +12 -0
  35. package/agents/seeds/steve_seed_d.json +12 -0
  36. package/agents/sensor.json +22 -0
  37. package/agents/solution-developer.json +35 -0
  38. package/agents/source-walker.json +38 -0
  39. package/agents/spec-drafter.json +31 -0
  40. package/agents/sub_thread_soft_judge.json +26 -0
  41. package/agents/sub_thread_trace_sensor.json +22 -0
  42. package/agents/summarizer.json +25 -0
  43. package/agents/synthesis-writer.json +29 -0
  44. package/agents/triage-judge.json +39 -0
  45. package/agents/user_flow_judge.json +26 -0
  46. package/agents/user_flow_sensor.json +22 -0
  47. package/agents/verdict-judger.json +39 -0
  48. package/core_types/artifact.json +17 -0
  49. package/core_types/interpretation.json +16 -0
  50. package/core_types/judgment.json +16 -0
  51. package/core_types/plan.json +16 -0
  52. package/core_types/signal.json +16 -0
  53. package/core_types/verdict.json +27 -0
  54. package/dist/src/access_grant.d.ts +46 -0
  55. package/dist/src/access_grant.js +76 -0
  56. package/dist/src/access_grant.js.map +1 -0
  57. package/dist/src/acoustics.d.ts +9 -0
  58. package/dist/src/acoustics.js +65 -0
  59. package/dist/src/acoustics.js.map +1 -0
  60. package/dist/src/agent_profile.d.ts +36 -0
  61. package/dist/src/agent_profile.js +57 -0
  62. package/dist/src/agent_profile.js.map +1 -0
  63. package/dist/src/bifrost_invoker.d.ts +33 -0
  64. package/dist/src/bifrost_invoker.js +85 -0
  65. package/dist/src/bifrost_invoker.js.map +1 -0
  66. package/dist/src/canonical_core_types.d.ts +2 -0
  67. package/dist/src/canonical_core_types.js +111 -0
  68. package/dist/src/canonical_core_types.js.map +1 -0
  69. package/dist/src/canonical_form.d.ts +55 -0
  70. package/dist/src/canonical_form.js +93 -0
  71. package/dist/src/canonical_form.js.map +1 -0
  72. package/dist/src/charter.d.ts +37 -0
  73. package/dist/src/charter.js +86 -0
  74. package/dist/src/charter.js.map +1 -0
  75. package/dist/src/circle_of_fifths.d.ts +27 -0
  76. package/dist/src/circle_of_fifths.js +78 -0
  77. package/dist/src/circle_of_fifths.js.map +1 -0
  78. package/dist/src/claude_invoker.d.ts +102 -0
  79. package/dist/src/claude_invoker.js +760 -0
  80. package/dist/src/claude_invoker.js.map +1 -0
  81. package/dist/src/coltrane_profile.d.ts +87 -0
  82. package/dist/src/coltrane_profile.js +132 -0
  83. package/dist/src/coltrane_profile.js.map +1 -0
  84. package/dist/src/composition.d.ts +57 -0
  85. package/dist/src/composition.js +447 -0
  86. package/dist/src/composition.js.map +1 -0
  87. package/dist/src/core_types.d.ts +6 -0
  88. package/dist/src/core_types.js +25 -0
  89. package/dist/src/core_types.js.map +1 -0
  90. package/dist/src/document_factory.d.ts +89 -0
  91. package/dist/src/document_factory.js +176 -0
  92. package/dist/src/document_factory.js.map +1 -0
  93. package/dist/src/genome_schema.d.ts +512 -0
  94. package/dist/src/genome_schema.js +168 -0
  95. package/dist/src/genome_schema.js.map +1 -0
  96. package/dist/src/genome_writer.d.ts +67 -0
  97. package/dist/src/genome_writer.js +179 -0
  98. package/dist/src/genome_writer.js.map +1 -0
  99. package/dist/src/gig_song.d.ts +29 -0
  100. package/dist/src/gig_song.js +56 -0
  101. package/dist/src/gig_song.js.map +1 -0
  102. package/dist/src/gig_tracker.d.ts +54 -0
  103. package/dist/src/gig_tracker.js +109 -0
  104. package/dist/src/gig_tracker.js.map +1 -0
  105. package/dist/src/harmonic_validation.d.ts +11 -0
  106. package/dist/src/harmonic_validation.js +35 -0
  107. package/dist/src/harmonic_validation.js.map +1 -0
  108. package/dist/src/hooks.d.ts +27 -0
  109. package/dist/src/hooks.js +2 -0
  110. package/dist/src/hooks.js.map +1 -0
  111. package/dist/src/index.d.ts +36 -0
  112. package/dist/src/index.js +37 -0
  113. package/dist/src/index.js.map +1 -0
  114. package/dist/src/jsong.d.ts +47 -0
  115. package/dist/src/jsong.js +130 -0
  116. package/dist/src/jsong.js.map +1 -0
  117. package/dist/src/judges/user_flow_judge.d.ts +89 -0
  118. package/dist/src/judges/user_flow_judge.js +200 -0
  119. package/dist/src/judges/user_flow_judge.js.map +1 -0
  120. package/dist/src/learner.d.ts +37 -0
  121. package/dist/src/learner.js +30 -0
  122. package/dist/src/learner.js.map +1 -0
  123. package/dist/src/ledger.d.ts +225 -0
  124. package/dist/src/ledger.js +306 -0
  125. package/dist/src/ledger.js.map +1 -0
  126. package/dist/src/loader.d.ts +87 -0
  127. package/dist/src/loader.js +504 -0
  128. package/dist/src/loader.js.map +1 -0
  129. package/dist/src/mcp.d.ts +23 -0
  130. package/dist/src/mcp.js +125 -0
  131. package/dist/src/mcp.js.map +1 -0
  132. package/dist/src/modulation_path.d.ts +42 -0
  133. package/dist/src/modulation_path.js +60 -0
  134. package/dist/src/modulation_path.js.map +1 -0
  135. package/dist/src/output_validation.d.ts +19 -0
  136. package/dist/src/output_validation.js +51 -0
  137. package/dist/src/output_validation.js.map +1 -0
  138. package/dist/src/outputs.d.ts +125 -0
  139. package/dist/src/outputs.js +452 -0
  140. package/dist/src/outputs.js.map +1 -0
  141. package/dist/src/overtones.d.ts +42 -0
  142. package/dist/src/overtones.js +98 -0
  143. package/dist/src/overtones.js.map +1 -0
  144. package/dist/src/player_to_claude_code.d.ts +17 -0
  145. package/dist/src/player_to_claude_code.js +141 -0
  146. package/dist/src/player_to_claude_code.js.map +1 -0
  147. package/dist/src/playwright_cage.d.ts +28 -0
  148. package/dist/src/playwright_cage.js +47 -0
  149. package/dist/src/playwright_cage.js.map +1 -0
  150. package/dist/src/polyphony.d.ts +46 -0
  151. package/dist/src/polyphony.js +91 -0
  152. package/dist/src/polyphony.js.map +1 -0
  153. package/dist/src/portfolio.d.ts +52 -0
  154. package/dist/src/portfolio.js +186 -0
  155. package/dist/src/portfolio.js.map +1 -0
  156. package/dist/src/pricing.d.ts +23 -0
  157. package/dist/src/pricing.js +16 -0
  158. package/dist/src/pricing.js.map +1 -0
  159. package/dist/src/registry.d.ts +46 -0
  160. package/dist/src/registry.js +199 -0
  161. package/dist/src/registry.js.map +1 -0
  162. package/dist/src/runtime.d.ts +304 -0
  163. package/dist/src/runtime.js +1099 -0
  164. package/dist/src/runtime.js.map +1 -0
  165. package/dist/src/server.d.ts +88 -0
  166. package/dist/src/server.js +1692 -0
  167. package/dist/src/server.js.map +1 -0
  168. package/dist/src/server_entry.d.ts +2 -0
  169. package/dist/src/server_entry.js +40 -0
  170. package/dist/src/server_entry.js.map +1 -0
  171. package/dist/src/server_relay.d.ts +82 -0
  172. package/dist/src/server_relay.js +341 -0
  173. package/dist/src/server_relay.js.map +1 -0
  174. package/dist/src/simulate.d.ts +60 -0
  175. package/dist/src/simulate.js +72 -0
  176. package/dist/src/simulate.js.map +1 -0
  177. package/dist/src/skill_runner.mjs +34 -0
  178. package/dist/src/skill_subprocess.d.ts +65 -0
  179. package/dist/src/skill_subprocess.js +132 -0
  180. package/dist/src/skill_subprocess.js.map +1 -0
  181. package/dist/src/skills.d.ts +85 -0
  182. package/dist/src/skills.js +258 -0
  183. package/dist/src/skills.js.map +1 -0
  184. package/dist/src/subthread_recorder.d.ts +68 -0
  185. package/dist/src/subthread_recorder.js +166 -0
  186. package/dist/src/subthread_recorder.js.map +1 -0
  187. package/dist/src/tensor_read.d.ts +38 -0
  188. package/dist/src/tensor_read.js +60 -0
  189. package/dist/src/tensor_read.js.map +1 -0
  190. package/dist/src/test_honest_broker.d.ts +34 -0
  191. package/dist/src/test_honest_broker.js +155 -0
  192. package/dist/src/test_honest_broker.js.map +1 -0
  193. package/dist/src/test_topology.d.ts +22 -0
  194. package/dist/src/test_topology.js +137 -0
  195. package/dist/src/test_topology.js.map +1 -0
  196. package/dist/src/tones.d.ts +20 -0
  197. package/dist/src/tones.js +81 -0
  198. package/dist/src/tones.js.map +1 -0
  199. package/dist/src/tool_providers.d.ts +45 -0
  200. package/dist/src/tool_providers.js +104 -0
  201. package/dist/src/tool_providers.js.map +1 -0
  202. package/dist/src/type_versioning.d.ts +23 -0
  203. package/dist/src/type_versioning.js +31 -0
  204. package/dist/src/type_versioning.js.map +1 -0
  205. package/dist/src/version.d.ts +28 -0
  206. package/dist/src/version.js +86 -0
  207. package/dist/src/version.js.map +1 -0
  208. package/domain_types/claim-draft.json +34 -0
  209. package/domain_types/coverage-report.json +31 -0
  210. package/domain_types/draft-agent-profile.json +31 -0
  211. package/domain_types/draft-domain-type.json +28 -0
  212. package/domain_types/draft-standard.json +46 -0
  213. package/domain_types/e2e-observation.json +32 -0
  214. package/domain_types/e2e-report.json +30 -0
  215. package/domain_types/e2e-target.json +26 -0
  216. package/domain_types/e2e-verdict.json +26 -0
  217. package/domain_types/examine-round-record.json +40 -0
  218. package/domain_types/examiner-rejection.json +28 -0
  219. package/domain_types/failure-modes.json +31 -0
  220. package/domain_types/invention-analysis.json +34 -0
  221. package/domain_types/invention-spec.json +34 -0
  222. package/domain_types/novelty-analysis.json +28 -0
  223. package/domain_types/novelty-verdict.json +35 -0
  224. package/domain_types/parsed-conversation-trace.json +38 -0
  225. package/domain_types/pattern-extraction.json +62 -0
  226. package/domain_types/prior-art-hit.json +50 -0
  227. package/domain_types/project-charter.json +71 -0
  228. package/domain_types/provisional-draft.json +34 -0
  229. package/domain_types/raw-note.json +19 -0
  230. package/domain_types/repo-survey.json +28 -0
  231. package/domain_types/seeding-verdict.json +70 -0
  232. package/domain_types/soft-verdict.json +55 -0
  233. package/domain_types/summary.json +19 -0
  234. package/domain_types/triage-verdict.json +59 -0
  235. package/domain_types/user-flow-transcript.json +43 -0
  236. package/domain_types/user-flow-verdict.json +60 -0
  237. package/domain_types/verdict-record.json +39 -0
  238. package/package.json +82 -0
  239. package/skills/citation-verify/fixtures/basic.json +14 -0
  240. package/skills/citation-verify/meta.json +10 -0
  241. package/skills/citation-verify/skill.mjs +26 -0
  242. package/skills/claim-element-decompose/fixtures/basic.json +11 -0
  243. package/skills/claim-element-decompose/meta.json +10 -0
  244. package/skills/claim-element-decompose/skill.mjs +14 -0
  245. package/skills/diamond-cutting-discipline/fixtures/basic.json +13 -0
  246. package/skills/diamond-cutting-discipline/meta.json +19 -0
  247. package/skills/diamond-cutting-discipline/skill.md +46 -0
  248. package/skills/element-mapping-matrix/fixtures/basic.json +13 -0
  249. package/skills/element-mapping-matrix/meta.json +10 -0
  250. package/skills/element-mapping-matrix/skill.mjs +20 -0
  251. package/skills/number-adder/fixtures/basic.json +9 -0
  252. package/skills/number-adder/meta.json +10 -0
  253. package/skills/number-adder/skill.mjs +12 -0
  254. package/skills/patent-fetch/fixtures/parse.json +18 -0
  255. package/skills/patent-fetch/meta.json +11 -0
  256. package/skills/patent-fetch/skill.mjs +34 -0
  257. package/skills/query-expand/fixtures/basic.json +14 -0
  258. package/skills/query-expand/meta.json +10 -0
  259. package/skills/query-expand/skill.mjs +29 -0
  260. package/skills/run-vitest-band/fixtures/basic.json +9 -0
  261. package/skills/run-vitest-band/meta.json +18 -0
  262. package/skills/run-vitest-band/skill.mjs +22 -0
  263. package/skills/statutory-checklist/fixtures/basic.json +16 -0
  264. package/skills/statutory-checklist/meta.json +10 -0
  265. package/skills/statutory-checklist/skill.mjs +25 -0
  266. package/skills/summarize-tight/fixtures/basic.json +13 -0
  267. package/skills/summarize-tight/meta.json +20 -0
  268. package/skills/summarize-tight/skill.md +1 -0
  269. package/skills/verdict-gate/fixtures/basic.json +9 -0
  270. package/skills/verdict-gate/meta.json +10 -0
  271. package/skills/verdict-gate/skill.mjs +82 -0
  272. package/standards/e2e-suite-v0.json +44 -0
  273. package/standards/patent-triage-v0.json +98 -0
  274. package/standards/patent-triage-v1.json +84 -0
  275. package/standards/seed-from-local-repos-v0.json +115 -0
  276. package/standards/sub_thread_invocation.json +50 -0
  277. package/standards/summarize.json +40 -0
  278. package/standards/synthesis-walk-v0.json +108 -0
  279. package/standards/user_flow_correctness.json +57 -0
@@ -0,0 +1,1692 @@
1
+ // §7 MCP server — the stdio entry that exposes MCP_TOOLS and routes calls.
2
+ // Two layers: a PURE dispatcher (dispatchTool — testable, no transport) and the
3
+ // stdio wiring (runStdioServer). Tools needing gig-execution context (output_write,
4
+ // gig_*) are honest `not_implemented` until src/runtime lands; the context-free
5
+ // tools (type_resolve/register/browse, standard_simulate) are wired now.
6
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
+ import { MCP_TOOLS, requiresApproval, AGENT_STATUS_ORDER, STANDARD_STATUS_ORDER, SKILL_STATUS_ORDER, checkPromotion, PromotionError, } from "./mcp.js";
10
+ import { loadRegistry } from "./registry.js";
11
+ import { resolveGenome } from "./loader.js";
12
+ import { SkillSchema, AgentSchema, StandardSchema } from "./genome_schema.js";
13
+ import { sealAgentDefinition, sealDefinition, sealSkillPackage, recordIdentity } from "./genome_writer.js";
14
+ import { createOutputStore, defaultOutputsPersistDir } from "./outputs.js";
15
+ import { FileLedger, LedgerError, LEDGER_SCHEMA_VERSION, defaultLedgerPath, } from "./ledger.js";
16
+ import { standardSimulate } from "./simulate.js";
17
+ import { runGig, BudgetExhausted, GigAborted, partialGigUsage, partialBudgetState } from "./runtime.js";
18
+ import { makeClaudeInvoker, killLiveChairChildren } from "./claude_invoker.js";
19
+ import { isDepth, DEPTHS } from "./pricing.js";
20
+ import { ENGINE_MCP_SERVER } from "./tool_providers.js";
21
+ import { composeStandard, defineAgent, CompositionError } from "./composition.js";
22
+ import { PRIMITIVE_OUTPUT_TYPE } from "./core_types.js";
23
+ import { proposeTypeChange } from "./type_versioning.js";
24
+ import { proposeAgentChange, evolveProfile } from "./agent_profile.js";
25
+ import { checkGrantTTL, validatePlanAgainstGrant } from "./access_grant.js";
26
+ import { loadCharter, CharterError } from "./charter.js";
27
+ import { COLTRANE_VERSION } from "./version.js";
28
+ import { readFileSync, existsSync, mkdirSync, appendFileSync, readdirSync } from "node:fs";
29
+ import { randomUUID, createHash } from "node:crypto";
30
+ import { join } from "node:path";
31
+ import { newGigRun, applyGigProgress, gigEventLogLine, pruneGigRuns } from "./gig_tracker.js";
32
+ import { isGig } from "./ledger.js";
33
+ import { SubthreadRecorder, ApiVersionMismatchError } from "./subthread_recorder.js";
34
+ import { canonJson, runFingerprint, CANONICAL_FORM_VERSION } from "./canonical_form.js";
35
+ // Every StandardSchema field that is NOT structural (slug/domain/agents/agent_slugs/phases) is a
36
+ // passthrough that composeStandard must receive — eval_slugs, input_types (the gig contract),
37
+ // output_types, max_examine_rounds, description, … Derived from the schema's own key list so adding
38
+ // a field can't re-drift. Shared by standard_compose AND the agent_evolve cascade re-compose: both
39
+ // must thread the SAME fields, or one rejects a standard the other accepts (#204 — the cascade
40
+ // dropped input_types and wrongly failed entry chairs that read their contract from the gig input).
41
+ const STD_PASSTHROUGH = Object.keys(StandardSchema.shape).filter((k) => !["slug", "domain", "agents", "agent_slugs", "phases"].includes(k));
42
+ /** Build a governance row. Every governance act names WHAT it was about (`subject_slug`) and
43
+ * carries its payload (`detail`) — v1 recorded a bare UUID and "n/a" identity (#212). */
44
+ function governanceRow(event, subject_slug, detail, subject_gig_id) {
45
+ const now = new Date().toISOString();
46
+ return {
47
+ kind: "governance",
48
+ schema_version: LEDGER_SCHEMA_VERSION,
49
+ entry_id: `${event}:${randomUUID()}`,
50
+ event,
51
+ subject_slug,
52
+ ...(subject_gig_id ? { subject_gig_id } : {}),
53
+ detail,
54
+ output_hashes: [],
55
+ started_at: now,
56
+ finished_at: now,
57
+ };
58
+ }
59
+ const KNOWN_SLUGS = new Set(MCP_TOOLS.map((t) => t.slug));
60
+ // Live registry of admissible tool slugs — the cage gate for agent_define's
61
+ // allowed_tools. Seeded with the static MCP_TOOLS surface; tool_register grows
62
+ // it at runtime so the propose→register→define loop can close.
63
+ const REGISTERED_TOOL_SLUGS = new Set(MCP_TOOLS.map((t) => t.slug));
64
+ // Honest gap set: tools in the surface whose impl still awaits another lane. Now
65
+ // EMPTY — every v0 tool is wired against real in-repo impl (no stubs). Kept as the
66
+ // hook so a future tool can be surfaced before it's implemented without lying.
67
+ const NEEDS_RUNTIME = new Set([]);
68
+ // CoreType → Primitive — the inverse of PRIMITIVE_OUTPUT_TYPE. output_write
69
+ // auto-resolves the writing primitive from core_type when the caller omits it.
70
+ const CORE_TYPE_TO_PRIMITIVE = Object.fromEntries(Object.entries(PRIMITIVE_OUTPUT_TYPE).map(([prim, core]) => [core, prim]));
71
+ function arr(v) {
72
+ return Array.isArray(v) ? v : [];
73
+ }
74
+ /**
75
+ * Read an optional `depth` argument (#237). Absent/empty → no depth (each agent's own
76
+ * `depth_profile` stands). Present but not a real depth → an ERROR, never a silent discard:
77
+ * the whole point of the parameter is cost control, and "we ignored your depth and ran the
78
+ * expensive one" is the failure it exists to prevent.
79
+ */
80
+ function readDepth(v) {
81
+ if (v === undefined || v === null || v === "")
82
+ return {};
83
+ if (!isDepth(v))
84
+ return { error: `unknown depth "${String(v)}" — expected one of: ${DEPTHS.join(", ")}` };
85
+ return { depth: v };
86
+ }
87
+ /**
88
+ * Normalize the user-passed `schema` for type_register (Rob #131).
89
+ *
90
+ * The validator (registry.ts) reads `schema.properties` and treats anything
91
+ * not in it as `additionalProperties` (silently rejected). The MCP surface
92
+ * doesn't tell the caller the wrapper is required — Rob hit this writing
93
+ * `{schema: {title: {type: "string"}}}` and watching every field disappear.
94
+ *
95
+ * Heuristic: if `schema` lacks a `.properties` key AND its values look like
96
+ * JSON-schema field defs (objects with a `type` key), wrap them under
97
+ * `.properties`. Otherwise leave the shape alone. Safe round-trip: a schema
98
+ * that already has `.properties` is returned untouched.
99
+ */
100
+ function normalizeSchemaShape(schema) {
101
+ if ("properties" in schema)
102
+ return schema;
103
+ const entries = Object.entries(schema);
104
+ if (entries.length === 0)
105
+ return schema;
106
+ const looksLikeFieldDefs = entries.every(([, v]) => v !== null && typeof v === "object" && !Array.isArray(v) && "type" in v);
107
+ if (!looksLikeFieldDefs)
108
+ return schema;
109
+ return { type: "object", properties: schema };
110
+ }
111
+ /**
112
+ * In-place sync helper for genome_reload (Rob #130). Mutates `target` so it
113
+ * matches `source` after the call: keys in `source` not in `target` are added;
114
+ * keys in both are replaced; keys in `target` not in `source` are deleted.
115
+ * Returns the slug diff (added / modified / removed). `before` is a snapshot
116
+ * captured BEFORE the mutation so modified-vs-unchanged is computable via
117
+ * JSON-stringify equality.
118
+ *
119
+ * If `target` is undefined (deps wasn't bootstrapped with that class) the
120
+ * function is a no-op and returns empty diffs — honest: nothing to mutate.
121
+ */
122
+ function syncMap(target, source, before) {
123
+ const added = [];
124
+ const modified = [];
125
+ const removed = [];
126
+ if (!target)
127
+ return { added, modified, removed };
128
+ // Add or replace
129
+ for (const [key, val] of source) {
130
+ const prior = before.get(key);
131
+ target.set(key, val);
132
+ if (!prior)
133
+ added.push(key);
134
+ else if (JSON.stringify(prior) !== JSON.stringify(val))
135
+ modified.push(key);
136
+ }
137
+ // Remove keys gone from source
138
+ for (const key of [...target.keys()]) {
139
+ if (!source.has(key)) {
140
+ target.delete(key);
141
+ removed.push(key);
142
+ }
143
+ }
144
+ return { added, modified, removed };
145
+ }
146
+ /**
147
+ * Pure tool dispatcher. Routes a tool call to its implementation. No transport,
148
+ * no I/O beyond the injected deps — fully unit-testable.
149
+ */
150
+ export async function dispatchTool(slug, args, deps) {
151
+ if (!KNOWN_SLUGS.has(slug)) {
152
+ return { ok: false, error: `unknown tool "${slug}"` };
153
+ }
154
+ // Approval gating is surfaced on every result so the caller (or a wrapping
155
+ // policy layer) can refuse to apply a change that needs human sign-off.
156
+ const approval = requiresApproval({
157
+ slug,
158
+ change_class: args["change_class"] ?? null,
159
+ target_kind: args["target_kind"] ?? null,
160
+ });
161
+ if (NEEDS_RUNTIME.has(slug)) {
162
+ return { ok: false, not_implemented: true, requires_approval: approval, error: `"${slug}" awaits src/runtime / context stores` };
163
+ }
164
+ // #206 — the interception seam. The engine ships ZERO hooks; it only CALLS whatever the wrapping
165
+ // layer injected. No hooks → this loop is a no-op and dispatch is byte-identical to no seam. Hooks
166
+ // wrap ONLY known, implemented calls (we are past the guards above). A hook that throws fails the
167
+ // call CLOSED — a gate that errors must never let the call through.
168
+ const hooks = deps.hooks ?? [];
169
+ let workArgs = args;
170
+ const hookCtx = () => ({ slug, args: workArgs, deps, requires_approval: approval });
171
+ // before: array order; first halt wins (impl + remaining before-hooks + ALL after-hooks skipped).
172
+ for (const h of hooks) {
173
+ if (!h.before)
174
+ continue;
175
+ let out;
176
+ try {
177
+ out = await h.before(hookCtx());
178
+ }
179
+ catch (e) {
180
+ return { ok: false, error: `hook "${h.name}" before() failed: ${e instanceof Error ? e.message : String(e)}` };
181
+ }
182
+ if (out.action === "halt")
183
+ return out.result;
184
+ if (out.args)
185
+ workArgs = out.args; // threaded to the next hook + the impl
186
+ }
187
+ let result = await runImpl(slug, workArgs, deps, approval);
188
+ // after: array order; folds over the result (each hook sees the prior's output).
189
+ for (const h of hooks) {
190
+ if (!h.after)
191
+ continue;
192
+ try {
193
+ result = await h.after(hookCtx(), result);
194
+ }
195
+ catch (e) {
196
+ return { ok: false, error: `hook "${h.name}" after() failed: ${e instanceof Error ? e.message : String(e)}` };
197
+ }
198
+ }
199
+ return result;
200
+ }
201
+ // The engine's actual tool implementations — the big switch, extracted from dispatchTool (#206) so
202
+ // the hook loop can wrap it. The body is UNCHANGED: it reads `args` (which the caller threads as the
203
+ // possibly-rewritten workArgs) and `approval` (computed once by the wrapper). No logic change.
204
+ async function runImpl(slug, args, deps, approval) {
205
+ try {
206
+ switch (slug) {
207
+ case "type_resolve": {
208
+ const res = deps.registry.resolveType({
209
+ extends: String(args["core_type"] ?? args["extends"] ?? ""),
210
+ domain: String(args["domain"] ?? ""),
211
+ required_fields: arr(args["required_fields"]),
212
+ });
213
+ return { ok: true, requires_approval: approval, data: res };
214
+ }
215
+ case "type_browse": {
216
+ let types = deps.registry.listTypes();
217
+ if (args["domain"])
218
+ types = types.filter((t) => t.domain === args["domain"]);
219
+ if (args["extends"])
220
+ types = types.filter((t) => t.extends === args["extends"]);
221
+ return { ok: true, requires_approval: approval, data: { types, stats: { count: types.length } } };
222
+ }
223
+ case "type_register": {
224
+ const def = {
225
+ slug: String(args["slug"] ?? ""),
226
+ extends: String(args["extends"] ?? ""),
227
+ domain: String(args["domain"] ?? ""),
228
+ // Rob #131 — normalize schema shape. The validator reads
229
+ // `schema.properties`; if the caller passes field defs at the top
230
+ // level (e.g. `{title: {type: "string"}}`) instead of inside a
231
+ // `.properties` wrapper, every field gets silently rejected as
232
+ // `additionalProperties`. Detect the unwrapped shape and wrap it.
233
+ schema: normalizeSchemaShape(args["schema"] ?? {}),
234
+ required_fields: arr(args["required_fields"]),
235
+ };
236
+ const res = deps.registry.registerType(def);
237
+ // substrate seal: persist a loadable domain_types/<slug>.json (full record) + ledger.
238
+ const fileDef = { slug: def.slug, version: 1, extends: def.extends, domain: def.domain, status: "active", schema: def.schema, required_fields: def.required_fields };
239
+ const sealed = sealDefinition("type_register", def.slug, fileDef, deps.ledger, deps.genome_dir, "domain_types");
240
+ return { ok: true, requires_approval: approval, data: { ...res, content_hash: sealed.content_hash, dependency_hash: sealed.dependency_hash, effective_hash: sealed.effective_hash } };
241
+ }
242
+ case "standard_simulate": {
243
+ const simSlug = String(args["standard_slug"] ?? "");
244
+ const simDepth = readDepth(args["depth"]);
245
+ if (simDepth.error)
246
+ return { ok: false, requires_approval: approval, error: simDepth.error };
247
+ // #239 — hand the simulator the standard it is simulating. It only ever received a
248
+ // SLUG, so a 6-phase pipeline came back as three invented phases and a cost with no
249
+ // connection to it. And hand it the REAL settled spend of prior runs (#195): a measured
250
+ // mean of this pipeline beats any formula for the "validate before you spend" check.
251
+ const std = deps.standards?.get(simSlug);
252
+ // #267 — refuse a standard we cannot find, rather than estimating one we invented.
253
+ // This tool is documented as the cheap pre-dispatch gate ("validate before you
254
+ // spend"), and a gate that cannot fail is worse than no gate: callers stop looking.
255
+ // A typo'd slug is the single most likely thing an operator wants caught here.
256
+ //
257
+ // The gate lives at the MCP boundary, NOT in standardSimulate(). Keeping the pure
258
+ // function permissive is a deliberate separation — an estimator that refuses is a
259
+ // different kind of thing from an estimator — and it is the TOOL that owes callers a
260
+ // verdict. (`standardSimulate` has exactly one non-test caller: this line. So this is
261
+ // a design choice about where refusal belongs, not a constraint imposed by other
262
+ // callers.) `basis: "fallback"` labels an invented number honestly, but it is a field
263
+ // inside a SUCCESS payload — it stops nobody.
264
+ //
265
+ // "I looked and it is absent" is a different answer from "I have no way to look", and
266
+ // the two get different answers. A host that wired no standards map cannot resolve
267
+ // ANY slug, so it reports `not_implemented` exactly as `gig_dispatch` does on the same
268
+ // host. Returning a $1.00 estimate there would be worse than useless: it would quote a
269
+ // price for a run that `gig_dispatch` is about to refuse outright.
270
+ if (!deps.standards) {
271
+ return {
272
+ ok: false,
273
+ not_implemented: true,
274
+ requires_approval: approval,
275
+ error: "standard_simulate needs standards wired into the server",
276
+ };
277
+ }
278
+ if (!simSlug) {
279
+ return { ok: false, requires_approval: approval, error: "standard_simulate requires a standard_slug" };
280
+ }
281
+ if (!std) {
282
+ return {
283
+ ok: false,
284
+ requires_approval: approval,
285
+ error: `unknown standard "${simSlug}" — nothing to simulate. Check the slug, and if the ` +
286
+ `standard is newly authored run genome_reload and confirm load_errors is empty.`,
287
+ };
288
+ }
289
+ const observed = deps.ledger
290
+ .query({ kind: "gig", standard_slug: simSlug })
291
+ .filter(isGig)
292
+ .map((e) => e.usage?.total_cost_usd)
293
+ .filter((n) => typeof n === "number" && n > 0);
294
+ const res = standardSimulate({
295
+ standard_slug: simSlug,
296
+ mock_input: args["mock_input"] ?? {},
297
+ depth: simDepth.depth ?? "standard",
298
+ ...(std ? { standard: { slug: std.slug, phases: std.phases.map((p) => ({ name: p.name, chairs: p.chairs.length })) } } : {}),
299
+ ...(observed.length > 0 ? { observed_costs_usd: observed } : {}),
300
+ });
301
+ return { ok: true, requires_approval: approval, data: res };
302
+ }
303
+ case "output_query": {
304
+ let outs = deps.outputs.all();
305
+ if (args["domain_type"])
306
+ outs = outs.filter((o) => o.domain_type === args["domain_type"]);
307
+ if (args["gig_id"])
308
+ outs = outs.filter((o) => o.gig_id === args["gig_id"]);
309
+ if (args["agent_slug"])
310
+ outs = outs.filter((o) => o.agent_slug === args["agent_slug"]);
311
+ return { ok: true, requires_approval: approval, data: { outputs: outs, total_count: outs.length } };
312
+ }
313
+ case "output_trace": {
314
+ const id = String(args["output_id"] ?? "");
315
+ const maxDepth = typeof args["max_depth"] === "number" ? args["max_depth"] : undefined;
316
+ const chain = deps.outputs.trace(id, maxDepth !== undefined ? { max_depth: maxDepth } : undefined);
317
+ return { ok: true, requires_approval: approval, data: { graph: { nodes: chain }, root_signals: chain.filter((o) => o.input_refs.length === 0) } };
318
+ }
319
+ case "output_write": {
320
+ // §6 universal output write: validates against core+domain schema AT WRITE
321
+ // (T3). Primitive is auto-resolved from core_type when omitted. Optional
322
+ // `refs: [{ to, relation }]` link provenance edges after the row exists.
323
+ //
324
+ // Boundary discipline: null/undefined at the dispatchTool boundary is
325
+ // NOT silently coerced to "" or {} before validate sees them. The
326
+ // validator must see what the caller actually sent — `data: null`
327
+ // falls through and Ajv rejects with the type-mismatch message, rather
328
+ // than the boundary swallowing the adversarial intent.
329
+ const core_type = String(args["core_type"] ?? "");
330
+ const primitive = String(args["primitive"] ?? CORE_TYPE_TO_PRIMITIVE[core_type] ?? "SENSE");
331
+ const domain_type_raw = args["domain_type"];
332
+ const domain_type = typeof domain_type_raw === "string"
333
+ ? domain_type_raw
334
+ : domain_type_raw == null ? "" : String(domain_type_raw);
335
+ const data_raw = args["data"];
336
+ // undefined → {} (caller never sent the field); null stays null so Ajv sees it.
337
+ const data = (data_raw === undefined ? {} : data_raw);
338
+ const rec = deps.outputs.write({
339
+ core_type,
340
+ domain_type,
341
+ domain_type_version: args["domain_type_version"],
342
+ domain: String(args["domain"] ?? ""),
343
+ gig_id: String(args["gig_id"] ?? ""),
344
+ agent_slug: String(args["agent_slug"] ?? ""),
345
+ phase: args["phase"],
346
+ primitive,
347
+ data,
348
+ input_refs: arr(args["input_refs"]),
349
+ cost_usd: args["cost_usd"],
350
+ tokens_used: args["tokens_used"],
351
+ duration_ms: args["duration_ms"],
352
+ });
353
+ const refs = Array.isArray(args["refs"]) ? args["refs"] : [];
354
+ for (const r of refs) {
355
+ deps.outputs.addRef(rec.id, r.to, r.relation, primitive);
356
+ }
357
+ return { ok: true, requires_approval: approval, data: { output_id: rec.id, primitive, output: rec } };
358
+ }
359
+ case "execution_history_read": {
360
+ // Read the append-only ledger — the genome's run history. Filterable by
361
+ // gig / standard / genome_hash / time window (LedgerQuery).
362
+ const filter = {};
363
+ for (const k of ["gig_id", "standard_slug", "genome_hash", "after", "before"]) {
364
+ if (args[k])
365
+ filter[k] = String(args[k]);
366
+ }
367
+ const executions = deps.ledger.query(filter);
368
+ return { ok: true, requires_approval: approval, data: { executions, count: executions.length } };
369
+ }
370
+ case "gig_dispatch": {
371
+ if (!deps.standards || !deps.invoke) {
372
+ return { ok: false, not_implemented: true, requires_approval: approval, error: "gig_dispatch needs standards + invoke wired into the server" };
373
+ }
374
+ const slug2 = String(args["standard_slug"] ?? "");
375
+ const standard = deps.standards.get(slug2);
376
+ if (!standard)
377
+ return { ok: false, requires_approval: approval, error: `unknown standard "${slug2}"` };
378
+ // Optional budget arg — when present, runtime enforces per-gig cost-budget
379
+ // and raises BudgetExhausted on depletion (PR for T10 gap, see runtime.ts).
380
+ const budgetArg = args["budget"];
381
+ let budget;
382
+ if (budgetArg && typeof budgetArg["opening"] === "number") {
383
+ budget = { opening: budgetArg["opening"] };
384
+ if (typeof budgetArg["base_cost"] === "number")
385
+ budget.base_cost = budgetArg["base_cost"];
386
+ if (typeof budgetArg["k"] === "number")
387
+ budget.k = budgetArg["k"];
388
+ }
389
+ const gigInput = args["input"] ?? {};
390
+ // #237 — `depth` was advertised here and never read. Every dispatch ran at full depth,
391
+ // so the documented "skim first while iterating" practice had no mechanism behind it.
392
+ const depthArg = readDepth(args["depth"]);
393
+ if (depthArg.error)
394
+ return { ok: false, requires_approval: approval, error: depthArg.error };
395
+ const depth = depthArg.depth;
396
+ // Synchronous mode (opt-in via wait:true) — block, return the manifest. The
397
+ // deterministic test path and any caller that wants the answer in one call.
398
+ const wait = args["wait"] === true;
399
+ if (wait) {
400
+ try {
401
+ const res = await runGig(standard, gigInput, {
402
+ outputs: deps.outputs, ledger: deps.ledger, invoke: deps.invoke,
403
+ model_version: deps.model_version, skills: deps.skills, skill_dirs: deps.skill_dirs, evals: deps.evals, budget,
404
+ ...(depth ? { depth } : {}),
405
+ });
406
+ return {
407
+ ok: true, requires_approval: approval,
408
+ data: {
409
+ gig_id: res.gig_id,
410
+ ...(depth ? { depth } : {}),
411
+ manifest: {
412
+ genome_hash: res.genome_hash, run_fingerprint: res.run_fingerprint, output_count: res.outputs.length,
413
+ ...(res.usage ? { usage: res.usage } : {}), // #195 — settled model spend
414
+ ...(res.budget_state ? { budget_state: res.budget_state } : {}),
415
+ },
416
+ },
417
+ };
418
+ }
419
+ catch (e) {
420
+ if (e instanceof BudgetExhausted) {
421
+ // #236 — the synchronous half: a depleted gig also burned real dollars before it
422
+ // stopped, and the operator needs them in the same reply as the depletion notice.
423
+ const partial = partialGigUsage(e);
424
+ return { ok: false, requires_approval: approval, error: e.message,
425
+ data: { budget_exhausted: true, agent_slug: e.agent_slug, balance: e.balance, cost: e.cost, budget_state: e.state,
426
+ ...(partial ? { usage: partial } : {}) } };
427
+ }
428
+ throw e;
429
+ }
430
+ }
431
+ // Async mode (default) — register live state, run in the background, return the id
432
+ // immediately so the caller can poll gig_monitor + tail the per-chair logs instead of
433
+ // blocking for the whole run ("synchronous dispatch is not a good pattern").
434
+ const gigId = randomUUID();
435
+ const runs = deps.gig_runs ?? (deps.gig_runs = new Map());
436
+ const state = newGigRun(gigId, slug2, standard.phases.length, new Date().toISOString());
437
+ // #249/#250 — the cancellation handle, held for as long as the run is live. This is the
438
+ // object gig_abort reaches; before it existed there was nothing to reach.
439
+ const controller = new AbortController();
440
+ state.controller = controller;
441
+ runs.set(gigId, state);
442
+ // #253 — bound the live-run map. Only settled entries are dropped, so a running gig's
443
+ // controller is never pruned out from under gig_abort.
444
+ pruneGigRuns(runs);
445
+ const logDir = deps.gig_log_base ? join(deps.gig_log_base, "gigs", gigId) : undefined;
446
+ const onProgress = (ev) => {
447
+ applyGigProgress(state, ev);
448
+ // tee each chair's child events to its own jsonl — the agent-layer log
449
+ if (logDir && ev.type === "agent_event") {
450
+ try {
451
+ mkdirSync(logDir, { recursive: true });
452
+ appendFileSync(join(logDir, `${ev.role}.jsonl`), JSON.stringify(ev.event) + "\n");
453
+ }
454
+ catch { /* best-effort */ }
455
+ }
456
+ // compact milestone line to stderr (captured in the MCP log)
457
+ const line = gigEventLogLine(gigId, ev);
458
+ if (line) {
459
+ try {
460
+ process.stderr.write(line + "\n");
461
+ }
462
+ catch { /* best-effort */ }
463
+ }
464
+ };
465
+ void runGig(standard, gigInput, {
466
+ outputs: deps.outputs, ledger: deps.ledger, invoke: deps.invoke,
467
+ model_version: deps.model_version, skills: deps.skills, skill_dirs: deps.skill_dirs, evals: deps.evals, budget,
468
+ gig_id: gigId, onProgress, signal: controller.signal, ...(depth ? { depth } : {}),
469
+ })
470
+ .then((res) => {
471
+ state.status = "complete";
472
+ state.finished_at = new Date().toISOString();
473
+ state.run_fingerprint = res.run_fingerprint;
474
+ state.genome_hash = res.genome_hash;
475
+ state.outputs_count = res.outputs.length;
476
+ if (res.usage)
477
+ state.usage = res.usage; // #195 — surface settled spend to gig_monitor
478
+ // #236 — the synchronous reply has carried budget_state since the budget existed;
479
+ // the async path never did, so the DEFAULT dispatch mode could not answer "what did
480
+ // this consume?" even on success.
481
+ if (res.budget_state)
482
+ state.budget_state = res.budget_state;
483
+ })
484
+ .catch((e) => {
485
+ state.finished_at = new Date().toISOString();
486
+ if (e instanceof GigAborted) {
487
+ // A cancelled run is not a crashed run (#251). And the spend it already accrued
488
+ // still has to land: killing children without recording accrued usage would turn
489
+ // a recorded cost into an unrecorded one.
490
+ state.status = "aborted";
491
+ state.abort_reason = e.reason;
492
+ state.outputs_count = e.outputs.length;
493
+ if (e.usage)
494
+ state.usage = e.usage;
495
+ return;
496
+ }
497
+ state.status = "failed";
498
+ state.error = e instanceof Error ? e.message : String(e);
499
+ // #236 — settled spend used to die here. Async is the DEFAULT dispatch mode, so
500
+ // every failed or aborted gig reported zero dollars while its completed chairs'
501
+ // outputs persisted on disk. Failed runs are the ones whose cost matters most.
502
+ const partial = partialGigUsage(e);
503
+ if (partial)
504
+ state.usage = partial;
505
+ // ...and the budget half of the same loss: the runtime attaches the snapshot to
506
+ // whatever it throws, but nothing read it back here. A depleted or crashed gig
507
+ // could not say how much of its allowance it had already burned.
508
+ const bs = partialBudgetState(e);
509
+ if (bs)
510
+ state.budget_state = bs;
511
+ onProgress({ type: "gig_failed", error: state.error });
512
+ })
513
+ .finally(() => { state.controller = undefined; }); // don't pin a controller past settle
514
+ return {
515
+ ok: true, requires_approval: approval,
516
+ data: { gig_id: gigId, status: "running", ...(depth ? { depth } : {}), ...(logDir ? { log_dir: logDir } : {}) },
517
+ };
518
+ }
519
+ case "gig_monitor": {
520
+ const gid = String(args["gig_id"] ?? "");
521
+ // Prefer the live state map (async runs). Falls back to the ledger/outputs read for a
522
+ // synchronously-completed gig (or one from a prior server lifetime, not in the map).
523
+ const live = deps.gig_runs?.get(gid);
524
+ if (live) {
525
+ const outs = deps.outputs.all().filter((o) => o.gig_id === gid);
526
+ return {
527
+ ok: true, requires_approval: approval,
528
+ data: {
529
+ status: live.status,
530
+ standard_slug: live.standard_slug,
531
+ current_phase: live.current_phase ?? null,
532
+ phases_total: live.phases_total,
533
+ phases_complete: live.phases_seen.length,
534
+ chairs: Object.values(live.chairs),
535
+ outputs_count: live.outputs_count,
536
+ outputs_so_far: outs,
537
+ ...(live.run_fingerprint ? { run_fingerprint: live.run_fingerprint } : {}),
538
+ ...(live.usage ? { usage: live.usage } : {}), // #195 — settled model spend, queryable by gig_id
539
+ // #236 — what the gig consumed of its allowance, on BOTH terminal paths. Carries
540
+ // `unit: "append-units"` and the real `settled_usd` alongside (#233), so nothing
541
+ // reads the synthetic proxy as dollars.
542
+ ...(live.budget_state ? { budget_state: live.budget_state } : {}),
543
+ ...(live.abort_reason ? { abort_reason: live.abort_reason } : {}), // why it stopped (#251)
544
+ ...(live.error ? { error: live.error } : {}),
545
+ ...(live.finished_at ? { finished_at: live.finished_at } : {}),
546
+ },
547
+ };
548
+ }
549
+ const outs = deps.outputs.all().filter((o) => o.gig_id === gid);
550
+ const entry = deps.ledger.query({ kind: "gig", gig_id: gid })[0];
551
+ return {
552
+ ok: true, requires_approval: approval,
553
+ data: {
554
+ status: entry ? "complete" : outs.length > 0 ? "running" : "unknown",
555
+ phases_complete: outs.length,
556
+ current_agent: outs.length ? outs[outs.length - 1].agent_slug : null,
557
+ outputs_so_far: outs,
558
+ // #195 — settled spend from the ledger (post-restart path). Only a gig row carries usage.
559
+ ...(entry?.kind === "gig" && entry.usage ? { usage: entry.usage } : {}),
560
+ },
561
+ };
562
+ }
563
+ case "gig_logs": {
564
+ // The agent-layer transcript, served (not hand-read off disk). gig_monitor gives the
565
+ // coltrane-layer summary; this returns each chair's child events from the per-chair
566
+ // jsonl the async dispatcher tees. Filter by role and/or event type; tail the last N.
567
+ const gid = String(args["gig_id"] ?? "");
568
+ const roleFilter = args["role"] ? String(args["role"]) : undefined;
569
+ const typeFilter = args["type"] ? String(args["type"]) : undefined;
570
+ const tail = typeof args["tail"] === "number" ? args["tail"] : undefined;
571
+ const dir = deps.gig_log_base ? join(deps.gig_log_base, "gigs", gid) : undefined;
572
+ if (!dir || !existsSync(dir)) {
573
+ return { ok: true, requires_approval: approval, data: { gig_id: gid, roles: [], count: 0, events: [] } };
574
+ }
575
+ const roleFiles = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).map((f) => f.slice(0, -6));
576
+ const roles = roleFilter ? roleFiles.filter((r) => r === roleFilter) : roleFiles;
577
+ const events = [];
578
+ for (const role of roles) {
579
+ const lines = readFileSync(join(dir, `${role}.jsonl`), "utf8").split("\n").filter(Boolean);
580
+ for (const l of lines) {
581
+ try {
582
+ const e = JSON.parse(l);
583
+ if (typeFilter && e["type"] !== typeFilter)
584
+ continue;
585
+ events.push({ role, ...e });
586
+ }
587
+ catch { /* skip malformed */ }
588
+ }
589
+ }
590
+ const sliced = tail !== undefined ? events.slice(-tail) : events;
591
+ return { ok: true, requires_approval: approval, data: { gig_id: gid, roles, count: events.length, events: sliced } };
592
+ }
593
+ case "tool_registry_browse": {
594
+ let tools = [...MCP_TOOLS];
595
+ if (args["category"])
596
+ tools = tools.filter((t) => t.category === args["category"]);
597
+ return { ok: true, requires_approval: approval, data: { tools: tools.map((t) => ({ slug: t.slug, category: t.category })), usage_stats: [], dependency_map: {} } };
598
+ }
599
+ case "standard_compose": {
600
+ try {
601
+ const sSlug = String(args["slug"] ?? "");
602
+ const sDomain = String(args["domain"] ?? "");
603
+ // Rob #132 — resolve agent slugs from the genome. Before: clients had
604
+ // to round-trip the full Agent JSON (slug + primitives + types + ...)
605
+ // because composeStandard does a slug-keyed lookup and would NPE on
606
+ // plain string slugs. Now: when an entry is a string or a slug-only
607
+ // object, look it up in deps.agents (populated by bootstrap from the
608
+ // loaded genome). Full Agent objects are passed through unchanged.
609
+ const sAgentsRaw = args["agents"] ?? [];
610
+ const sAgents = sAgentsRaw.map((a) => {
611
+ if (typeof a === "string") {
612
+ const loaded = deps.agents?.get(a);
613
+ if (!loaded)
614
+ throw new CompositionError(`agent "${a}" not found in genome`);
615
+ return loaded;
616
+ }
617
+ if (a && typeof a === "object" && "slug" in a && !("primitives" in a)) {
618
+ const slug = a.slug;
619
+ const loaded = deps.agents?.get(slug);
620
+ if (loaded)
621
+ return loaded;
622
+ throw new CompositionError(`agent "${slug}" not found in genome (slug-only object form)`);
623
+ }
624
+ return a;
625
+ });
626
+ const sPhases = args["phases"] ?? [];
627
+ // Carry EVERY passthrough field the schema declares (eval_slugs, input_types, output_types,
628
+ // max_examine_rounds, description, …) through compose → live map → persisted file. The
629
+ // handler used to thread only eval_slugs, silently dropping input_types/output_types/
630
+ // max_examine_rounds/description on both the compose call AND the persisted file — so a
631
+ // standard authored via the TOOL lost exactly the fields composeStandard preserves on the
632
+ // FILE path (audit finding D). Copy by the schema's own key list (STD_PASSTHROUGH) so it
633
+ // can't re-drift.
634
+ const extras = {};
635
+ for (const k of STD_PASSTHROUGH)
636
+ if (args[k] !== undefined)
637
+ extras[k] = args[k];
638
+ const std = composeStandard({ slug: sSlug, domain: sDomain, agents: sAgents, phases: sPhases, ...extras });
639
+ // Write-through to the LIVE map so gig_dispatch sees the new standard
640
+ // in the same session (no rebootstrap needed).
641
+ deps.standards?.set(sSlug, std);
642
+ // substrate seal: persist a loadable standards/<slug>.json (agent_slugs form) + ledger.
643
+ const fileDef = { slug: sSlug, domain: sDomain, agent_slugs: sAgents.map((a) => a.slug), phases: sPhases, ...extras };
644
+ const sealed = sealDefinition("standard_compose", sSlug, fileDef, deps.ledger, deps.genome_dir, "standards");
645
+ return { ok: true, requires_approval: approval, data: { standard_id: std.slug, content_hash: sealed.content_hash, dependency_hash: sealed.dependency_hash, effective_hash: sealed.effective_hash, validation_result: { valid: true } } };
646
+ }
647
+ catch (e) {
648
+ if (e instanceof CompositionError)
649
+ return { ok: false, requires_approval: approval, error: e.message, data: { validation_result: { valid: false, error: e.message } } };
650
+ throw e;
651
+ }
652
+ }
653
+ case "agent_validate_pipeline": {
654
+ if (Array.isArray(args["primitives"])) {
655
+ try {
656
+ defineAgent({
657
+ slug: String(args["slug"] ?? "pipeline-check"),
658
+ primitives: args["primitives"],
659
+ // synthetic agent: only the primitive progression is under test here, so the
660
+ // behavioral fields are stubs that satisfy defineAgent's required-field gate.
661
+ identity: "pipeline validation stub",
662
+ method: "validate the primitive progression",
663
+ constraints: [],
664
+ behavioral_primitives: ["analyst", "critic"],
665
+ });
666
+ return { ok: true, requires_approval: approval, data: { valid: true, errors: [], illegal_progressions: [], unsatisfied_inputs: [] } };
667
+ }
668
+ catch (e) {
669
+ if (e instanceof CompositionError)
670
+ return { ok: true, requires_approval: approval, data: { valid: false, errors: [e.message], illegal_progressions: [e.message], unsatisfied_inputs: [] } };
671
+ throw e;
672
+ }
673
+ }
674
+ try {
675
+ composeStandard({
676
+ slug: String(args["standard_slug"] ?? "pipeline-check"),
677
+ domain: String(args["domain"] ?? ""),
678
+ agents: (args["agents"] ?? []),
679
+ phases: (args["phases"] ?? []),
680
+ });
681
+ return { ok: true, requires_approval: approval, data: { valid: true, errors: [], illegal_progressions: [], unsatisfied_inputs: [] } };
682
+ }
683
+ catch (e) {
684
+ if (e instanceof CompositionError)
685
+ return { ok: true, requires_approval: approval, data: { valid: false, errors: [e.message], illegal_progressions: [e.message], unsatisfied_inputs: [] } };
686
+ throw e;
687
+ }
688
+ }
689
+ case "type_extend": {
690
+ // resolve the base type from the registry, propose the field additions.
691
+ const baseDef = deps.registry.listTypes().find((t) => t.slug === args["slug"]);
692
+ if (!baseDef)
693
+ return { ok: false, requires_approval: approval, error: `unknown type "${String(args["slug"])}"` };
694
+ const baseProps = baseDef.schema.properties ?? {};
695
+ const extension = args["extension"] ?? undefined;
696
+ const addProps = extension?.schema?.properties ??
697
+ args["fields_to_add"] ?? {};
698
+ const nextProps = { ...baseProps, ...addProps };
699
+ const nextRequired = extension?.schema?.required ?? baseDef.required_fields;
700
+ const base = {
701
+ slug: baseDef.slug, version: 1, extends: baseDef.extends, domain: baseDef.domain,
702
+ status: "active", schema: { type: "object", properties: baseProps }, required_fields: baseDef.required_fields,
703
+ };
704
+ const next = {
705
+ ...base, schema: { type: "object", properties: nextProps }, required_fields: nextRequired,
706
+ };
707
+ const proposal = proposeTypeChange(base, next);
708
+ const newFields = Object.keys(nextProps).length - Object.keys(baseProps).length;
709
+ // substrate seal: the new version's identity is recorded in the ledger (file
710
+ // materialization of versioned types follows the version-aware loader path).
711
+ const versioned = { ...next, version: proposal.next_version };
712
+ const tx = deps.genome_dir ? recordIdentity("type_extend", `${base.slug}@v${proposal.next_version}`, versioned, deps.ledger) : undefined;
713
+ return { ok: true, requires_approval: proposal.approval_required, data: { new_version: proposal.next_version, changelog_entry: `${proposal.change_class}: +${newFields} field(s)`, change_class: proposal.change_class, effective_hash: tx?.effective_hash, content_hash: tx?.content_hash } };
714
+ }
715
+ case "charter_read": {
716
+ const path = args["path"] ? String(args["path"]) : "";
717
+ if (!path)
718
+ return { ok: false, requires_approval: approval, error: "charter_read: path required (no default charter location)" };
719
+ if (!existsSync(path))
720
+ return { ok: false, requires_approval: approval, error: `charter_read: file not found at ${path}` };
721
+ try {
722
+ const raw = JSON.parse(readFileSync(path, "utf-8"));
723
+ const ch = loadCharter(raw);
724
+ return { ok: true, requires_approval: approval, data: ch };
725
+ }
726
+ catch (e) {
727
+ if (e instanceof CharterError)
728
+ return { ok: false, requires_approval: approval, error: e.message };
729
+ throw e;
730
+ }
731
+ }
732
+ case "charter_suggest_update": {
733
+ // Validate BEFORE appending. The append-first shape let `charter_suggest_update({})`
734
+ // pump a permanent, content-free row into a store with no compaction and no retention.
735
+ // session_review_write was already the correct pattern in this file.
736
+ const field = String(args["field"] ?? "");
737
+ if (!field) {
738
+ return { ok: false, requires_approval: approval, error: "charter_suggest_update requires field" };
739
+ }
740
+ const proposal_id = randomUUID();
741
+ deps.ledger.append(governanceRow("charter_suggest_update", field, {
742
+ proposal_id,
743
+ current_value: args["current_value"] ?? null,
744
+ suggested_value: args["suggested_value"] ?? null,
745
+ evidence: args["evidence"] ?? null,
746
+ }));
747
+ return {
748
+ ok: true, requires_approval: true,
749
+ data: {
750
+ proposal_id,
751
+ field,
752
+ current_value: args["current_value"] ?? null,
753
+ suggested_value: args["suggested_value"] ?? null,
754
+ evidence: args["evidence"] ?? null,
755
+ },
756
+ };
757
+ }
758
+ case "system_health": {
759
+ // #216 — GIG rows, not every row. `count()` used to be the raw row total, so every
760
+ // agent_define, promotion, proposal, review, tool_register and abort inflated the
761
+ // reported gig count AND the derived cost AND the reported budget spend.
762
+ // ONE read, two derivations. These were a `count()` and a separate `query()`, each a
763
+ // full file read for FileLedger — so an append landing between them made `gigs_run`
764
+ // and `cost` describe different ledgers IN THE SAME RESPONSE. `read()`'s own docstring
765
+ // promises "a single read pass shared by query / count / integrity, so the three can
766
+ // never disagree"; the call site was undoing that.
767
+ const gigRows = deps.ledger.query({ kind: "gig" });
768
+ const gigs_run = gigRows.length;
769
+ // Settled spend where we have it (#195) — a real number now that gig rows are
770
+ // separable, instead of a row-count proxy standing in for dollars.
771
+ const cost = gigRows.reduce((sum, e) => sum + (e.kind === "gig" ? e.usage?.total_cost_usd ?? 0 : 0), 0);
772
+ // #255 — both audit surfaces compute an honest damage report and nothing ever asked
773
+ // for it: `integrity` had ZERO call sites in this file. `load_errors` below is the
774
+ // precedent — a soft-failure channel surfaced here because CLAUDE.md sends operators
775
+ // to system_health first. Corruption belongs in the same place and reads as loudly.
776
+ const ledger_integrity = deps.ledger.integrity();
777
+ const outputs_integrity = deps.outputs.integrity();
778
+ // What we can actually justify saying about the totals below. `countsShort` is only
779
+ // ever set from damage we FOUND; nothing here infers completeness from its absence.
780
+ const countsShort = !ledger_integrity.ok || !outputs_integrity.ok;
781
+ const damaged = [
782
+ ...(ledger_integrity.ok ? [] : [`ledger (${ledger_integrity.corrupt.length} unreadable line(s))`]),
783
+ ...(outputs_integrity.ok ? [] : [`output store (${outputs_integrity.corrupt.length} unreadable line(s))`]),
784
+ ];
785
+ const countsBasis = countsShort
786
+ ? `counts are SHORT: ${damaged.join(" and ")} — every total below is computed over the rows that parsed. ` +
787
+ `Note the output store's report also covers the refs graph, which feeds only \`refs\`; if the damage is ` +
788
+ `confined there the other totals may in fact be whole.`
789
+ : `no unreadable line was found (ledger: ${ledger_integrity.entries} entries; output store: ` +
790
+ `${outputs_integrity.scanned} file(s) scanned). That is NOT proof the counts are complete — a jsonl ` +
791
+ `truncated at a line boundary loses whole rows without leaving a parse error, and an in-memory ledger ` +
792
+ `or a store with no persistDir has nothing to scan at all.`;
793
+ const outs = deps.outputs.all();
794
+ const type_stats = {};
795
+ const agent_stats = {};
796
+ for (const o of outs) {
797
+ type_stats[o.domain_type] = (type_stats[o.domain_type] ?? 0) + 1;
798
+ agent_stats[o.agent_slug] = (agent_stats[o.agent_slug] ?? 0) + 1;
799
+ }
800
+ return {
801
+ ok: true, requires_approval: approval,
802
+ data: {
803
+ gigs_run, cost, type_stats, agent_stats,
804
+ types: deps.registry.listTypes().length, outputs: outs.length, refs: deps.outputs.refs().length,
805
+ tool_stats: {}, bottlenecks: [], budget: { spent: cost, remaining: null },
806
+ // Rob #129 — surface what was skipped at load so operators see broken files
807
+ load_errors: deps.load_errors ?? [],
808
+ // #255 — the damage reports, and an honest label on everything derived from them.
809
+ ledger_integrity,
810
+ outputs_integrity,
811
+ // gigs_run / cost / outputs / type_stats / agent_stats are all computed over the
812
+ // rows that PARSED, so a corrupt line makes every one of them SHORT.
813
+ //
814
+ // ROUND 2 — this is `false` or `null` and NEVER `true`, deliberately.
815
+ //
816
+ // The first version was `ledger_integrity.ok && outputs_integrity.ok`, which
817
+ // claimed completeness from the absence of a parse error. That does not follow.
818
+ // A jsonl truncated at a LINE BOUNDARY — the likeliest way an append-only file
819
+ // gets damaged, and what an interrupted write usually leaves — loses whole rows
820
+ // without leaving anything unparseable behind. Both reports come back clean and
821
+ // the counts are still short. Worse, the predicate was structurally constant in
822
+ // real deployments: `MemoryLedger.integrity()` is unconditionally ok, and a store
823
+ // with no persistDir has nothing to scan, so a server wired that way could never
824
+ // report anything but `true`.
825
+ //
826
+ // That is precisely the #238 pattern this surface exists to oppose — a hardcoded
827
+ // affirmative dressed as a measurement. Corruption we FOUND is provable;
828
+ // completeness is not. So the field states only what can be known, and the basis
829
+ // says why, following #238's own remedy: a labelled null is an answer, a
830
+ // fabricated attestation is not.
831
+ counts_complete: countsShort ? false : null,
832
+ counts_complete_basis: countsBasis,
833
+ // genome extension — per-slug layer provenance, queryable at runtime (e.g.
834
+ // a consumer checking "is this player coming from where I expect" before composing)
835
+ provenance: deps.provenance ? Object.fromEntries(deps.provenance) : {},
836
+ },
837
+ };
838
+ }
839
+ case "genome_reload": {
840
+ // Rob #130 — re-read the genome from disk and update deps in place. No
841
+ // MCP server restart needed; the user's Claude Code session keeps its
842
+ // conversational context.
843
+ if (!deps.genome_dir) {
844
+ return { ok: false, requires_approval: approval, error: "genome_reload requires deps.genome_dir; this server wasn't bootstrapped from a genome directory" };
845
+ }
846
+ const fresh = resolveGenome(deps.genome_dir);
847
+ // Diff each definition class against the live deps.
848
+ const standardsBefore = new Map(deps.standards ?? []);
849
+ const skillsBefore = new Map(deps.skills ?? []);
850
+ const evalsBefore = new Map(deps.evals ?? []);
851
+ const typesBefore = new Map(deps.registry.listTypes().map((t) => [t.slug, t]));
852
+ // domain_types — registry.replaceTypes does the mutation + diff.
853
+ const typeDefs = [...fresh.domain_types.values()].map((d) => ({
854
+ slug: d.slug,
855
+ extends: d.extends,
856
+ domain: d.domain,
857
+ schema: d.schema,
858
+ required_fields: [...d.required_fields],
859
+ }));
860
+ const typeDiff = deps.registry.replaceTypes(typeDefs);
861
+ // standards — mutate in place so callers holding deps.standards see updates.
862
+ const standardsDiff = syncMap(deps.standards, fresh.standards, standardsBefore);
863
+ const skillsDiff = syncMap(deps.skills, fresh.skills, skillsBefore);
864
+ const evalsDiff = syncMap(deps.evals, fresh.evals, evalsBefore);
865
+ // Refresh surfaced load_errors so the next system_health call sees them.
866
+ deps.load_errors = [...fresh.load_errors];
867
+ // agents — diff against deps.agents (the prior-load snapshot) then
868
+ // mutate deps.agents in place so the next reload sees the new baseline.
869
+ const agentsBefore = new Map(deps.agents ?? []);
870
+ const agentsDiff = syncMap(deps.agents, fresh.agents, agentsBefore);
871
+ // typesBefore is captured for symmetry; not currently surfaced beyond typeDiff.
872
+ void typesBefore;
873
+ return {
874
+ ok: true, requires_approval: approval,
875
+ data: {
876
+ reloaded: true,
877
+ changes: {
878
+ added: {
879
+ domain_types: typeDiff.added,
880
+ standards: standardsDiff.added,
881
+ skills: skillsDiff.added,
882
+ evals: evalsDiff.added,
883
+ agents: agentsDiff.added,
884
+ },
885
+ modified: {
886
+ domain_types: typeDiff.modified,
887
+ standards: standardsDiff.modified,
888
+ skills: skillsDiff.modified,
889
+ evals: evalsDiff.modified,
890
+ agents: agentsDiff.modified,
891
+ },
892
+ removed: {
893
+ domain_types: typeDiff.removed,
894
+ standards: standardsDiff.removed,
895
+ skills: skillsDiff.removed,
896
+ evals: evalsDiff.removed,
897
+ agents: agentsDiff.removed,
898
+ },
899
+ },
900
+ load_errors: deps.load_errors,
901
+ },
902
+ };
903
+ }
904
+ case "server_restart": {
905
+ // PR #141 — the relay parent-process intercepts this call before it
906
+ // reaches the server child. If execution reaches this handler, the
907
+ // relay is misconfigured (typically: COLTRANE_SERVER_DIRECT=1 was
908
+ // set, bypassing the relay) and the conversation will lose its pipe
909
+ // if the server is killed.
910
+ //
911
+ // The registry spec exists for discoverability (tool_inspect,
912
+ // system_audit). This guard turns "silent miss" into "loud error"
913
+ // when the relay isn't catching.
914
+ return {
915
+ ok: false,
916
+ requires_approval: approval,
917
+ error: "server_restart was not intercepted by the relay; the server child cannot restart itself in place. This usually means COLTRANE_SERVER_DIRECT=1 was set on the parent process, so the relay was skipped. Restart Claude Code without that env var (or use Rob's pre-relay workaround: `claude mcp remove coltrane -s local` → `claude mcp add coltrane node /path/to/dist/src/server_entry.js` → `/branch` → `claude -r <session-id>`). See docs/mcp_hot_reload.md.",
918
+ };
919
+ }
920
+ case "health_check": {
921
+ const targetSlug = String(args["slug"] ?? "");
922
+ const targetKind = String(args["kind"] ?? args["entity_type"] ?? "");
923
+ const all = deps.outputs.all();
924
+ // standards live in the ledger (executions); agents/types in the outputs store.
925
+ const gigRows = targetKind === "standard"
926
+ ? deps.ledger.query({ kind: "gig", standard_slug: targetSlug }).filter(isGig)
927
+ : [];
928
+ const execution_count = gigRows.length;
929
+ const filtered = targetKind === "agent"
930
+ ? all.filter((o) => o.agent_slug === targetSlug)
931
+ : targetKind === "standard"
932
+ ? []
933
+ : all.filter((o) => o.domain_type === targetSlug);
934
+ const output_count = targetKind === "standard" ? execution_count : filtered.length;
935
+ // #238 — REAL dollars. `cost: output_count` reported "2" for $1.25 of spend; the engine
936
+ // has carried settled model spend on the gig row since #195, so the proxy is now simply
937
+ // a wrong number where a right one is available.
938
+ const cost_usd = targetKind === "standard"
939
+ ? gigRows.reduce((s, e) => s + (e.usage?.total_cost_usd ?? 0), 0)
940
+ : filtered.reduce((s, o) => s + (o.cost_usd ?? 0), 0);
941
+ return {
942
+ ok: true, requires_approval: approval,
943
+ data: {
944
+ entity: targetSlug, kind: targetKind, output_count, execution_count,
945
+ usage: output_count,
946
+ cost: cost_usd, cost_usd,
947
+ cost_basis: targetKind === "standard"
948
+ ? "settled model spend summed over this standard's gig rows (#195)"
949
+ : "sum of per-output cost_usd; unset on model-invoked outputs today, so 0 can mean 'not recorded'",
950
+ // #238 — these were the literal constants 1.0 and "stable" for ANY entity. An agent
951
+ // that failed every dispatch it ever ran reported a 100% success rate, and it COULD
952
+ // NOT report otherwise: a failed gig writes no ledger row, so the denominator does
953
+ // not exist. A fabricated measurement presented as a measurement is worse than a
954
+ // missing one, because the missing one gets investigated. null + a stated reason.
955
+ success_rate: null,
956
+ success_rate_basis: "unavailable — a failed gig writes no ledger row, so the denominator does not exist; " +
957
+ "any rate computed from what IS recorded would be 1.0 by construction (#236)",
958
+ trend: null,
959
+ trend_basis: "unavailable — no time-windowed execution history is retained to compare against",
960
+ recommendations: [],
961
+ },
962
+ };
963
+ }
964
+ case "system_audit": {
965
+ // Real derivation over the genome: a registered domain type with zero
966
+ // outputs is an unused type — the canonical audit finding in v0.
967
+ const types = deps.registry.listTypes();
968
+ const usedTypes = new Set(deps.outputs.all().map((o) => o.domain_type));
969
+ const unused_types = types.filter((t) => !usedTypes.has(t.slug)).map((t) => t.slug);
970
+ const findings = unused_types.map((slug) => ({ kind: "unused_type", slug, severity: "info" }));
971
+ return { ok: true, requires_approval: approval, data: { findings, unused_types, type_count: types.length, output_count: deps.outputs.all().length } };
972
+ }
973
+ case "tool_propose": {
974
+ const proposal_id = randomUUID();
975
+ return { ok: true, requires_approval: true, data: { proposal_id } };
976
+ }
977
+ case "tool_deprecate_propose": {
978
+ const proposal_id = randomUUID();
979
+ return { ok: true, requires_approval: true, data: { proposal_id, affected_agents: [] } };
980
+ }
981
+ case "proposal_create": {
982
+ const change_type = String(args["change_type"] ?? "");
983
+ const target = String(args["target"] ?? "");
984
+ if (!change_type || !target) {
985
+ return { ok: false, requires_approval: approval, error: "proposal_create requires change_type and target" };
986
+ }
987
+ const proposal_id = randomUUID();
988
+ deps.ledger.append(governanceRow("proposal_create", target, {
989
+ proposal_id, change_type, reason: args["reason"] ?? null,
990
+ target_kind: args["target_kind"] ?? null,
991
+ }));
992
+ return {
993
+ ok: true, requires_approval: approval,
994
+ data: { proposal_id, cascade_impact: { agents_affected: [], standards_affected: [] } },
995
+ };
996
+ }
997
+ case "capability_research": {
998
+ // Real local gap-search over the genome: does any existing tool or domain
999
+ // type already cover the asked-for capability? If nothing matches, it's a gap.
1000
+ const q = String(args["query"] ?? args["capability"] ?? "").toLowerCase();
1001
+ const toolMatches = q ? MCP_TOOLS.filter((t) => t.slug.toLowerCase().includes(q)).map((t) => t.slug) : [];
1002
+ const typeMatches = q ? deps.registry.listTypes().filter((t) => t.slug.toLowerCase().includes(q)).map((t) => t.slug) : [];
1003
+ const existing_matches = [...toolMatches, ...typeMatches];
1004
+ const gap = existing_matches.length === 0;
1005
+ return {
1006
+ ok: true, requires_approval: approval,
1007
+ data: { query: q, existing_matches, gap, approaches: [], mcp_options: toolMatches, recommendation: gap ? "no existing capability — propose a new tool/type" : "reuse existing" },
1008
+ };
1009
+ }
1010
+ case "gig_abort": {
1011
+ const gid = String(args["gig_id"] ?? "");
1012
+ const reason = String(args["reason"] ?? "");
1013
+ // #249/#251 — the LIVE run map is the authority, consulted first. The old handler read
1014
+ // only the ledger + output store, which got the answer wrong in both directions: a gig
1015
+ // in its FIRST phase has sealed nothing, so it reported `not_found` for precisely the
1016
+ // window abort exists to serve; and post-restart every historical gig reported
1017
+ // `running`/`aborted:true` forever. gig_monitor already read this map, so the two tools
1018
+ // disagreed about the same gig_id in the same millisecond.
1019
+ const live = gid.length > 0 ? deps.gig_runs?.get(gid) : undefined;
1020
+ let status;
1021
+ let aborted = false;
1022
+ if (live) {
1023
+ if (live.status === "running") {
1024
+ live.abort_requested = true;
1025
+ live.abort_reason = reason || "aborted by operator";
1026
+ // THE actual cancellation. runGig stops at its next checkpoint (between phases /
1027
+ // between dispatch batches) and the invoker kills the chair's in-flight child.
1028
+ const controller = live.controller;
1029
+ if (controller) {
1030
+ try {
1031
+ controller.abort(live.abort_reason);
1032
+ }
1033
+ catch { /* an already-aborted signal is fine */ }
1034
+ aborted = true;
1035
+ }
1036
+ // A run registered without a controller (dispatched by an older code path) can be
1037
+ // MARKED but not stopped — say so rather than claiming a cancellation.
1038
+ status = aborted ? "aborting" : "running";
1039
+ }
1040
+ else {
1041
+ status = live.status === "aborted" ? "already_aborted"
1042
+ : live.status === "failed" ? "already_failed"
1043
+ : "already_complete";
1044
+ }
1045
+ }
1046
+ else {
1047
+ // No live run in THIS process. The stores can testify that a gig existed; they cannot
1048
+ // make it cancellable — so `aborted` stays false. An empty gig_id must not be probed:
1049
+ // query({gig_id: ""}) drops the filter entirely and would match every row, reporting a
1050
+ // phantom "already_complete".
1051
+ const completed = gid.length > 0 && deps.ledger.query({ gig_id: gid }).length > 0;
1052
+ const hasOutputs = gid.length > 0 && deps.outputs.all().some((o) => o.gig_id === gid);
1053
+ status = completed ? "already_complete" : hasOutputs ? "running" : "not_found";
1054
+ }
1055
+ // Record only a real abort. v1 appended REGARDLESS — including on not_found — so an
1056
+ // immutable row claimed a cancellation for a gig that never existed. `subject_gig_id`
1057
+ // is first-class so the abort surfaces in the aborted gig's own history; the v1
1058
+ // `abort:<gid>` namespace hid it from the only query that would look (#213).
1059
+ if (status !== "not_found") {
1060
+ deps.ledger.append(governanceRow("gig_abort", gid, { reason, status, cancelled: aborted }, gid));
1061
+ }
1062
+ return {
1063
+ ok: true, requires_approval: approval,
1064
+ data: { status, aborted, cancellable: aborted, cleanup_result: { reason } },
1065
+ };
1066
+ }
1067
+ case "agent_define": {
1068
+ // Build the def by the SCHEMA's own field list (genome_schema.ts AgentSchema): copy exactly
1069
+ // the fields the schema declares from args. This handler was one of the restatements that
1070
+ // DRIFTED — it read a RETIRED nested `permissions` object for the tuning fields (the generated
1071
+ // surface advertises them flat) and never read `browser_grant` at all, silently dropping the
1072
+ // cage grant on every MCP-authored agent. Iterating the schema keys keeps the write-path from
1073
+ // re-drifting (add a field to AgentSchema and it's copied automatically; none is invented). We
1074
+ // deliberately do NOT parse here: defineAgent (inside sealAgentDefinition) runs the structural
1075
+ // + composition checks and surfaces their precise typed errors — pre-parsing would mask a
1076
+ // composition error (e.g. CREATE with no upstream reasoning) behind a generic schema error.
1077
+ const built = {};
1078
+ for (const key of Object.keys(AgentSchema.shape)) {
1079
+ if (args[key] !== undefined)
1080
+ built[key] = args[key];
1081
+ }
1082
+ const def = built;
1083
+ // Governance gate: each allowed_tools slug must be registered. tool_propose
1084
+ // alone does NOT register; tool_register lands the slug. Unknown slugs are
1085
+ // rejected so the cage cannot grant scope to a tool the registry doesn't know.
1086
+ if (def.allowed_tools && def.allowed_tools.length > 0) {
1087
+ const unknown = def.allowed_tools.filter((s) => !REGISTERED_TOOL_SLUGS.has(s));
1088
+ if (unknown.length > 0) {
1089
+ return {
1090
+ ok: false,
1091
+ requires_approval: approval,
1092
+ error: `agent_define: unknown/unregistered allowed_tools slug${unknown.length > 1 ? "s" : ""}: ${unknown.join(", ")} — call tool_propose then tool_register first`,
1093
+ };
1094
+ }
1095
+ }
1096
+ // The substrate loop: validate → canonical hash → (if genome_dir) persist + ledger-seal.
1097
+ const sealed = sealAgentDefinition(def, deps.ledger, deps.genome_dir);
1098
+ return {
1099
+ ok: true,
1100
+ requires_approval: approval,
1101
+ data: {
1102
+ agent: sealed.agent,
1103
+ agent_profile_id: sealed.agent.slug,
1104
+ content_hash: sealed.content_hash,
1105
+ dependency_hash: sealed.dependency_hash,
1106
+ effective_hash: sealed.effective_hash,
1107
+ validation_result: { valid: true },
1108
+ },
1109
+ };
1110
+ }
1111
+ case "tool_register": {
1112
+ // Close the propose→register loop. Adds the slug to REGISTERED_TOOL_SLUGS
1113
+ // so subsequent agent_define calls can grant scope to it. The propose step
1114
+ // creates the proposal_id; this step lands the slug in the live registry.
1115
+ const targetSlug = String(args["slug"] ?? "");
1116
+ if (!targetSlug) {
1117
+ return { ok: false, requires_approval: approval, error: "tool_register requires slug" };
1118
+ }
1119
+ // #218 — SEAL BEFORE GRANTING. REGISTERED_TOOL_SLUGS is the capability gate that
1120
+ // decides whether agent_define may grant this slug. v1 mutated it (and toolProviders)
1121
+ // and only then appended, so a failed append left the tool registered and grantable,
1122
+ // the caller told the call failed, and no audit row at all — the audit trail could not
1123
+ // answer "who granted this capability, and when".
1124
+ const registration_id = randomUUID();
1125
+ deps.ledger.append(governanceRow("tool_register", targetSlug, { registration_id }));
1126
+ REGISTERED_TOOL_SLUGS.add(targetSlug);
1127
+ // Keep the #185 provider bridge live: a freshly-registered tool must resolve for a same-
1128
+ // session agent_define→dispatch (the registry and provider map share lifecycle).
1129
+ deps.toolProviders?.set(targetSlug, { tool: targetSlug, kind: "in_house" });
1130
+ return {
1131
+ ok: true,
1132
+ requires_approval: approval,
1133
+ data: { registered: true, slug: targetSlug, registration_id },
1134
+ };
1135
+ }
1136
+ case "agent_evolve": {
1137
+ // Real change-space classification: a permissions change needs approval,
1138
+ // a harmonic (type-graph) or creative (identity/method) change does not.
1139
+ const base = args["base"];
1140
+ const next = args["next"];
1141
+ const new_version = Number(args["new_version"] ?? ((base?.version ?? 0) + 1));
1142
+ if (base && next) {
1143
+ const change = proposeAgentChange(base, next);
1144
+ // For a creative-space change, return the lineage-threaded evolved profile
1145
+ // (version+1, parent_version=base.version) so the immutable chain reconstructs.
1146
+ const evolved = change.space === "creative"
1147
+ ? evolveProfile(base, { identity: next.identity, method: next.method, constraints: next.constraints })
1148
+ : null;
1149
+ // substrate seal: the evolved version's identity (lineage claim) is recorded in
1150
+ // the ledger when persisting — never a contract lie, even before file materialization.
1151
+ const ev = (evolved && deps.genome_dir) ? recordIdentity("agent_evolve", `${base.slug}@v${new_version}`, evolved, deps.ledger) : undefined;
1152
+ return {
1153
+ ok: true, requires_approval: change.approval_required,
1154
+ data: { space: change.space, approval_required: change.approval_required, type_check_passed: change.type_check_passed ?? null, new_version, evolved_profile: evolved, parent_version: evolved?.parent_version ?? base.version, effective_hash: ev?.effective_hash, content_hash: ev?.content_hash, cascade_check: { agents_affected: [], standards_affected: [] } },
1155
+ };
1156
+ }
1157
+ // (slug, changes) shape: apply a field-diff to a named genome agent, then
1158
+ // CASCADE — type-check every standard the agent is bound into and fail
1159
+ // CLOSED if any breaks, so a bad evolve can't corrupt a live pipeline.
1160
+ const evolveSlug = typeof args["slug"] === "string" ? args["slug"] : undefined;
1161
+ const changes = (args["changes"] && typeof args["changes"] === "object")
1162
+ ? args["changes"] : undefined;
1163
+ if (evolveSlug && changes && deps.genome_dir) {
1164
+ const agentPath = join(deps.genome_dir, "agents", `${evolveSlug}.json`);
1165
+ if (!existsSync(agentPath)) {
1166
+ return { ok: false, requires_approval: approval, error: `agent_evolve: unknown agent "${evolveSlug}" (no agents/${evolveSlug}.json)` };
1167
+ }
1168
+ const currentDef = JSON.parse(readFileSync(agentPath, "utf-8"));
1169
+ const nextDef = { ...currentDef, ...changes };
1170
+ // The agent must still be a legal composition on its own…
1171
+ try {
1172
+ defineAgent(nextDef);
1173
+ }
1174
+ catch (e) {
1175
+ if (e instanceof CompositionError) {
1176
+ return { ok: false, requires_approval: approval, error: `agent_evolve rejected: ${e.message}`, data: { cascade_check: { agents_affected: [], standards_affected: [] } } };
1177
+ }
1178
+ throw e;
1179
+ }
1180
+ // …and every standard it's bound into must still type-check.
1181
+ const standards_affected = [];
1182
+ for (const std of deps.standards?.values() ?? []) {
1183
+ if (!std.agents.some((a) => a.slug === evolveSlug))
1184
+ continue;
1185
+ const rebound = std.agents.map((a) => (a.slug === evolveSlug ? { ...a, ...changes } : a));
1186
+ // Carry the SAME passthrough fields the compose/file path carries — above all input_types,
1187
+ // the gig contract an entry chair reads its input_contract from. Threading only eval_slugs
1188
+ // dropped input_types, so the re-compose saw no gig inputs and wrongly rejected a valid
1189
+ // entry chair as "input not produced by any upstream chair" (#204), failing the cascade.
1190
+ const stdRec = std;
1191
+ const stdExtras = {};
1192
+ for (const k of STD_PASSTHROUGH)
1193
+ if (stdRec[k] !== undefined)
1194
+ stdExtras[k] = stdRec[k];
1195
+ try {
1196
+ composeStandard({ slug: std.slug, domain: std.domain, agents: rebound, phases: std.phases, ...stdExtras });
1197
+ standards_affected.push({ slug: std.slug, type_check_passed: true, errors: [] });
1198
+ }
1199
+ catch (e) {
1200
+ if (e instanceof CompositionError)
1201
+ standards_affected.push({ slug: std.slug, type_check_passed: false, errors: [e.message] });
1202
+ else
1203
+ throw e;
1204
+ }
1205
+ }
1206
+ // Fail closed: if any binding standard broke, persist NOTHING.
1207
+ const broken = standards_affected.filter((s) => !s.type_check_passed);
1208
+ if (broken.length > 0) {
1209
+ return {
1210
+ ok: false, requires_approval: approval,
1211
+ error: `agent_evolve rejected: ${broken.length} standard(s) fail type-check after the change: ${broken.map((b) => b.slug).join(", ")}`,
1212
+ data: { new_version, cascade_check: { agents_affected: [], standards_affected } },
1213
+ };
1214
+ }
1215
+ // Persist the evolved agent + ledger-seal, then re-bind the live
1216
+ // standards so genome_hash reflects the change on the next gig.
1217
+ const sealed = sealAgentDefinition(nextDef, deps.ledger, deps.genome_dir);
1218
+ for (const std of deps.standards?.values() ?? []) {
1219
+ const agentsArr = std.agents;
1220
+ for (let i = 0; i < agentsArr.length; i++) {
1221
+ if (agentsArr[i].slug === evolveSlug)
1222
+ agentsArr[i] = sealed.agent;
1223
+ }
1224
+ }
1225
+ return {
1226
+ ok: true, requires_approval: approval,
1227
+ data: { new_version, evolved: sealed.agent, content_hash: sealed.content_hash, effective_hash: sealed.effective_hash, cascade_check: { agents_affected: [], standards_affected } },
1228
+ };
1229
+ }
1230
+ return { ok: true, requires_approval: approval, data: { new_version, cascade_check: { agents_affected: [], standards_affected: [] } } };
1231
+ }
1232
+ case "access_grant_check": {
1233
+ // Real validation: TTL (is the grant live?) + optional plan-scope check
1234
+ // (does the proposed file set fit the grant's paths/limits?).
1235
+ const grant = args["grant"];
1236
+ if (grant) {
1237
+ const nowMs = typeof args["now_ms"] === "number" ? args["now_ms"] : Date.now();
1238
+ const ttl = checkGrantTTL(grant, nowMs);
1239
+ const plan = args["plan"];
1240
+ const planResult = plan ? validatePlanAgainstGrant(plan, grant) : { valid: true };
1241
+ const valid = ttl.valid && planResult.valid;
1242
+ return {
1243
+ ok: true, requires_approval: approval,
1244
+ data: { valid, granted: valid, ttl, plan_check: plan ? planResult : null, expires_in: ttl.remaining_ms ?? null, reason: ttl.reason ?? planResult.reason ?? null },
1245
+ };
1246
+ }
1247
+ const required = arr(args["required_permissions"]);
1248
+ return {
1249
+ ok: true, requires_approval: approval,
1250
+ data: { valid: required.length === 0, granted: required.length === 0, missing_permissions: required, expires_in: null },
1251
+ };
1252
+ }
1253
+ case "skill_define": {
1254
+ // The missing skill authoring tool. Persist (non-destructively) + ledger-seal
1255
+ // via the blessed write path, then write through to the LIVE skills map so a
1256
+ // gig in the same session resolves it into an agent's Skills layer.
1257
+ const skSlug = typeof args["slug"] === "string" ? args["slug"].trim() : "";
1258
+ if (!skSlug)
1259
+ return { ok: false, requires_approval: approval, error: "skill_define requires a non-empty slug" };
1260
+ // Validate against the single Zod source — skill_define is package-aware (meta + permission/
1261
+ // network + fixtures + code/md), not the retired flat {slug, domain, md}. Unknown keys are
1262
+ // dropped; a malformed declared field is rejected before the write/seal.
1263
+ const skParsed = SkillSchema.safeParse({ ...args, slug: skSlug });
1264
+ if (!skParsed.success) {
1265
+ const why = skParsed.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
1266
+ return { ok: false, requires_approval: approval, error: `skill_define: ${why}` };
1267
+ }
1268
+ const def = skParsed.data;
1269
+ // Persist the LOADABLE PACKAGE (skills/<slug>/…), not a flat skills/<slug>.json the loader
1270
+ // skips — otherwise a defined skill seals fine but vanishes on reload (audit finding E). The
1271
+ // loader hard-fails an incomplete package, so require what it requires up front: ≥1 fixture
1272
+ // (the skill's pre-registered contract) + a code and/or reasoning half. Refuse here rather
1273
+ // than write a package that would crash the next genome load.
1274
+ if (!Array.isArray(def.fixtures) || def.fixtures.length === 0) {
1275
+ return { ok: false, requires_approval: approval, error: "skill_define requires ≥1 fixture — a skill ships its pre-registered contract (the loader rejects a fixtureless package)" };
1276
+ }
1277
+ if (typeof def.code !== "string" && typeof def.md !== "string") {
1278
+ return { ok: false, requires_approval: approval, error: "skill_define requires a code half (code) and/or a reasoning half (md) — an empty package can't load" };
1279
+ }
1280
+ const sealed = sealSkillPackage(def, deps.ledger, deps.genome_dir);
1281
+ deps.skills?.set(skSlug, def);
1282
+ return { ok: true, requires_approval: approval, data: { skill_id: skSlug, content_hash: sealed.content_hash, dependency_hash: sealed.dependency_hash, effective_hash: sealed.effective_hash } };
1283
+ }
1284
+ case "agent_promote":
1285
+ case "standard_promote":
1286
+ case "skill_promote": {
1287
+ // §7 lifecycle promotion. Forward-only state-machine transition is recorded
1288
+ // as an immutable ledger event (parity with OG's append-not-mutate evolution
1289
+ // discipline). Status enum per entity class:
1290
+ // agent: draft → review → approved → active → retired
1291
+ // standard: draft → active → retired
1292
+ // skill: draft → testing → active → retired
1293
+ // Caller supplies (slug, status, [current]); when `current` is omitted the
1294
+ // call records the intent and skips the chain check (the writer is trusted
1295
+ // to know the prior state — same shape as OG handleAgentPromote).
1296
+ const order = slug === "agent_promote" ? AGENT_STATUS_ORDER :
1297
+ slug === "standard_promote" ? STANDARD_STATUS_ORDER :
1298
+ SKILL_STATUS_ORDER;
1299
+ const targetSlug = String(args["slug"] ?? "");
1300
+ const target = String(args["status"] ?? "");
1301
+ const current = args["current"] != null ? String(args["current"]) : null;
1302
+ if (!targetSlug || !target) {
1303
+ return { ok: false, requires_approval: approval, error: "missing slug or status" };
1304
+ }
1305
+ try {
1306
+ if (current != null)
1307
+ checkPromotion(current, target, order);
1308
+ else if (order.indexOf(target) < 0)
1309
+ throw new PromotionError(`unknown target status "${target}"`);
1310
+ }
1311
+ catch (e) {
1312
+ if (e instanceof PromotionError) {
1313
+ return { ok: false, requires_approval: approval, error: e.message };
1314
+ }
1315
+ throw e;
1316
+ }
1317
+ // #254 — VALIDITY, not just transition legality. v1 checked only that the status move
1318
+ // was forward-legal and never looked at the definition at all: `targetSlug` was used
1319
+ // solely as a ledger subject, so a slug naming NOTHING promoted to `active` happily.
1320
+ //
1321
+ // Promotion is the transition that grants a definition production status. A definition
1322
+ // that could not be CREATED must not be able to become ACTIVE — otherwise the write-path
1323
+ // gate is a fiction, because anything already sitting at `draft` walks straight past it.
1324
+ // The check run here is deliberately the LOADER'S OWN check, not a parallel one: a
1325
+ // promote that validates differently from the loader is exactly the drift that produced
1326
+ // #254. (The loader's hard-fail stays too — hand-edited JSON is a deliberately open path
1327
+ // per CLAUDE.md, so the write path makes a malformed definition hard to create and the
1328
+ // load path makes it impossible to use.)
1329
+ //
1330
+ // An ABSENT genome map means the server was never bootstrapped from a genome, so absence
1331
+ // is not evidence that the slug names nothing — same discipline the runtime applies to an
1332
+ // absent skills map. bootstrapServerDeps always populates all three.
1333
+ const notFound = (kind) => ({
1334
+ ok: false, requires_approval: approval,
1335
+ error: `${slug}: no ${kind} "${targetSlug}" in the genome — a promotion names a definition that must already exist (define it first, or fix the slug)`,
1336
+ });
1337
+ if (slug === "agent_promote" && deps.agents) {
1338
+ const ag = deps.agents.get(targetSlug);
1339
+ if (!ag)
1340
+ return notFound("agent");
1341
+ try {
1342
+ defineAgent(ag); // the loader's own gate
1343
+ }
1344
+ catch (e) {
1345
+ return {
1346
+ ok: false, requires_approval: approval,
1347
+ error: `${slug}: agent "${targetSlug}" does not pass validation and must not become "${target}" — ${e instanceof Error ? e.message : String(e)}`,
1348
+ };
1349
+ }
1350
+ }
1351
+ else if (slug === "standard_promote" && deps.standards) {
1352
+ if (!deps.standards.get(targetSlug))
1353
+ return notFound("standard");
1354
+ }
1355
+ else if (slug === "skill_promote" && deps.skills) {
1356
+ const sk = deps.skills.get(targetSlug);
1357
+ if (!sk)
1358
+ return notFound("skill");
1359
+ const check = SkillSchema.safeParse(sk); // the loader's own gate
1360
+ if (!check.success) {
1361
+ const why = check.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`).join("; ");
1362
+ return {
1363
+ ok: false, requires_approval: approval,
1364
+ error: `${slug}: skill "${targetSlug}" does not pass validation and must not become "${target}" — ${why}`,
1365
+ };
1366
+ }
1367
+ }
1368
+ const promotion_id = randomUUID();
1369
+ // v1 recorded neither WHICH entity was promoted nor the transition — standard_slug held
1370
+ // the TOOL name. A lifecycle transition is exactly the event an audit trail exists for.
1371
+ deps.ledger.append(governanceRow(slug, targetSlug, {
1372
+ promotion_id, from_status: current, to_status: target,
1373
+ }));
1374
+ return {
1375
+ ok: true, requires_approval: approval,
1376
+ data: { slug: targetSlug, status: target, promoted: true, promotion_id },
1377
+ };
1378
+ }
1379
+ case "session_review_write": {
1380
+ // §11 learning loop, half 1: record a quality review of a gig's output. The
1381
+ // review is an immutable ledger event; learning_synthesize aggregates many
1382
+ // reviews into evolution evidence.
1383
+ const gig_id = String(args["gig_id"] ?? "");
1384
+ const output_id = String(args["output_id"] ?? "");
1385
+ const agent_slug = String(args["agent_slug"] ?? "");
1386
+ const quality_scores = args["quality_scores"];
1387
+ if (!gig_id || !output_id || !agent_slug || quality_scores == null || typeof quality_scores !== "object") {
1388
+ return { ok: false, requires_approval: approval, error: "session_review_write requires gig_id, output_id, agent_slug, quality_scores" };
1389
+ }
1390
+ const review_id = randomUUID();
1391
+ // agent_slug / output_id / quality_scores were validated above and then thrown away,
1392
+ // because v1 LedgerEntry had nowhere to put them. That discard is the root cause of the
1393
+ // cross-agent evidence bug in learning_synthesize (#215).
1394
+ deps.ledger.append(governanceRow("session_review_write", agent_slug, {
1395
+ review_id, output_id, quality_scores,
1396
+ }, gig_id));
1397
+ return { ok: true, requires_approval: approval, data: { review_id, recorded: true, agent_slug, gig_id } };
1398
+ }
1399
+ case "learning_synthesize": {
1400
+ // §11 learning loop, half 2: aggregate session reviews into evolution evidence
1401
+ // for one agent. Returns evidence_sufficient=true only when review count meets
1402
+ // min_reviews (default 5, matching OG threshold). auto_propose creates a
1403
+ // proposal_create-shaped proposal_id (recorded against the same agent_slug).
1404
+ const agent_slug = String(args["agent_slug"] ?? "");
1405
+ if (!agent_slug) {
1406
+ return { ok: false, requires_approval: approval, error: "learning_synthesize requires agent_slug" };
1407
+ }
1408
+ const min_reviews = typeof args["min_reviews"] === "number" ? args["min_reviews"] : 5;
1409
+ const auto_propose = args["auto_propose"] === true;
1410
+ // Scoped to the named agent. v1 queried EVERY review row in the ledger and only
1411
+ // echoed agent_slug back, so five reviews of five different agents opened the
1412
+ // evolution gate for a sixth with none (#215). The typed discriminators replace a
1413
+ // load-bearing String.startsWith on a synthetic gig_id.
1414
+ const reviews = deps.ledger.query({
1415
+ kind: "governance", event: "session_review_write", subject_slug: agent_slug,
1416
+ });
1417
+ const review_count = reviews.length;
1418
+ const evidence_sufficient = review_count >= min_reviews;
1419
+ let proposal_id = null;
1420
+ if (evidence_sufficient && auto_propose) {
1421
+ proposal_id = randomUUID();
1422
+ deps.ledger.append(governanceRow("learning_synthesize", agent_slug, {
1423
+ proposal_id, review_count, min_reviews,
1424
+ }));
1425
+ }
1426
+ return {
1427
+ ok: true, requires_approval: approval,
1428
+ data: {
1429
+ agent_slug, review_count, evidence_sufficient,
1430
+ summary: { min_reviews, threshold_met: evidence_sufficient },
1431
+ proposal_id,
1432
+ },
1433
+ };
1434
+ }
1435
+ default:
1436
+ return { ok: false, not_implemented: true, requires_approval: approval, error: `"${slug}" has no v0 handler` };
1437
+ }
1438
+ }
1439
+ catch (e) {
1440
+ if (e instanceof LedgerError) {
1441
+ // #218 — the audit row did not land. Collapsing this into a generic {ok:false} told the
1442
+ // caller nothing happened, when in fact the side effect may have been applied. Callers
1443
+ // (and operators) need to distinguish a rejected request from an unrecorded one.
1444
+ return {
1445
+ ok: false,
1446
+ requires_approval: approval,
1447
+ audit_write_failed: true,
1448
+ error: `audit write failed — "${slug}" was NOT sealed: ${e.message}`,
1449
+ };
1450
+ }
1451
+ return { ok: false, requires_approval: approval, error: e instanceof Error ? e.message : String(e) };
1452
+ }
1453
+ }
1454
+ /** Build the low-level MCP Server with ListTools + CallTool wired to the dispatcher. */
1455
+ export function createColtraneServer(deps, recorder) {
1456
+ const server = new Server({ name: "coltrane", version: COLTRANE_VERSION }, { capabilities: { tools: {} } });
1457
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1458
+ tools: MCP_TOOLS.map((t) => ({
1459
+ name: t.slug,
1460
+ description: `${t.category} tool`,
1461
+ inputSchema: t.input_schema,
1462
+ })),
1463
+ }));
1464
+ server.setRequestHandler(CallToolRequestSchema, async (req) => {
1465
+ const result = await dispatchTool(req.params.name, (req.params.arguments ?? {}), deps);
1466
+ if (recorder) {
1467
+ recorder.recordToolCall(req.params.name);
1468
+ recorder.recordObservability(`call:${req.params.name}`, { ok: result.ok });
1469
+ }
1470
+ return {
1471
+ content: [{ type: "text", text: JSON.stringify(result) }],
1472
+ isError: !result.ok,
1473
+ };
1474
+ });
1475
+ return server;
1476
+ }
1477
+ /**
1478
+ * Deterministic hash of the loaded genome (types + agents + standards). Identical
1479
+ * source trees produce identical hashes, regardless of which session boots the server.
1480
+ */
1481
+ function loadedGenomeHash(genome) {
1482
+ const types = [...genome.domain_types.values()]
1483
+ .map((t) => ({ slug: t.slug, extends: t.extends, domain: t.domain, required_fields: t.required_fields, schema: t.schema }))
1484
+ .sort((a, b) => (a.slug < b.slug ? -1 : 1));
1485
+ const agents = [...genome.agents.values()]
1486
+ .map((a) => ({ slug: a.slug, primitives: a.primitives, input_types: a.input_types, output_types: a.output_types, domain: a.domain }))
1487
+ .sort((a, b) => (a.slug < b.slug ? -1 : 1));
1488
+ const standards = [...genome.standards.values()]
1489
+ .map((s) => ({ slug: s.slug, domain: s.domain, agent_slugs: s.agents.map((x) => x.slug), phases: s.phases }))
1490
+ .sort((a, b) => (a.slug < b.slug ? -1 : 1));
1491
+ return createHash("sha256").update(canonJson({ types, agents, standards })).digest("hex");
1492
+ }
1493
+ /**
1494
+ * stdio entry. Boots a server and connects over stdin/stdout. By default the
1495
+ * AgentInvoker is the REAL Claude CLI (Claude Code = the cognition) — so a prod
1496
+ * server runs gigs against the live model. Tests inject deps (incl. a mock invoke).
1497
+ */
1498
+ /**
1499
+ * Boot a full ServerDeps from the genome FILES on disk — so a bare `node dist/src/server_entry.js`
1500
+ * serves the repo's genome (types, agents, standards), not an empty registry. The genome
1501
+ * root is COLTRANE_GENOME or the cwd. Pure + testable (no stdio); fails loud if the cwd
1502
+ * isn't a genome (loadGenome rejects a missing/invalid core_types/).
1503
+ */
1504
+ // #185 — the MCP servers a deployment makes available, keyed by server slug. The repo's .mcp.json
1505
+ // IS that registry (coltrane ships its own "coltrane" server; a deployment adds e.g. a browser
1506
+ // server there). Per-agent grant resolution wires only the servers an agent's allowed_tools name
1507
+ // into its spawn — deny-by-default. Falls back to coltrane's own server if .mcp.json is absent.
1508
+ function readMcpServerConfigs(root) {
1509
+ const path = join(root, ".mcp.json");
1510
+ if (existsSync(path)) {
1511
+ try {
1512
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
1513
+ if (parsed.mcpServers && typeof parsed.mcpServers === "object")
1514
+ return parsed.mcpServers;
1515
+ }
1516
+ catch { /* fall through to the default */ }
1517
+ }
1518
+ return { [ENGINE_MCP_SERVER]: { command: "node", args: ["dist/src/server_entry.js"] } };
1519
+ }
1520
+ export function bootstrapServerDeps(genomeRoot) {
1521
+ const root = genomeRoot ?? process.env["COLTRANE_GENOME"] ?? process.cwd();
1522
+ const genome = resolveGenome(root); // manifest-aware: honors a consumer's `extends` base
1523
+ const registry = loadRegistry(genome);
1524
+ const mcpServerConfigs = readMcpServerConfigs(root);
1525
+ // #185 — the genome→provider bridge the resolver needs to be reachable in production. Each
1526
+ // registered engine tool slug (the coltrane MCP surface + anything tool_register added) becomes an
1527
+ // in_house provider, so an agent that grants a real engine tool resolves instead of failing closed.
1528
+ // Without this the resolver only ever saw the browser cage, so any non-playwright grant was a dead
1529
+ // name. Shared by reference with the invoker so tool_register stays live (no restart needed).
1530
+ // #204 — tag each in-house tool with the engine's own MCP server ("coltrane", the slug the
1531
+ // repo's .mcp.json ships). An in-house grant then wires that server into the spawn AND advertises
1532
+ // the tool as mcp__coltrane__<slug> — the name the server exposes — so a bare-slug grant (the form
1533
+ // agent_define accepts) is actually callable instead of a silent dead name that seals nothing.
1534
+ const toolProviders = new Map([...REGISTERED_TOOL_SLUGS].map((slug) => [slug, { tool: slug, kind: "in_house", server: ENGINE_MCP_SERVER }]));
1535
+ return {
1536
+ registry,
1537
+ toolProviders,
1538
+ // PR #78 follow-up: persist outputs to disk so the audit chain survives an
1539
+ // MCP session close (Rob cold-trial requirement). COLTRANE_OUTPUTS_DIR
1540
+ // overrides the default ~/.eir/coltrane_outputs path (tests + sandboxes).
1541
+ outputs: createOutputStore(registry, { persistDir: defaultOutputsPersistDir() }),
1542
+ // #209 — the audit spine is durable by default. The line above gives OUTPUTS a persistDir
1543
+ // under an explicit "the audit chain must survive an MCP session close" requirement
1544
+ // (PR #78); the ledger sat in RAM directly beneath it, which made absence-of-row mean
1545
+ // "we forgot" instead of "the run did not finish" — inverting the invariant
1546
+ // tests/e2e/recorder_durability_mid_crash.spec.ts deliberately pins.
1547
+ // FileLedger creates nothing until the first append (#210), so merely bootstrapping deps
1548
+ // — as tests/dispatch_tool_resolution.test.ts does with no root — leaves no trace.
1549
+ ledger: new FileLedger(defaultLedgerPath(root)),
1550
+ standards: genome.standards, // ← gig_dispatch can now resolve file-defined standards
1551
+ invoke: makeClaudeInvoker({
1552
+ registry,
1553
+ model: process.env["COLTRANE_MODEL"],
1554
+ // #185 — per-agent grant resolution wires each agent's MCP servers into its spawn (coltrane's
1555
+ // own server + any the deployment registers in .mcp.json). An unresolvable grant fails closed.
1556
+ mcpServerConfigs,
1557
+ toolProviders, // the genome→provider bridge (above) — makes in_house grants resolvable
1558
+ // per-chair wall-clock bound; COLTRANE_CHAIR_TIMEOUT_MS overrides for slow deployments
1559
+ ...(process.env["COLTRANE_CHAIR_TIMEOUT_MS"] ? { timeout_ms: Number(process.env["COLTRANE_CHAIR_TIMEOUT_MS"]) } : {}),
1560
+ }),
1561
+ model_version: process.env["COLTRANE_MODEL"] ?? "claude-cli-default",
1562
+ skills: genome.skills, // ← skill substrate — runGig resolves agent.skill_slugs into prompt
1563
+ // skill-backed chairs (Chair.skill_slug) run the skill's code half — map slug → package dir.
1564
+ skill_dirs: new Map([...genome.skills.values()].map((s) => [s.slug, String(s.package_dir)])),
1565
+ evals: genome.evals, // ← 5th-class eval substrate — runGig judges declared eval_slugs
1566
+ genome_dir: root, // ← genome-mutation tools persist + ledger-seal into the live genome
1567
+ load_errors: [...genome.load_errors], // ← Rob #129 — surfaced via system_health
1568
+ agents: new Map(genome.agents), // ← Rob #130 + #132 — slug-resolve + reload-diff
1569
+ provenance: genome.provenance, // ← genome extension — which layer supplied each def
1570
+ gig_runs: new Map(), // ← async dispatch — live gig state gig_monitor reads
1571
+ gig_log_base: defaultOutputsPersistDir(), // ← per-gig agent logs at <base>/gigs/<id>/<role>.jsonl
1572
+ };
1573
+ }
1574
+ const SHUTDOWN_SIGNALS = ["SIGTERM", "SIGINT"];
1575
+ // 128 + signal number, the shell convention.
1576
+ const SIGNAL_EXIT_CODE = { SIGTERM: 143, SIGINT: 130 };
1577
+ /**
1578
+ * Install the server's shutdown path (#252).
1579
+ *
1580
+ * The bug this closes: the old wiring was `process.on("SIGTERM", flush)` where `flush()` sets
1581
+ * a flag and returns. In Node, installing a SIGINT/SIGTERM listener REPLACES the default
1582
+ * terminate behaviour — so a recorder-enabled server survived SIGTERM indefinitely. The relay's
1583
+ * 2s SIGKILL escalation masked it; a direct `kill <pid>` did not terminate the server at all.
1584
+ *
1585
+ * The second half: the server's own `claude` grandchildren are spawned WITHOUT `detached`, so
1586
+ * they are not in a separate process group and POSIX delivers them nothing when the server is
1587
+ * signalled. They keep running, orphaned, still billing — and gig tracking is dropped by the
1588
+ * restart, so nothing records that the orphans exist. So the server kills them itself on the
1589
+ * way out. (Killing the process GROUP would be airtight but takes children out of the server's
1590
+ * group, so an operator Ctrl-C would stop reaching them — deliberately not taken.)
1591
+ */
1592
+ /**
1593
+ * The SIGTERM→SIGKILL grace the SHUTDOWN path passes to `killLiveChairChildren` — zero, on
1594
+ * purpose (#260).
1595
+ *
1596
+ * `terminateChild` implements its grace as a `setTimeout(...).unref()`, and `shutdown()` below
1597
+ * calls `proc.exit()` on the very next line. An unref'd timer in a process that is already
1598
+ * leaving can never fire, so any POSITIVE grace here means the escalation is skipped entirely
1599
+ * and a SIGTERM-trapping `claude` child survives exactly the shutdown that #252 added to take
1600
+ * it with us — still running, still billing, and now with no parent tracking it. A zero grace
1601
+ * escalates inline instead: SIGTERM then SIGKILL, both before we exit.
1602
+ *
1603
+ * The cooperative window is not lost, only relocated: the CANCELLATION path
1604
+ * (`spawnStreaming`'s abort listener) keeps running afterwards, so it keeps
1605
+ * `DEFAULT_ABORT_GRACE_MS` and its timer genuinely fires.
1606
+ */
1607
+ export const SHUTDOWN_CHILD_GRACE_MS = 0;
1608
+ export function installShutdownHandlers(opts, proc = process) {
1609
+ let shuttingDown = false;
1610
+ const shutdown = (signal) => {
1611
+ if (shuttingDown)
1612
+ return; // an impatient double Ctrl-C must not re-enter the flush
1613
+ shuttingDown = true;
1614
+ try {
1615
+ opts.flush?.();
1616
+ }
1617
+ catch { /* a failed flush must not block the exit */ }
1618
+ try {
1619
+ opts.killChildren?.();
1620
+ }
1621
+ catch { /* nor must a failed kill */ }
1622
+ proc.exit(SIGNAL_EXIT_CODE[signal] ?? 0);
1623
+ };
1624
+ for (const sig of SHUTDOWN_SIGNALS)
1625
+ proc.on(sig, () => shutdown(sig));
1626
+ // Normal-exit paths still flush; they do not need (or want) an explicit exit call.
1627
+ const flushOnly = () => { try {
1628
+ opts.flush?.();
1629
+ }
1630
+ catch { /* best-effort */ } };
1631
+ proc.on("beforeExit", flushOnly);
1632
+ proc.on("exit", flushOnly);
1633
+ }
1634
+ export async function runStdioServer(deps) {
1635
+ // Tests inject deps; a bare prod start bootstraps the genome from files.
1636
+ const resolved = deps ?? bootstrapServerDeps();
1637
+ const recorder = openSubthreadRecorderFromEnv(resolved);
1638
+ const server = createColtraneServer(resolved, recorder ?? undefined);
1639
+ installShutdownHandlers({
1640
+ ...(recorder ? { flush: () => { recorder.flush(); } } : {}),
1641
+ // unconditional: the orphan half has nothing to do with the recorder.
1642
+ // Zero grace — see SHUTDOWN_CHILD_GRACE_MS: the default grace is an unref'd timer that
1643
+ // cannot fire in a process that exits on the next line.
1644
+ killChildren: () => { killLiveChairChildren(SHUTDOWN_CHILD_GRACE_MS); },
1645
+ });
1646
+ await server.connect(new StdioServerTransport());
1647
+ }
1648
+ /**
1649
+ * Open a sub-thread recorder if the harness/parent supplied env wiring. Reads
1650
+ * COLTRANE_SESSION_ID + COLTRANE_RECORDER_PATH (mandatory pair); optional
1651
+ * COLTRANE_API_VERSION (default "1.0.0"), COLTRANE_PARENT_SESSION_ID,
1652
+ * COLTRANE_MODEL. On api_version mismatch with a prior turn for this session,
1653
+ * writes a typed error entry to the recorder, prints the typed error to stderr
1654
+ * (best-effort observability), and exits non-zero so the seam fails CLOSED.
1655
+ */
1656
+ function openSubthreadRecorderFromEnv(deps) {
1657
+ const session_id = process.env["COLTRANE_SESSION_ID"];
1658
+ const path = process.env["COLTRANE_RECORDER_PATH"]
1659
+ ?? (deps.genome_dir ? join(deps.genome_dir, ".coltrane-recorder.jsonl") : undefined);
1660
+ if (!session_id || !path)
1661
+ return null;
1662
+ const api_version = process.env["COLTRANE_API_VERSION"] ?? "1.0.0";
1663
+ const parent_session_id = process.env["COLTRANE_PARENT_SESSION_ID"] ?? null;
1664
+ const model_version = process.env["COLTRANE_MODEL"] ?? deps.model_version ?? "claude-cli-default";
1665
+ const genome = deps.genome_dir ? resolveGenome(deps.genome_dir) : null;
1666
+ const genome_hash = genome ? loadedGenomeHash(genome) : "no-genome";
1667
+ const run_fp = runFingerprint({
1668
+ genome_hash,
1669
+ model_version,
1670
+ canonical_form_version: CANONICAL_FORM_VERSION,
1671
+ eval_scores: {},
1672
+ output_hashes: [],
1673
+ });
1674
+ try {
1675
+ return SubthreadRecorder.open({
1676
+ path,
1677
+ session_id,
1678
+ parent_session_id,
1679
+ api_version,
1680
+ genome_hash,
1681
+ run_fingerprint: run_fp,
1682
+ });
1683
+ }
1684
+ catch (e) {
1685
+ if (e instanceof ApiVersionMismatchError) {
1686
+ process.stderr.write(`${e.message}\n`);
1687
+ process.exit(2);
1688
+ }
1689
+ throw e;
1690
+ }
1691
+ }
1692
+ //# sourceMappingURL=server.js.map