@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,760 @@
1
+ // The real AgentInvoker: builds the 5-layer prompt and runs cognition via the
2
+ // `claude` CLI (Claude Code IS the cognition — the prime directive's "depend on
3
+ // nothing but Claude Code"). buildPrompt is pure + testable; runClaude is the one
4
+ // non-deterministic seam (spawns the CLI, parses structured output).
5
+ import { spawn } from "node:child_process";
6
+ import { writeFileSync, unlinkSync } from "node:fs";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { randomUUID } from "node:crypto";
10
+ import { abortReasonText } from "./runtime.js";
11
+ import { assertToolGrantsResolvable } from "./tool_providers.js";
12
+ import { playwrightServerFor } from "./playwright_cage.js";
13
+ const EMPTY_TOOL_REGISTRY = new Map();
14
+ // Per-tier model resolution (the old MODEL_TIER_MAP: economy/standard/premium →
15
+ // haiku/sonnet/opus). An agent's model_tier picks the concrete spawn model; falls back to
16
+ // the invoker's static default only when the agent declares no tier.
17
+ export const MODEL_TIER_MAP = {
18
+ economy: "claude-haiku-4-5",
19
+ standard: "claude-sonnet-4-6",
20
+ premium: "claude-opus-4-8",
21
+ };
22
+ function resolveModel(tier, fallback) {
23
+ return tier ? MODEL_TIER_MAP[tier] : fallback;
24
+ }
25
+ // code_tool_access → the built-in code tools the cage denies. none denies all; read keeps
26
+ // Read; write keeps Read/Write/Edit; full denies none; unset adds no denial layer.
27
+ const CODE_TOOLS = ["Read", "Write", "Edit", "Bash"];
28
+ function codeToolDenials(access) {
29
+ switch (access) {
30
+ case "none": return [...CODE_TOOLS];
31
+ case "read": return ["Write", "Edit", "Bash"];
32
+ case "write": return ["Bash"];
33
+ default: return []; // "full" or unset → no code-tool denial
34
+ }
35
+ }
36
+ // Belbin cognitive-role descriptions for the Disposition layer (the agent's stance, 2
37
+ // "in tension"). Strings match the old runtime verbatim so a restored prompt reaches
38
+ // parity with the baseline fixtures. Reference data; buildPrompt wires it in.
39
+ export const BELBIN_DESCRIPTIONS = {
40
+ explorer: "Navigates unknown territory, discovers structure, maps the landscape.",
41
+ analyst: "Finds patterns, extracts meaning, builds structured understanding from raw data.",
42
+ critic: "Challenges assumptions, finds weaknesses, demands evidence for every claim.",
43
+ synthesizer: "Combines disparate inputs into coherent wholes, resolves contradictions.",
44
+ planner: "Decomposes goals into sequences, allocates resources, designs strategies.",
45
+ executor: "Produces concrete artifacts, writes code, builds deliverables.",
46
+ audience_modeler: "Understands user perspectives, models personas, anticipates needs.",
47
+ };
48
+ // #237 — what a dispatch-time depth actually ASKS FOR. The prompt half of the lever.
49
+ export const DEPTH_GUIDANCE = {
50
+ skim: " — this is a cheap iteration pass. Do the minimum that produces a well-formed, valid output. Do not explore, do not use tools you do not strictly need, do not elaborate.",
51
+ quick: " — favour speed over exhaustiveness. Cover the obvious ground and stop.",
52
+ standard: "",
53
+ deep: " — be exhaustive. Chase the non-obvious and justify every claim.",
54
+ };
55
+ // #237 — the SPEND half of the lever. `--max-turns` is the only hard per-chair cost bound the
56
+ // cage has, so a shallow depth caps it: a skim run that can still take 100 tool turns is a full
57
+ // run wearing a label. Only ever TIGHTENS an agent's own declared cap, never widens it. Depths
58
+ // with no entry leave the agent's cap exactly as declared.
59
+ export const DEPTH_MAX_TOOL_CALLS = { skim: 8, quick: 16 };
60
+ // The 5-layer prompt hierarchy: Disposition → Identity → Skills → Context → Task.
61
+ // Pure: same context in, same prompt out. Hashable, reviewable, testable.
62
+ // Layer 3 (Skills) is emitted when the AgentInvocationContext carries resolved
63
+ // SkillRecords — the runtime resolves the agent's `skill_slugs` against the
64
+ // genome's skills map and passes the records through. Empty/absent → the Skills
65
+ // section is omitted entirely (no empty header, no noise) so the model only
66
+ // sees skills the agent actually declared.
67
+ export function buildPrompt(ctx, outputSchema,
68
+ // Per-type schemas for a MULTI-output agent (slug → schema). When the agent declares
69
+ // more than one output type, the Task layer asks for a blob keyed by type rather than a
70
+ // single object — the runtime then seals one record per key.
71
+ outputSchemas) {
72
+ const a = ctx.agent;
73
+ const layers = [];
74
+ // 1. Disposition — the Belbin cognitive-role pairing, held in tension (how you think).
75
+ const dispo = a.behavioral_primitives.map((r) => `- **${r}**: ${BELBIN_DESCRIPTIONS[r] ?? r}`).join("\n");
76
+ layers.push(`# Disposition\nYou hold these cognitive modes in equal tension:\n${dispo}\nHold every mode active throughout your work; none dominates.`);
77
+ // 2. Identity — who you are: the slug line plus the agent's own prose.
78
+ layers.push(`# Identity\nYou are the agent "${a.slug}"${a.domain ? ` in the "${a.domain}" domain` : ""}.\n\n${a.identity}`);
79
+ // 3. Method — how THIS agent does its job, the step-by-step.
80
+ layers.push(`# Method\n${a.method}`);
81
+ // 4. Skills — content the agent's bound skills contribute to the prompt. Each
82
+ // skill renders as `## <slug>` + its text payload. We pick the first non-empty
83
+ // string from the conventional content keys (`md`, then `text`, then `body`);
84
+ // a slug-only SkillRecord still renders its slug so the model knows it's bound.
85
+ const resolved = ctx.skills ?? [];
86
+ const skillBlocks = resolved.length > 0
87
+ ? resolved.map((s) => {
88
+ const text = (typeof s["md"] === "string" && s["md"]) ||
89
+ (typeof s["text"] === "string" && s["text"]) ||
90
+ (typeof s["body"] === "string" && s["body"]) ||
91
+ "";
92
+ return `## ${s.slug}${text ? `\n${text}` : ""}`;
93
+ })
94
+ // No resolved content this gig — still name the bound skills so the model knows it has
95
+ // them (matches the old runtime's skills index). #241: NEVER name a slug the runtime
96
+ // resolved to no package. An all-dangling agent used to render `# Skills` / `## <slug>`
97
+ // with zero content — the prompt ASSERTING to the model that it holds a discipline that
98
+ // does not exist. An ABSENT `missing_skills` means resolution was never attempted (no
99
+ // skills map), so nothing is known-unresolved and the legacy index behaviour stands.
100
+ : (a.skill_slugs ?? [])
101
+ .filter((slug) => !(ctx.missing_skills ?? []).includes(slug))
102
+ .map((slug) => `## ${slug}`);
103
+ if (skillBlocks.length > 0) {
104
+ layers.push(`# Skills\n${skillBlocks.join("\n\n")}`);
105
+ }
106
+ // 5. Constraints — the negative space (never-invent / cite-sources). Omitted when empty.
107
+ if (a.constraints.length > 0) {
108
+ layers.push(`# Constraints\n${a.constraints.map((c) => `- ${c}`).join("\n")}`);
109
+ }
110
+ // 6. Available Tools — name every granted tool so the model knows it has them and uses
111
+ // them (the cage grants access; the prompt must grant awareness, or the tools sit unused).
112
+ if (a.allowed_tools && a.allowed_tools.length > 0) {
113
+ layers.push(`# Available Tools\nThese tools are available to you — call them directly:\n${a.allowed_tools.map((t) => `- ${t}`).join("\n")}`);
114
+ }
115
+ // 7. Context — the gig input + the upstream typed outputs you consume + depth tuning.
116
+ const inputsBlock = ctx.inputs.length
117
+ ? ctx.inputs.map((o) => `- ${o.domain_type} (from ${o.agent_slug}): ${JSON.stringify(o.data)}`).join("\n")
118
+ : "(none — you are a root agent)";
119
+ // #237 — a dispatch-time depth OVERRIDES the agent's static depth_profile, and carries an
120
+ // instruction with it. A depth that only gets recorded is not a cost lever; the model has to
121
+ // be told to do less, or "skim first" stays a slogan and every iteration pays full price.
122
+ const runDepth = ctx.depth ?? a.depth_profile;
123
+ const depthLine = runDepth
124
+ ? `Depth: ${runDepth}${ctx.depth ? DEPTH_GUIDANCE[ctx.depth] : ""}\n`
125
+ : "";
126
+ layers.push(`# Context\n${depthLine}Gig input: ${JSON.stringify(ctx.gig_input)}\nUpstream outputs:\n${inputsBlock}`);
127
+ // 5. Task — produce the types THIS CHAIR promises as JSON. #174: the chair's output_contract
128
+ // (threaded as ctx.output_types) is the selector — a multi-capability agent at a single-purpose
129
+ // chair is asked for only its promised subset, not its whole catalogue. Legacy ctx without it
130
+ // falls back to the agent's full output_types.
131
+ const sealTypes = ctx.output_types?.length ? ctx.output_types : a.output_types;
132
+ if (sealTypes.length > 1) {
133
+ // multi-output: one JSON object keyed by each output-type slug; each value is that
134
+ // type's data. The runtime seals one record per key (a SENSE+JUDGE agent yields its
135
+ // Signal and its Judgment in one pass).
136
+ const perType = sealTypes
137
+ .map((t) => {
138
+ const s = outputSchemas?.[t];
139
+ return ` "${t}": <object${s ? ` matching ${JSON.stringify(s)}` : ""}>`;
140
+ })
141
+ .join(",\n");
142
+ layers.push(`# Task\nProduce one object for EACH of your output types: ${sealTypes.map((t) => `"${t}"`).join(", ")}.\n` +
143
+ `Respond with ONLY a single JSON object keyed by output-type name — no prose, no code fence:\n{\n${perType}\n}`);
144
+ }
145
+ else {
146
+ const outType = sealTypes[0] ?? "output";
147
+ const schemaHint = outputSchema ? `\nIt must match this JSON schema:\n${JSON.stringify(outputSchema)}` : "";
148
+ layers.push(`# Task\nProduce exactly one "${outType}".${schemaHint}\n` +
149
+ `Respond with ONLY a single JSON object (the output's data) — no prose, no code fence.`);
150
+ }
151
+ return layers.join("\n\n");
152
+ }
153
+ // ───────────────────────── JSON extraction (#221, #226) ─────────────────────────
154
+ //
155
+ // The old implementation took "the first balanced brace run" — string-blind, anchored on
156
+ // the first `{` and never re-anchored, with exactly one candidate ever handed to
157
+ // JSON.parse. It mis-sliced valid output (a `}` inside a string value truncated the slice)
158
+ // and, worse, silently returned an illustrative preamble object in place of the answer.
159
+ // That wrong object then sealed with a real content_sha and genuine provenance edges, so
160
+ // `output_trace` reported an intact chain over garbage.
161
+ //
162
+ // This is now ONE implementation shared by all four production call sites (:319, :325,
163
+ // bifrost_invoker.ts, document_factory.ts) — see #226; the judge's half-fixed duplicate is
164
+ // gone.
165
+ /** Bound on the raw-output excerpt a parse failure carries, so no blob lands in a log line. */
166
+ const EXCERPT_MAX_CHARS = 500;
167
+ /**
168
+ * A typed extraction failure. Carries the number of balanced JSON objects found and a
169
+ * bounded excerpt of the raw text — previously both throws were bare `Error`s with no
170
+ * sample, so the operator's entire diagnostic was a V8 offset into a string never
171
+ * surfaced. The type is also the prerequisite for any future retry policy: runtime.ts
172
+ * cannot currently tell a retryable parse failure from a non-retryable contract failure.
173
+ */
174
+ export class ModelOutputParseError extends Error {
175
+ candidateCount;
176
+ excerpt;
177
+ constructor(message, candidateCount, raw) {
178
+ super(`${message} (candidates: ${candidateCount})`);
179
+ this.name = "ModelOutputParseError";
180
+ this.candidateCount = candidateCount;
181
+ this.excerpt =
182
+ raw.length > EXCERPT_MAX_CHARS ? `${raw.slice(0, EXCERPT_MAX_CHARS)}…` : raw;
183
+ }
184
+ }
185
+ /**
186
+ * Walk forward from `start` (which must be a `{`) honouring JSON string literals and
187
+ * backslash escapes, so only STRUCTURAL braces move the depth counter. Returns the index
188
+ * of the matching `}`, or -1 if the object never closes.
189
+ *
190
+ * Escape handling is the half of this that is easiest to get wrong, and two guards pin
191
+ * it: `{"path":"C:\\","v":1}` (an escaped backslash immediately before the closing quote
192
+ * — a naive `inString = !inString` toggle breaks it) and `{"note":"use {slug} here"}`
193
+ * (balanced in-string braces, which worked by accident before and must keep working).
194
+ */
195
+ function scanBalanced(text, start) {
196
+ let depth = 0;
197
+ let inString = false;
198
+ let escaped = false;
199
+ for (let i = start; i < text.length; i++) {
200
+ const ch = text[i];
201
+ if (escaped) {
202
+ escaped = false;
203
+ continue;
204
+ }
205
+ if (inString) {
206
+ if (ch === "\\") {
207
+ escaped = true;
208
+ continue;
209
+ }
210
+ if (ch === '"')
211
+ inString = false;
212
+ continue;
213
+ }
214
+ if (ch === '"') {
215
+ inString = true;
216
+ continue;
217
+ }
218
+ if (ch === "{")
219
+ depth++;
220
+ else if (ch === "}") {
221
+ depth--;
222
+ if (depth === 0)
223
+ return i;
224
+ }
225
+ }
226
+ return -1;
227
+ }
228
+ /**
229
+ * Every balanced, parseable JSON object in the text, in document order.
230
+ *
231
+ * Enumerates at EVERY `{` start position — the old code fixed `start` at the first one,
232
+ * so a brace run in the prose (`The set {a,b} matters.`) sank the whole extraction. A
233
+ * start that fails to parse is skipped and the scan re-anchors on the next `{`.
234
+ *
235
+ * Starts INSIDE an accepted candidate are skipped, so `{"a":{"b":1},"c":2}` yields the
236
+ * outer object rather than also offering its own nested `{"b":1}` as a rival.
237
+ */
238
+ function enumerateCandidates(text) {
239
+ const found = [];
240
+ let i = 0;
241
+ while (i < text.length) {
242
+ if (text[i] !== "{") {
243
+ i++;
244
+ continue;
245
+ }
246
+ const end = scanBalanced(text, i);
247
+ if (end === -1) {
248
+ i++;
249
+ continue;
250
+ }
251
+ let parsed;
252
+ try {
253
+ parsed = JSON.parse(text.slice(i, end + 1));
254
+ }
255
+ catch {
256
+ i++;
257
+ continue;
258
+ }
259
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
260
+ found.push({ start: i, end, value: parsed });
261
+ i = end + 1;
262
+ continue;
263
+ }
264
+ i++;
265
+ }
266
+ return found;
267
+ }
268
+ // A fenced block, tagged (```json) or bare (```). Non-greedy so consecutive fences are
269
+ // separate spans rather than one span swallowing the prose between them.
270
+ const FENCE_RE = /```[ \t]*[A-Za-z0-9_+-]*[ \t]*\r?\n([\s\S]*?)```/g;
271
+ /** Character spans of every fenced block's BODY. */
272
+ function fenceSpans(text) {
273
+ const spans = [];
274
+ FENCE_RE.lastIndex = 0;
275
+ let m;
276
+ while ((m = FENCE_RE.exec(text)) !== null) {
277
+ const body = m[1] ?? "";
278
+ const bodyStart = m.index + m[0].length - 3 - body.length;
279
+ spans.push({ start: bodyStart, end: bodyStart + body.length - 1 });
280
+ }
281
+ return spans;
282
+ }
283
+ /**
284
+ * Extract the model's answer object from its output.
285
+ *
286
+ * Selection policy (a deliberate contract change from "the first balanced object"):
287
+ * 1. A candidate inside a fenced block beats one outside; among fenced, prefer the last.
288
+ * 2. Then `expectKeys` — a candidate must contain ALL expected keys to qualify.
289
+ * 3. Then the LAST surviving candidate, not the first. The prompt demands a single
290
+ * object (buildPrompt :146/:153), so an earlier object is evidence of scaffolding.
291
+ *
292
+ * A whole-text top-level array is handled explicitly: one element unwraps, more than one
293
+ * throws rather than silently discarding the array framing and every later element.
294
+ */
295
+ export function extractJson(text, opts = {}) {
296
+ const trimmed = text.trim();
297
+ if (trimmed.startsWith("[")) {
298
+ let arr;
299
+ try {
300
+ arr = JSON.parse(trimmed);
301
+ }
302
+ catch { /* not a clean array — fall through */ }
303
+ if (Array.isArray(arr)) {
304
+ const only = arr.length === 1 ? arr[0] : undefined;
305
+ if (only && typeof only === "object" && !Array.isArray(only)) {
306
+ return only;
307
+ }
308
+ throw new ModelOutputParseError(`model output is a ${arr.length}-element JSON array where a single object was required`, arr.length, text);
309
+ }
310
+ }
311
+ const candidates = enumerateCandidates(text);
312
+ if (candidates.length === 0) {
313
+ throw new ModelOutputParseError("no JSON object in model output — the model produced no answer", 0, text);
314
+ }
315
+ // 1. Fenced candidates win outright when any exist.
316
+ const spans = fenceSpans(text);
317
+ const fenced = candidates.filter((c) => spans.some((s) => c.start >= s.start && c.end <= s.end));
318
+ let pool = fenced.length > 0 ? fenced : candidates;
319
+ // 2. Expected keys — ALL must be present. If nothing matches, the signal simply does not
320
+ // narrow (it never widens, and it never picks a partial match).
321
+ const keys = opts.expectKeys ?? [];
322
+ if (keys.length > 0) {
323
+ const matching = pool.filter((c) => keys.every((k) => Object.prototype.hasOwnProperty.call(c.value, k)));
324
+ if (matching.length > 0)
325
+ pool = matching;
326
+ }
327
+ // 3. Refuse to guess when the caller had nothing to score against.
328
+ if (pool.length > 1 && opts.requireUnambiguous === true) {
329
+ throw new ModelOutputParseError("ambiguous model output — several JSON objects and no output schema to choose between them", pool.length, text);
330
+ }
331
+ return pool[pool.length - 1].value;
332
+ }
333
+ /** Property names declared by a resolved output schema (the single-output key signal). */
334
+ function schemaPropertyNames(schema) {
335
+ const props = schema?.["properties"];
336
+ return props && typeof props === "object" ? Object.keys(props) : [];
337
+ }
338
+ /**
339
+ * Build the extractor's options for a chair from what the invoker already resolved.
340
+ * Shared by the Claude and Bifrost invokers so the key signal reaches every call site —
341
+ * behaviour propagates through the shared import, but `expectKeys` does not unless each
342
+ * site passes it (#221 policy 5).
343
+ *
344
+ * The two prompt shapes are mutually exclusive, so the derivation is per-shape:
345
+ * - MULTI-output chair — buildPrompt :138-147 asks for a blob keyed by type slug, so the
346
+ * slugs are the expected keys.
347
+ * - SINGLE-output chair — buildPrompt :148-155 asks for the bare data object, never
348
+ * wrapped in {"<type-slug>": …}, so the resolved schema's property names are the
349
+ * signal. Unioning the slug in here would make the set unsatisfiable under
350
+ * all-must-match semantics and destroy the signal entirely.
351
+ * - Neither available (bare core type, or a domain type absent from the registry) —
352
+ * no signal, so refuse to guess between rival candidates.
353
+ */
354
+ export function extractOptionsForChair(sealTypes, schema) {
355
+ if (sealTypes.length > 1)
356
+ return { expectKeys: [...sealTypes] };
357
+ const props = schemaPropertyNames(schema);
358
+ return props.length > 0 ? { expectKeys: props } : { requireUnambiguous: true };
359
+ }
360
+ // The wall-clock bound on one chair's spawn. A tool-granted child has no inherent
361
+ // terminus (it can search/loop), and the gig runs the spawn synchronously — so without
362
+ // this bound one wedged child wedges the whole server. SIGKILL, not SIGTERM: a
363
+ // signal-trapping child can't outlive its budget. Long enough for a tool-using chair
364
+ // (a capped search agent runs minutes), far below an operator-visible hang.
365
+ export const DEFAULT_CHAIR_TIMEOUT_MS = 10 * 60_000;
366
+ // How long a cancelled chair child gets to shut down politely before it is killed outright.
367
+ // SIGTERM first (a `claude` child spawns its own MCP servers; a cooperative exit gives it a
368
+ // chance to take them with it), SIGKILL after. Deliberately NOT `detached: true` +
369
+ // process.kill(-pid): that is the only airtight answer to grandchild orphaning, but it takes
370
+ // children OUT of the server's process group, so an operator's Ctrl-C stops reaching them —
371
+ // which makes #252 worse, not better. Process-group kill is a separate decision.
372
+ export const DEFAULT_ABORT_GRACE_MS = 2_000;
373
+ // #250/#252 — every chair child the invoker spawns, so something can reach them.
374
+ // Before this, `child` was a const inside spawnStreaming's promise executor: never returned,
375
+ // never registered, never exposed, and the ONLY path to child.kill was the timeout closure.
376
+ // A server told to shut down could not stop its own grandchildren, which kept running,
377
+ // orphaned, still billing — with gig tracking dropped, so nothing recorded they existed.
378
+ const LIVE_CHAIR_CHILDREN = new Set();
379
+ /** How many chair children are alive right now (observability for the shutdown path). */
380
+ export function liveChairChildCount() {
381
+ return LIVE_CHAIR_CHILDREN.size;
382
+ }
383
+ /** SIGTERM every live chair child, escalating to SIGKILL after `graceMs`. Returns the count
384
+ * signalled. Called on server shutdown so a restart is not an orphaning. */
385
+ export function killLiveChairChildren(graceMs = DEFAULT_ABORT_GRACE_MS) {
386
+ const victims = [...LIVE_CHAIR_CHILDREN];
387
+ for (const c of victims)
388
+ terminateChild(c, graceMs);
389
+ return victims.length;
390
+ }
391
+ function terminateChild(child, graceMs) {
392
+ const hardKill = () => { try {
393
+ child.kill("SIGKILL");
394
+ }
395
+ catch { /* already gone */ } };
396
+ try {
397
+ child.kill("SIGTERM");
398
+ }
399
+ catch { /* already gone */ }
400
+ if (graceMs <= 0) {
401
+ hardKill();
402
+ return;
403
+ }
404
+ const t = setTimeout(() => {
405
+ if (child.exitCode === null && child.signalCode === null)
406
+ hardKill();
407
+ }, graceMs);
408
+ // never hold the event loop open just to escalate a kill
409
+ t.unref?.();
410
+ }
411
+ // The blast-radius cage, PURE. Given the agent's tool grant + a per-gig mcp-config path,
412
+ // build the claude CLI args. Two halves: `--strict-mcp-config` + `--mcp-config <path>`
413
+ // means the spawn loads ONLY the servers in that file (never the host's ambient MCP) —
414
+ // deny-by-default. `--allowedTools`/`--disallowedTools` scope the tool surface to the
415
+ // agent's declared grant. Ports OG's claude-launcher 4-flag cage.
416
+ export function buildInvokerArgs(prompt, mcpConfigPath, opts) {
417
+ const args = ["-p", prompt];
418
+ if (opts.model)
419
+ args.push("--model", opts.model);
420
+ // per-agent blast-radius cap: a runaway agent can't burn past its own turn budget.
421
+ if (opts.max_tool_calls !== undefined)
422
+ args.push("--max-turns", String(opts.max_tool_calls));
423
+ // the cage floor: no ambient MCP servers leak into the spawn, ever.
424
+ args.push("--mcp-config", mcpConfigPath, "--strict-mcp-config");
425
+ if (opts.allowed_tools && opts.allowed_tools.length > 0)
426
+ args.push("--allowedTools", opts.allowed_tools.join(","));
427
+ if (opts.disallowed_tools && opts.disallowed_tools.length > 0)
428
+ args.push("--disallowedTools", opts.disallowed_tools.join(","));
429
+ return args;
430
+ }
431
+ // The production AgentInvoker. Writes a per-gig mcp-config (the permitted servers only),
432
+ // spawns `claude -p` inside the cage, parses the JSON. The spawn is the non-deterministic
433
+ // seam (inject `run` to test the cage args + parse without the CLI). When a
434
+ // parent_session_id is provided, every spawned MCP server in this child receives it via
435
+ // env so the recorder seals the parent → child lineage edge on the child's first turn.
436
+ export function makeClaudeInvoker(opts = {}) {
437
+ const bin = opts.bin ?? "claude";
438
+ // Injected run (tests) short-circuits the spawn: plain mode, returns the JSON blob directly.
439
+ // Absent → the default streaming spawn below runs the real CLI with stream-json.
440
+ const customRun = opts.run;
441
+ const spawnBounds = { timeout: opts.timeout_ms ?? DEFAULT_CHAIR_TIMEOUT_MS, killSignal: "SIGKILL" };
442
+ // #185 — grant resolution is enabled once the deployment wires a provider registry (either map
443
+ // present). Until then the invoker keeps its legacy pass-through (tools listed, no resolution) so
444
+ // a bare/test invoker is unaffected. bootstrapServerDeps always supplies mcpServerConfigs, so the
445
+ // running engine always resolves + fails closed.
446
+ const resolutionEnabled = opts.toolProviders !== undefined || opts.mcpServerConfigs !== undefined;
447
+ const abortGraceMs = opts.abort_grace_ms ?? DEFAULT_ABORT_GRACE_MS;
448
+ return async (ctx) => {
449
+ // #250 — a chair whose gig is already cancelled spends nothing: no prompt, no mcp-config,
450
+ // no spawn. This is the cheapest point on the whole cancellation chain.
451
+ if (ctx.signal?.aborted) {
452
+ throw new Error(`chair "${ctx.agent.slug}" not started — gig aborted (${abortReasonText(ctx.signal)})`);
453
+ }
454
+ // Resolve THIS agent's grants → the MCP servers it needs, FIRST: a grant with no resolvable
455
+ // provider is a dead name, so fail the chair closed before we build a prompt or spawn a child
456
+ // that advertises a tool it can't call.
457
+ let resolvedMcpServers = {};
458
+ // The grants as the SPAWN must see them in --allowedTools. Default to the raw grant list (the
459
+ // legacy pass-through invoker); when resolution is on, use the resolved names — an in-house engine
460
+ // tool granted by bare slug becomes mcp__<server>__<tool>, the name its server advertises (#204).
461
+ let effectiveAllowed = ctx.agent.allowed_tools;
462
+ if (resolutionEnabled) {
463
+ // The caged browser: if this agent declares a browser_grant, coltrane builds a deny-by-default
464
+ // Playwright server scoped to exactly its allowed origins and offers it as the "playwright"
465
+ // provider. An agent that grants mcp__playwright__* tools but declares NO browser_grant has no
466
+ // playwright config → its grant is unresolvable → fails closed (no uncaged browser, ever).
467
+ const browserCage = playwrightServerFor(ctx.agent.browser_grant);
468
+ const effectiveConfigs = browserCage
469
+ ? { ...(opts.mcpServerConfigs ?? {}), playwright: browserCage }
470
+ : (opts.mcpServerConfigs ?? {});
471
+ // assertToolGrantsResolvable is the single source of the fail-closed guard (it throws on a
472
+ // dead name) AND returns the resolved servers — no duplicated inline throw.
473
+ const resolved = assertToolGrantsResolvable(ctx.agent.slug, ctx.agent.allowed_tools ?? [], opts.toolProviders ?? EMPTY_TOOL_REGISTRY, effectiveConfigs);
474
+ resolvedMcpServers = resolved.mcpServers;
475
+ effectiveAllowed = resolved.effectiveAllowed;
476
+ }
477
+ const types = opts.registry?.listTypes() ?? [];
478
+ const schemaOf = (slug) => types.find((t) => t.slug === slug)?.schema;
479
+ // #174 — schemas follow the chair's promised subset (ctx.output_types), not the agent's
480
+ // whole catalogue; legacy ctx without it falls back to the agent's full output_types.
481
+ const sealTypes = ctx.output_types?.length ? ctx.output_types : ctx.agent.output_types;
482
+ const outType = sealTypes[0];
483
+ const schema = schemaOf(outType);
484
+ // For a multi-output chair, resolve every promised type's schema so the Task layer can
485
+ // ask for a blob keyed by type; the runtime seals one record per key.
486
+ const outputSchemas = sealTypes.length > 1
487
+ ? Object.fromEntries(sealTypes.map((t) => [t, schemaOf(t)]))
488
+ : undefined;
489
+ const prompt = buildPrompt(ctx, schema, outputSchemas);
490
+ // #221 — the key signal for candidate selection, derived from what we just resolved.
491
+ // Threaded into BOTH extract calls below; threading only the injected-run one would
492
+ // leave every real chair unscored.
493
+ const extractOpts = extractOptionsForChair(sealTypes, schema);
494
+ // per-gig mcp-config: only the deployment-permitted servers (empty by default).
495
+ const cfgPath = join(tmpdir(), `coltrane-mcp-${randomUUID()}.json`);
496
+ // the base map (opts.mcpServers) + the per-agent servers its grants resolved to (#185).
497
+ const servers = { ...(opts.mcpServers ?? {}), ...resolvedMcpServers };
498
+ const parent = opts.parent_session_id;
499
+ // Inject parent_session_id env into every named server so children seal lineage.
500
+ const enriched = parent
501
+ ? Object.fromEntries(Object.entries(servers).map(([name, def]) => {
502
+ const d = (def && typeof def === "object" ? def : {});
503
+ const env = (d["env"] && typeof d["env"] === "object" ? d["env"] : {});
504
+ return [name, { ...d, env: { ...env, COLTRANE_PARENT_SESSION_ID: parent } }];
505
+ }))
506
+ : servers;
507
+ writeFileSync(cfgPath, JSON.stringify({ mcpServers: enriched }));
508
+ try {
509
+ const a = ctx.agent;
510
+ // #237 — a shallow run depth tightens the turn cap; it never widens the agent's own.
511
+ const depthCap = ctx.depth ? DEPTH_MAX_TOOL_CALLS[ctx.depth] : undefined;
512
+ const maxToolCalls = depthCap === undefined
513
+ ? a.max_tool_calls
514
+ : Math.min(depthCap, a.max_tool_calls ?? depthCap);
515
+ const baseArgs = buildInvokerArgs(prompt, cfgPath, {
516
+ model: resolveModel(a.model_tier, opts.model),
517
+ allowed_tools: effectiveAllowed,
518
+ disallowed_tools: [...(a.disallowed_tools ?? []), ...codeToolDenials(a.code_tool_access)],
519
+ max_tool_calls: maxToolCalls,
520
+ });
521
+ // Custom run (tests): plain mode, the returned string IS the JSON blob — no streaming.
522
+ if (customRun)
523
+ return extractJson(await customRun(bin, baseArgs, spawnBounds), extractOpts);
524
+ // Default: stream-json so the child's tool calls / reasoning are observable LIVE. Each
525
+ // event is forwarded to ctx.onEvent (the runtime tees it to the gig's per-chair log);
526
+ // the final result text is extracted from the stream and parsed into the typed output.
527
+ const args = [...baseArgs, "--output-format", "stream-json", "--verbose"];
528
+ // #250 level 3 — the cancellation signal and grace window reach the spawn, so an
529
+ // aborted gig actually kills its chair child (and never spawns one if already aborted).
530
+ const stdout = await spawnStreaming(bin, args, spawnBounds, ctx.onEvent, ctx.signal, abortGraceMs);
531
+ const outcome = finalText(stdout);
532
+ // #223 — the child reported an error result. Both discriminators are required, and
533
+ // both are verified against the CLI (see the note on StreamOutcome): `subtype` for a
534
+ // run that did not complete, `is_error` for an API-error payload riding subtype
535
+ // "success". Neither is a chair answer, and the CLI exits 0 for the subtype cases —
536
+ // so without this the partial reasoning seals as if it had succeeded.
537
+ if (outcome.errorSubtype !== undefined) {
538
+ throw new Error(`claude ended with result subtype "${outcome.errorSubtype}" — the run did not ` +
539
+ `complete, so any text it emitted is partial reasoning, not an answer`);
540
+ }
541
+ if (outcome.apiErrorText !== undefined) {
542
+ throw new Error(`claude flagged its result with is_error — the payload is an error message, not an ` +
543
+ `answer: ${outcome.apiErrorText.slice(0, 300)}`);
544
+ }
545
+ // #222 — the stream parsed but carried no answer at all (e.g. only a system/init
546
+ // event). Report THAT, with the raw stdout as evidence, instead of blaming the model
547
+ // for emitting no JSON.
548
+ if (outcome.text.trim() === "") {
549
+ throw new ModelOutputParseError("the model produced no answer — the stream carried no result text and no assistant text", 0, stdout);
550
+ }
551
+ return extractJson(outcome.text, extractOpts);
552
+ }
553
+ finally {
554
+ try {
555
+ unlinkSync(cfgPath);
556
+ }
557
+ catch { /* best-effort cleanup */ }
558
+ }
559
+ };
560
+ }
561
+ // Spawn a child and stream its stdout line-by-line. Each complete line is parsed as a
562
+ // stream-json event and forwarded (granularly) to onEvent as it arrives — this is the
563
+ // agent-layer observability seam. Returns the full stdout on clean exit; rejects on
564
+ // non-zero exit (with stderr), timeout (SIGKILL, so a signal-trapping child can't survive),
565
+ // or cancellation via `signal` (SIGTERM → grace → SIGKILL).
566
+ //
567
+ // The child is REGISTERED in LIVE_CHAIR_CHILDREN for its whole lifetime (#250/#252): a handle
568
+ // nothing holds is a process nothing can stop.
569
+ function spawnStreaming(bin, args, bounds, onEvent, signal, abortGraceMs = DEFAULT_ABORT_GRACE_MS) {
570
+ return new Promise((resolve, reject) => {
571
+ if (signal?.aborted) {
572
+ reject(new Error(`chair child not spawned — gig aborted (${abortReasonText(signal)})`));
573
+ return;
574
+ }
575
+ const child = spawn(bin, [...args], { stdio: ["ignore", "pipe", "pipe"] });
576
+ LIVE_CHAIR_CHILDREN.add(child);
577
+ let stdout = "";
578
+ let stderr = "";
579
+ let buf = "";
580
+ let timer;
581
+ let onAbort;
582
+ const release = () => {
583
+ if (timer)
584
+ clearTimeout(timer);
585
+ LIVE_CHAIR_CHILDREN.delete(child);
586
+ if (onAbort && signal)
587
+ signal.removeEventListener("abort", onAbort);
588
+ };
589
+ if (signal) {
590
+ onAbort = () => {
591
+ const reason = abortReasonText(signal);
592
+ terminateChild(child, abortGraceMs);
593
+ release();
594
+ reject(new Error(`chair child aborted: ${reason}`));
595
+ };
596
+ signal.addEventListener("abort", onAbort, { once: true });
597
+ }
598
+ timer = setTimeout(() => {
599
+ child.kill(bounds.killSignal);
600
+ release();
601
+ reject(new Error(`chair child timed out after ${bounds.timeout}ms (${bounds.killSignal})`));
602
+ }, bounds.timeout);
603
+ const forwardLine = (line) => {
604
+ if (!line || !onEvent)
605
+ return;
606
+ try {
607
+ forwardStreamEvent(JSON.parse(line), onEvent);
608
+ }
609
+ catch { /* non-json line */ }
610
+ };
611
+ child.stdout.on("data", (chunk) => {
612
+ const s = chunk.toString();
613
+ stdout += s;
614
+ buf += s;
615
+ let nl;
616
+ while ((nl = buf.indexOf("\n")) >= 0) {
617
+ const line = buf.slice(0, nl).trim();
618
+ buf = buf.slice(nl + 1);
619
+ forwardLine(line);
620
+ }
621
+ });
622
+ child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
623
+ child.on("error", (e) => { release(); reject(e); });
624
+ child.on("close", (code) => {
625
+ // #250/#252 — release() subsumes the old clearTimeout: it also deregisters the child
626
+ // from LIVE_CHAIR_CHILDREN and drops the abort listener. It runs FIRST so a throwing
627
+ // onEvent in the flush below cannot leak the registration or the timer.
628
+ release();
629
+ // #224 — the read loop only drains on "\n", so a final line with no trailing newline
630
+ // was never forwarded. The usage sink (runtime.ts makeUsageSink) reads total_cost_usd
631
+ // ONLY from result events, so that chair's spend silently vanished from GigResult.usage
632
+ // and the per-chair jsonl lost its last event. finalText was unaffected (it re-splits
633
+ // the whole stdout), which is exactly why it was silent: the run succeeded and only
634
+ // the accounting was wrong. Flush before settling, on the failure path too — a chair
635
+ // that failed still spent money.
636
+ const tail = buf.trim();
637
+ buf = "";
638
+ forwardLine(tail);
639
+ if (code !== 0)
640
+ reject(new Error(`claude exited ${code}: ${stderr.slice(0, 500)}`));
641
+ else
642
+ resolve(stdout);
643
+ });
644
+ });
645
+ }
646
+ // Map a child stream-json event to granular AgentStreamEvents. assistant content explodes
647
+ // into per-block tool_use / text events (so a monitor sees each tool call); result passes
648
+ // its text; everything else passes its type + raw.
649
+ function forwardStreamEvent(evt, onEvent) {
650
+ const type = String(evt["type"] ?? "event");
651
+ if (type === "assistant" && evt["message"] && typeof evt["message"] === "object") {
652
+ const content = evt["message"].content ?? [];
653
+ for (const b of content) {
654
+ const bt = String(b["type"] ?? "");
655
+ if (bt === "tool_use")
656
+ onEvent({ type: "tool_use", tool: String(b["name"] ?? ""), raw: b });
657
+ else if (bt === "text")
658
+ onEvent({ type: "assistant", text: String(b["text"] ?? ""), raw: b });
659
+ }
660
+ return;
661
+ }
662
+ if (type === "result") {
663
+ onEvent({ type: "result", text: typeof evt["result"] === "string" ? evt["result"] : undefined, raw: evt });
664
+ return;
665
+ }
666
+ onEvent({ type, raw: evt });
667
+ }
668
+ // The `type` values the CLI's stream-json actually emits (SDKMessage, sdk.d.ts:370). A line
669
+ // that merely PARSES as JSON is NOT a stream event — that conflation is #222: the model's own
670
+ // answer parses and carries no `type`, which flipped the old `parsedAny` flag and made the
671
+ // raw-stdout fallback unreachable for the very payload it existed to rescue.
672
+ const STREAM_EVENT_TYPES = new Set([
673
+ "assistant",
674
+ "user",
675
+ "result",
676
+ "system",
677
+ "stream_event",
678
+ ]);
679
+ /**
680
+ * Pick the assistant text block that IS the answer.
681
+ *
682
+ * Concatenating every block across the run (the old behaviour) glues intermediate
683
+ * reasoning in front of the answer, and the extractor then has to choose between the
684
+ * reasoning's objects and the real one. Prefer the LAST block that is nothing but a JSON
685
+ * object — that is precisely what buildPrompt asks for ("Respond with ONLY a single JSON
686
+ * object — no prose, no code fence", :153), so such a block is the model complying, while
687
+ * a block with chatter wrapped around an object is commentary. Falls back to the final
688
+ * block when no block is a bare object (then the extractor's own policy decides).
689
+ */
690
+ function answerBlock(blocks) {
691
+ for (let i = blocks.length - 1; i >= 0; i--) {
692
+ const b = (blocks[i] ?? "").trim();
693
+ if (b.startsWith("{") && b.endsWith("}")) {
694
+ try {
695
+ const v = JSON.parse(b);
696
+ if (v && typeof v === "object" && !Array.isArray(v))
697
+ return b;
698
+ }
699
+ catch { /* not a bare object — keep looking */ }
700
+ }
701
+ }
702
+ return blocks.length > 0 ? (blocks[blocks.length - 1] ?? "") : "";
703
+ }
704
+ /**
705
+ * Read a stream-json stdout into the answer text (plus any error the CLI reported).
706
+ * Falls back to the raw stdout when no recognized stream event appeared at all — the
707
+ * plain `-p` shape the old `parsedAny` check claimed to handle and did not.
708
+ */
709
+ function finalText(stdout) {
710
+ const lines = stdout.split("\n").map((l) => l.trim()).filter(Boolean);
711
+ let result;
712
+ let errorSubtype;
713
+ let apiErrorText;
714
+ const assistant = [];
715
+ let sawStreamEvent = false;
716
+ for (const l of lines) {
717
+ let e;
718
+ try {
719
+ e = JSON.parse(l);
720
+ }
721
+ catch {
722
+ continue; /* non-json */
723
+ }
724
+ const type = typeof e["type"] === "string" ? e["type"] : "";
725
+ if (!STREAM_EVENT_TYPES.has(type))
726
+ continue;
727
+ sawStreamEvent = true;
728
+ if (type === "result") {
729
+ const subtype = typeof e["subtype"] === "string" ? e["subtype"] : "";
730
+ if (subtype !== "" && subtype !== "success") {
731
+ errorSubtype = subtype;
732
+ continue;
733
+ }
734
+ if (e["is_error"] === true) {
735
+ apiErrorText = typeof e["result"] === "string" ? e["result"] : "";
736
+ continue;
737
+ }
738
+ if (typeof e["result"] === "string")
739
+ result = e["result"];
740
+ }
741
+ else if (type === "assistant" && e["message"] && typeof e["message"] === "object") {
742
+ const content = e["message"].content ?? [];
743
+ for (const b of content)
744
+ if (b["type"] === "text")
745
+ assistant.push(String(b["text"] ?? ""));
746
+ }
747
+ }
748
+ if (errorSubtype !== undefined)
749
+ return { text: "", errorSubtype };
750
+ if (apiErrorText !== undefined)
751
+ return { text: "", apiErrorText };
752
+ if (!sawStreamEvent)
753
+ return { text: stdout };
754
+ // #222 — `""` IS a string, so `result ?? assistant.join("\n")` returned the empty result
755
+ // and beat real assistant text. Nullish coalescing was the bug; emptiness is the test.
756
+ if (result !== undefined && result.trim() !== "")
757
+ return { text: result };
758
+ return { text: answerBlock(assistant) };
759
+ }
760
+ //# sourceMappingURL=claude_invoker.js.map