@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,1099 @@
1
+ // §13/runtime — the gig executor. Walks a standard's phases, invokes each agent
2
+ // (via an INJECTED invoker so the orchestration is testable without spawning Claude),
3
+ // writes each typed output to the store (validated), links provenance (derived_from),
4
+ // and records one ledger entry with a deterministic genome_hash + a run_fingerprint
5
+ // that carries model_version + (empty, v0) eval_scores — honestly un-tempered.
6
+ import { randomUUID } from "node:crypto";
7
+ import { PRIMITIVE_OUTPUT_TYPE, CORE_TYPES } from "./core_types.js";
8
+ import { executeSkill } from "./skill_subprocess.js";
9
+ import { loadSkillPackage } from "./skills.js";
10
+ // core type → the process primitive that produces it (reverse of PRIMITIVE_OUTPUT_TYPE).
11
+ // A skill-backed chair seals its output as this primitive/core when its output_contract is
12
+ // a core type.
13
+ const CORE_TO_PRIMITIVE = Object.fromEntries(Object.entries(PRIMITIVE_OUTPUT_TYPE).map(([prim, core]) => [String(core), prim]));
14
+ import { sha256Hex, canonJson, runFingerprint, outputContentHash, CANONICAL_FORM_VERSION } from "./canonical_form.js";
15
+ import { LEDGER_SCHEMA_VERSION } from "./ledger.js";
16
+ /**
17
+ * #236 — settled spend used to be discarded on every failed gig: `usage` was written only on
18
+ * the success path, and the async dispatcher's `.catch` set status/error and nothing else. A
19
+ * gig that burned $6 across four chairs and died on the fifth reported zero dollars, everywhere
20
+ * — and failed gigs are exactly the ones whose cost an operator most needs. runGig now attaches
21
+ * the partial accounting to whatever it throws; these read it back safely.
22
+ */
23
+ export function partialGigUsage(e) {
24
+ if (!e || typeof e !== "object")
25
+ return undefined;
26
+ const u = e["usage"];
27
+ return u && typeof u === "object" ? u : undefined;
28
+ }
29
+ export function partialBudgetState(e) {
30
+ if (!e || typeof e !== "object")
31
+ return undefined;
32
+ const b = e["budget_state"];
33
+ return b && typeof b === "object" ? b : undefined;
34
+ }
35
+ export class RuntimeError extends Error {
36
+ }
37
+ /**
38
+ * Raised when a gig is cancelled through `RunDeps.signal` (#249). Distinct from RuntimeError
39
+ * so a caller can tell "an operator stopped this" from "this crashed" — #251's point that a
40
+ * killed gig surfacing as `failed` with a kill-shaped error is indistinguishable from a
41
+ * genuine crash.
42
+ *
43
+ * Carries the spend accrued BEFORE the cancellation. Today abort does nothing, so the gig
44
+ * completes and its cost IS recorded; a fix that kills children without capturing accrued
45
+ * usage would improve cost control while regressing accounting. The dispatcher folds this
46
+ * onto the run state so `gig_monitor` still answers "what did this cost me".
47
+ */
48
+ export class GigAborted extends Error {
49
+ gig_id;
50
+ reason;
51
+ usage;
52
+ outputs;
53
+ constructor(gig_id, reason, usage, outputs) {
54
+ super(`GigAborted: gig "${gig_id}" was aborted — ${reason}`);
55
+ this.name = "GigAborted";
56
+ this.gig_id = gig_id;
57
+ this.reason = reason;
58
+ this.usage = usage;
59
+ this.outputs = outputs;
60
+ }
61
+ }
62
+ /** The human-readable cause behind an AbortSignal, whatever shape the aborter used. */
63
+ export function abortReasonText(signal) {
64
+ const r = signal.reason;
65
+ if (typeof r === "string" && r.trim().length > 0)
66
+ return r;
67
+ if (r instanceof Error && r.message)
68
+ return r.message;
69
+ return "cancelled";
70
+ }
71
+ /**
72
+ * Raised when a gig's budget cannot cover the next agent's cost-of-append.
73
+ * Carries the agent_slug, the available balance, and the required cost so
74
+ * the caller can render the exact reason. The in-memory BudgetState is
75
+ * also attached for downstream telemetry.
76
+ */
77
+ export class BudgetExhausted extends Error {
78
+ agent_slug;
79
+ balance;
80
+ cost;
81
+ state;
82
+ constructor(agent_slug, balance, cost, state) {
83
+ super(`BudgetExhausted: agent "${agent_slug}" needs cost=${cost} but balance=${balance} (opening=${state.opening}, spent=${state.spent}, credit=${state.credit})`);
84
+ this.name = "BudgetExhausted";
85
+ this.agent_slug = agent_slug;
86
+ this.balance = balance;
87
+ this.cost = cost;
88
+ this.state = state;
89
+ }
90
+ }
91
+ /**
92
+ * Cost-of-append for an agent invocation, in synthetic append units (see BudgetState.unit).
93
+ * Deterministic function of the input context size — same input → same cost. Keeps cost
94
+ * calculation inside the runtime (not the invoker) so budget enforcement cannot be spoofed by
95
+ * a misbehaving invoker.
96
+ *
97
+ * #233 — this used to serialize `input_ids`: the UUIDs of the upstream outputs a chair
98
+ * consumes, not their data. An upstream output contributed exactly 36 bytes whether it was a
99
+ * one-line signal or a 40-page draft, so the proxy was not even monotonic in the thing that
100
+ * drives real cost. It now measures the CONTENT the invoker actually receives.
101
+ *
102
+ * Still excluded, honestly: resolved skills, the agent charter, type schemas, model tier,
103
+ * max_tool_calls, and the entire output side. This is a rate limiter on consumed context, not
104
+ * a price. Money is `BudgetState.settled_usd`.
105
+ */
106
+ export function computeAppendCost(ctx, base_cost, k) {
107
+ // Canonical serialization of what the invoker actually sees. JSON.stringify
108
+ // is sufficient for v0 — deterministic order isn't required since size is
109
+ // the only thing we extract, and Record key order in V8 is insertion-stable.
110
+ const size_bytes = JSON.stringify({
111
+ agent_slug: ctx.agent.slug,
112
+ phase: ctx.phase,
113
+ inputs: ctx.inputs.map((i) => i.data),
114
+ gig_input: ctx.gig_input,
115
+ }).length;
116
+ return base_cost + k * size_bytes;
117
+ }
118
+ // Deterministic hash over the definitions a gig touches: the standard + its agents,
119
+ // in a canonical (sorted, JCS) form. This is the reproducibility key — same defs,
120
+ // same genome_hash, regardless of model or run.
121
+ /**
122
+ * Resolve a list of skill slugs against the genome's skills map. REPORTS, never decides:
123
+ * it returns both what resolved and what did not, and `prepareChair` decides what a miss
124
+ * means (fatal when the chair declared the skill REQUIRED, reported otherwise).
125
+ *
126
+ * The boundary (#241): a skill package that LOADS is a legitimate degradation candidate —
127
+ * it has an identity, a version, a code_hash, and its degradation is already surfaced and
128
+ * sealed via `degraded_reason` (src/skills.ts resolveSkill). A slug that resolves to NO
129
+ * PACKAGE AT ALL has nothing to degrade; it is a dangling reference, exactly the shape
130
+ * `assertToolGrantsResolvable` already fails closed on ("a granted tool with no provider
131
+ * is a dead name") and exactly the shape `Chair.skill_slug` already hard-throws on.
132
+ *
133
+ * An ABSENT map means resolution was never configured (the documented v0 back-compat path)
134
+ * — that is not evidence of a dangling binding, so `missing` stays empty.
135
+ */
136
+ function resolveSkills(slugs, map) {
137
+ if (!slugs || slugs.length === 0 || !map)
138
+ return { skills: [], missing: [] };
139
+ const skills = [];
140
+ const missing = [];
141
+ for (const slug of slugs) {
142
+ const rec = map.get(slug);
143
+ if (rec)
144
+ skills.push(rec);
145
+ else
146
+ missing.push(slug);
147
+ }
148
+ return { skills, missing };
149
+ }
150
+ function genomeHash(standard) {
151
+ const agents = [...standard.agents]
152
+ .map((a) => ({
153
+ slug: a.slug,
154
+ primitives: a.primitives,
155
+ input_types: a.input_types,
156
+ output_types: a.output_types,
157
+ domain: a.domain,
158
+ }))
159
+ .sort((x, y) => (x.slug < y.slug ? -1 : 1));
160
+ return sha256Hex(canonJson({ standard: { slug: standard.slug, domain: standard.domain, phases: standard.phases }, agents }));
161
+ }
162
+ /**
163
+ * Execute one gig: walk phases in order, each phase's agent consumes the prior
164
+ * outputs that match its input_types, produces a typed output, which is validated
165
+ * + stored + provenance-linked. One immutable ledger entry records the run.
166
+ */
167
+ // Subtype-aware type match (docs/genome-extension.md — polymorphism). A declared
168
+ // type is satisfied by an output of the SAME domain_type (exact) OR — when the
169
+ // declared type is a CORE type — by any output whose core_type is that core (i.e.
170
+ // a domain type extending it). Domain-type declarations stay exact; only core-type
171
+ // declarations are polymorphic, so a base player written against `Interpretation`
172
+ // consumes any downstream subtype while domain contracts keep their precision.
173
+ const CORE_TYPE_SET = new Set(CORE_TYPES);
174
+ function outputSatisfiesType(output, declared) {
175
+ if (output.domain_type === declared)
176
+ return true;
177
+ if (CORE_TYPE_SET.has(declared) && output.core_type === declared)
178
+ return true;
179
+ return false;
180
+ }
181
+ export async function runGig(standard, gigInput, deps) {
182
+ const gig_id = deps.gig_id ?? randomUUID();
183
+ const started_at = new Date().toISOString();
184
+ const produced = [];
185
+ // #195 — settled model spend, accumulated from each agent invocation's `result` event (the
186
+ // stream-json result carries usage + total_cost_usd + a per-model breakdown). These were
187
+ // forwarded to onEvent but dropped; we fold them here and persist on the ledger entry. JS is
188
+ // single-threaded, so += from the concurrent chair callbacks is race-free.
189
+ //
190
+ // #235 — capture is now ATTRIBUTED. The old gate was one gig-wide boolean flipped by the
191
+ // first `result` event, which could not express three real states: (1) N chairs ran and one
192
+ // reported, (2) a `result` event carrying no usage payload at all — whose implicit zeros
193
+ // were folded in and reported as "$0.00 spent" where the truth is "unknown", and (3) a cost
194
+ // with no per-model breakdown. Every invocation is now counted, and an invocation that
195
+ // reports nothing is counted as UNATTRIBUTED rather than as free.
196
+ const usage = { input_tokens: 0, output_tokens: 0, total_cost_usd: 0, by_model: {} };
197
+ let startedInvocations = 0;
198
+ let attributedInvocations = 0;
199
+ let byModelPartial = false;
200
+ // One sink per chair — `onEvent` is already per-chair, so attribution is expressible at the
201
+ // only granularity that means anything. Returns whether THIS chair ever reported usage.
202
+ const makeUsageSink = () => {
203
+ let saw = false;
204
+ return {
205
+ attributed: () => saw,
206
+ fold(ev) {
207
+ if (ev.type !== "result")
208
+ return;
209
+ const raw = ev.raw;
210
+ if (!raw)
211
+ return;
212
+ const u = raw["usage"];
213
+ const mu = raw["modelUsage"];
214
+ const costRaw = raw["total_cost_usd"];
215
+ const inRaw = u?.["input_tokens"];
216
+ const outRaw = u?.["output_tokens"];
217
+ const hasCost = typeof costRaw === "number";
218
+ const hasTokens = typeof inRaw === "number" || typeof outRaw === "number";
219
+ const hasBreakdown = !!mu && Object.keys(mu).length > 0;
220
+ // A `result` event with no usage payload at all tells us NOTHING. Folding its implicit
221
+ // zeros in is how "not captured" became "$0.00 spent" — the single most misleading
222
+ // number this engine could produce about money.
223
+ if (!hasCost && !hasTokens && !hasBreakdown)
224
+ return;
225
+ usage.input_tokens += typeof inRaw === "number" ? inRaw : 0;
226
+ usage.output_tokens += typeof outRaw === "number" ? outRaw : 0;
227
+ usage.total_cost_usd += hasCost ? costRaw : 0;
228
+ // Per-model breakdown keyed by the ACTUAL model id that ran (not the configured tier).
229
+ if (hasBreakdown) {
230
+ for (const [model, m] of Object.entries(mu)) {
231
+ const slot = usage.by_model[model] ?? { input_tokens: 0, output_tokens: 0, cost_usd: 0 };
232
+ slot.input_tokens += typeof m["inputTokens"] === "number" ? m["inputTokens"] : 0;
233
+ slot.output_tokens += typeof m["outputTokens"] === "number" ? m["outputTokens"] : 0;
234
+ slot.cost_usd += typeof m["costUSD"] === "number" ? m["costUSD"] : 0;
235
+ usage.by_model[model] = slot;
236
+ }
237
+ }
238
+ else {
239
+ // The scalars moved but `by_model` did not — the breakdown cannot sum to the total.
240
+ byModelPartial = true;
241
+ }
242
+ saw = true;
243
+ },
244
+ };
245
+ };
246
+ // Stamp the coverage counters and return the usage IFF anything was genuinely captured.
247
+ // Idempotent: called on the success path and again from the failure path (#236).
248
+ const finalizeUsage = () => {
249
+ if (attributedInvocations === 0)
250
+ return undefined;
251
+ usage.invocations = startedInvocations;
252
+ usage.unattributed_invocations = startedInvocations - attributedInvocations;
253
+ if (usage.unattributed_invocations > 0)
254
+ usage.partial = true;
255
+ else
256
+ delete usage.partial;
257
+ if (byModelPartial)
258
+ usage.by_model_partial = true;
259
+ else
260
+ delete usage.by_model_partial;
261
+ return usage;
262
+ };
263
+ // #156 — the standard's declared gig inputs. A chair reads a declared gig-input type from
264
+ // gigInput rather than from an upstream record (composition.ts: "gig inputs are available to
265
+ // any chair"). The payload is validated BEFORE any chair fires — a missing gig input is a
266
+ // hard stop, so no model tokens are spent on bad input.
267
+ const standardInputs = new Set(standard.input_types ?? []);
268
+ // Keys are the HYPHENATED type slug. `grant_requirements` vs `grant-requirements` is the
269
+ // single most common dispatch mistake, and the caller's own keys are in scope here.
270
+ const normalizeKey = (k) => k.toLowerCase().replace(/[_\-\s]/g, "");
271
+ /**
272
+ * #244 — the error must blame the layer that is actually wrong. "upstream outputs only
273
+ * provide [...]" sent the operator to inspect a pipeline that is correctly wired when the
274
+ * real cause is a missing key in their own dispatch payload. The `fromGig` disjunction
275
+ * knows which branch failed; this carries that knowledge into the message.
276
+ */
277
+ function missingGigInput(need, role, upstreamProvided) {
278
+ const provided = Object.keys(gigInput);
279
+ const target = normalizeKey(need);
280
+ const nearMiss = provided.filter((k) => k !== need && normalizeKey(k) === target);
281
+ const unknown = provided.filter((k) => !standardInputs.has(k));
282
+ const quoted = (ks) => ks.map((k) => `"${k}"`).join(", ");
283
+ const hint = nearMiss.length > 0
284
+ ? ` The payload carries ${quoted(nearMiss)} — gig input keys are the hyphenated type slug, so did you mean "${need}"?`
285
+ : provided.length === 0
286
+ ? ` The dispatch payload is empty.`
287
+ : ` The payload's keys are [${quoted(provided)}]${unknown.length > 0 ? `; not declared by this standard: [${quoted(unknown)}]` : ""}.`;
288
+ const upstream = upstreamProvided !== undefined
289
+ ? ` (upstream provided [${upstreamProvided}], but "${need}" is a declared gig input and must come from the dispatch payload)`
290
+ : ` (no upstream chair produces "${need}", so it can only come from the dispatch payload)`;
291
+ return new RuntimeError(`gig input missing "${need}" required by chair "${role}" (MissingGigInput)${upstream}.${hint}`);
292
+ }
293
+ // What each role will seal — known statically from the composed standard, which is what
294
+ // makes the pre-flight below possible at t=0.
295
+ const sealedByRole = new Map();
296
+ for (const ph of standard.phases) {
297
+ for (const ch of ph.chairs) {
298
+ if (ch.skill_slug && (ch.agent_slug ?? "") === "") {
299
+ sealedByRole.set(ch.role, [ch.output_contract[0] ?? "Signal"]);
300
+ continue;
301
+ }
302
+ const ag = standard.agents.find((a) => a.slug === ch.agent_slug);
303
+ if (!ag)
304
+ continue; // prepareChair reports an unknown agent_slug precisely; don't pre-empt it
305
+ sealedByRole.set(ch.role, ch.output_contract.length ? ag.output_types.filter((t) => ch.output_contract.includes(t)) : ag.output_types);
306
+ }
307
+ }
308
+ // Type-name mirror of outputSatisfiesType (no record exists yet at t=0). Deliberately
309
+ // PERMISSIVE: an unresolvable core is treated as "might satisfy", so the pre-flight can
310
+ // only ever fire on a PROVABLE miss and can never reject a runnable gig.
311
+ const mightSatisfy = (producedType, need) => {
312
+ if (producedType === need)
313
+ return true;
314
+ if (!CORE_TYPE_SET.has(need))
315
+ return false;
316
+ const core = deps.outputs.coreTypeOf(producedType);
317
+ return core === null || core === need;
318
+ };
319
+ // The pre-flight itself. v0 checked ONLY phase 0, and within it only `depends_on: []`
320
+ // chairs — while its own comment promised "every entry chair" and "no model tokens are
321
+ // spent". Both exclusions are reachable with a validly composed standard, so a real chair
322
+ // fired and burned real money before a failure that was knowable before the gig started.
323
+ {
324
+ const producedByEarlierPhases = [];
325
+ for (const ph of standard.phases) {
326
+ const sealedThisPhase = [];
327
+ for (const ch of ph.chairs) {
328
+ // Mirrors prepareChair's input gathering: declared deps, else everything sealed by a
329
+ // strictly-earlier phase (a superset of the legacy input_types filter — permissive).
330
+ const reachable = ch.depends_on.length > 0
331
+ ? ch.depends_on.flatMap((d) => sealedByRole.get(d) ?? [])
332
+ : producedByEarlierPhases;
333
+ for (const need of ch.input_contract) {
334
+ if (!standardInputs.has(need))
335
+ continue; // not a gig input — upstream's job
336
+ if (gigInput[need] !== undefined)
337
+ continue; // supplied
338
+ if (reachable.some((t) => mightSatisfy(t, need)))
339
+ continue; // an upstream can cover it
340
+ throw missingGigInput(need, ch.role);
341
+ }
342
+ sealedThisPhase.push(...(sealedByRole.get(ch.role) ?? []));
343
+ }
344
+ producedByEarlierPhases.push(...sealedThisPhase);
345
+ }
346
+ }
347
+ // Best-effort progress sink — a logging/monitor sink must never break the run.
348
+ const emit = (ev) => {
349
+ try {
350
+ deps.onProgress?.(ev);
351
+ }
352
+ catch { /* observability must not fail the gig */ }
353
+ };
354
+ // #249/#250 — the cancellation checkpoint. Level 1 of the abort chain: cheap, deterministic,
355
+ // and where MOST of the post-abort spend was going. A standard with P sequential phases could
356
+ // burn P x DEFAULT_CHAIR_TIMEOUT_MS after gig_abort returned, because nothing between phases
357
+ // (or between dispatch batches) ever asked whether it should still be running.
358
+ const checkpoint = () => {
359
+ if (!deps.signal?.aborted)
360
+ return;
361
+ const reason = abortReasonText(deps.signal);
362
+ emit({ type: "gig_aborted", reason });
363
+ // NOTE (integration): #251 reads the gig-wide `sawUsage` boolean that #235 removed in
364
+ // favour of per-chair attribution. finalizeUsage() is its exact replacement — undefined
365
+ // when nothing was genuinely captured — and it additionally stamps the coverage counters,
366
+ // so an aborted gig reports "N started, M unattributed" rather than a bare total. It is
367
+ // idempotent, which is what makes it safe on this path. No merge conflict; tsc caught it.
368
+ throw new GigAborted(gig_id, reason, finalizeUsage(), produced);
369
+ };
370
+ // Budget state. When deps.budget is undefined, enforcement is OFF (back-compat).
371
+ // When present, we track an in-memory BudgetState mirroring budget-state.json.
372
+ const budget = deps.budget
373
+ ? {
374
+ opening: deps.budget.opening,
375
+ spent: 0,
376
+ credit: 0,
377
+ balance: deps.budget.opening,
378
+ agent_state: "active",
379
+ depleted_agent: null,
380
+ depleted_at: null,
381
+ base_cost: deps.budget.base_cost ?? 1,
382
+ k: deps.budget.k ?? 0.1,
383
+ unit: "append-units",
384
+ settled_usd: 0,
385
+ }
386
+ : null;
387
+ // #232 — cost RESERVED by chairs that passed the gate but have not settled. `prepareChair`
388
+ // runs eagerly for the whole ready batch, so the gate must see its batch siblings' holds;
389
+ // but a hold is not spend. It converts to `spent` only when the invocation succeeds, and is
390
+ // released (never charged) when it fails or when a later sibling trips the gate and the
391
+ // batch is abandoned before a single invoker is called.
392
+ let reserved = 0;
393
+ // Resolve agent-by-slug once.
394
+ const agentBySlug = new Map(standard.agents.map((a) => [a.slug, a]));
395
+ // Cross-phase role → output map. A chair in phase N can depends_on a chair
396
+ // in phase 0..N-1; this map carries each completed chair's outputS by role so
397
+ // downstream chairs can resolve their depends_on regardless of phase. A chair can
398
+ // seal MORE than one record (one per declared output type — e.g. a SENSE+JUDGE agent
399
+ // yields both a Signal hit and a Judgment verdict), so a role maps to a LIST; a
400
+ // dependent receives all of a role's records and picks the type its contract needs.
401
+ const producedByRole = new Map();
402
+ // #243 — chairs that sealed fewer types than their output_contract promised, collected for
403
+ // the manifest. Recording, not enforcement: see the seal loop.
404
+ const unfulfilledOutputs = [];
405
+ try {
406
+ for (const phase of standard.phases) {
407
+ checkpoint(); // between phases — the cheapest place to stop, and the biggest saving
408
+ emit({ type: "phase_start", phase: phase.name, roles: phase.chairs.map((c) => c.role) });
409
+ // Per-phase DAG executor. Chairs whose `depends_on` is fully covered by
410
+ // already-produced roles form the next dispatch-batch and run in parallel
411
+ // via Promise.allSettled. Failures from any chair in the batch are joined
412
+ // into a single RuntimeError naming every failing chair role. Cross-phase
413
+ // depends_on works because `producedByRole` carries across phases.
414
+ const remaining = new Map();
415
+ for (const ch of phase.chairs)
416
+ remaining.set(ch.role, ch);
417
+ while (remaining.size > 0) {
418
+ checkpoint(); // between dispatch batches — stops the NEXT topological level from firing
419
+ // Topological level: every chair whose depends_on ⊂ already-produced roles.
420
+ let ready = [];
421
+ for (const ch of remaining.values()) {
422
+ if (ch.depends_on.every((dep) => producedByRole.has(dep)))
423
+ ready.push(ch);
424
+ }
425
+ if (ready.length === 0) {
426
+ // No chair can advance — at least one depends_on is unresolved at
427
+ // runtime even though composition passed. Shouldn't happen since
428
+ // composeStandard rejects forward/unknown/cycle, but guard so a
429
+ // hand-rolled Standard literal can't wedge the runtime silently.
430
+ const stuck = [...remaining.values()].map((c) => c.role).join(", ");
431
+ throw new RuntimeError(`phase "${phase.name}" cannot advance — chairs [${stuck}] have unresolved depends_on`);
432
+ }
433
+ // Routing policy: when a selector is injected, it narrows the frontier to the
434
+ // chairs to dispatch THIS iteration; the rest stay in `remaining` and re-enter
435
+ // the next frontier. The return is validated strictly — empty or containing a
436
+ // chair outside the frontier is a policy bug and must not pass silently.
437
+ if (deps.selectChairs) {
438
+ const chosen = await deps.selectChairs({
439
+ phase: phase.name, ready, produced, budget, standard,
440
+ });
441
+ const readyRoles = new Set(ready.map((c) => c.role));
442
+ if (chosen.length === 0) {
443
+ throw new RuntimeError(`phase "${phase.name}" selectChairs returned no chairs from ready frontier [${[...readyRoles].join(", ")}]`);
444
+ }
445
+ const outside = chosen.filter((c) => !readyRoles.has(c.role));
446
+ if (outside.length > 0) {
447
+ throw new RuntimeError(`phase "${phase.name}" selectChairs returned chair(s) outside the ready frontier: [${outside.map((c) => c.role).join(", ")}] (ready: [${[...readyRoles].join(", ")}])`);
448
+ }
449
+ // Dispatch the frontier's own Chair objects for the chosen roles (dedup by
450
+ // role) — a selector echoing copies can't smuggle in a mutated chair.
451
+ const chosenRoles = new Set(chosen.map((c) => c.role));
452
+ ready = ready.filter((c) => chosenRoles.has(c.role));
453
+ }
454
+ // Per-chair work happens in two stages so non-invocation failures
455
+ // (BudgetExhausted, contract violations, programming-level errors like
456
+ // TypeError from a circular gig_input) propagate UNWRAPPED through the
457
+ // synchronous pre-stage; only the actual invoker rejection is caught
458
+ // and aggregated into a phase-level "chair(s) failed" RuntimeError that
459
+ // names every failing chair.
460
+ const prepared = ready.map((chair) => prepareChair(chair, phase.name));
461
+ const settled = await Promise.allSettled(prepared.map((p) => invokeAndWriteChair(p)));
462
+ const failures = [];
463
+ const failureErrors = [];
464
+ for (let i = 0; i < settled.length; i++) {
465
+ const r = settled[i];
466
+ const ch = ready[i];
467
+ if (r.status === "rejected") {
468
+ failures.push(ch.role);
469
+ const reason = r.reason instanceof Error ? r.reason.message : String(r.reason);
470
+ failureErrors.push(`${ch.role}: ${reason}`);
471
+ emit({ type: "chair_failed", phase: phase.name, role: ch.role, error: reason });
472
+ }
473
+ else {
474
+ producedByRole.set(ch.role, r.value);
475
+ produced.push(...r.value);
476
+ }
477
+ }
478
+ // #233 — BATCH BOUNDARY is the only point at which real settled dollars can be
479
+ // reconciled into the budget: prepareChair ran for every chair in this batch before any
480
+ // of them was invoked, so no chair could have seen its siblings' cost. Reporting only.
481
+ if (budget)
482
+ budget.settled_usd = usage.total_cost_usd;
483
+ if (failures.length > 0) {
484
+ // A cancellation that reached the chair's child (level 3) surfaces here as a rejected
485
+ // chair. Report it as the cancellation it is — not as "chair(s) failed", which is the
486
+ // exact confusion #251 flags: a killed gig indistinguishable from a genuine crash.
487
+ checkpoint();
488
+ throw new RuntimeError(`phase "${phase.name}" aborted — chair(s) failed: ${failures.join(", ")} (${failureErrors.join(" | ")})`);
489
+ }
490
+ // Drop completed chairs from remaining so the next iteration picks up
491
+ // chairs unblocked by this batch.
492
+ for (const ch of ready)
493
+ remaining.delete(ch.role);
494
+ }
495
+ }
496
+ // Resolve a chair's declared output types into seal-specs (type → core → primitive).
497
+ function outputSpecsFor(domainTypes, fallbackPrimitive) {
498
+ return domainTypes.map((dt) => {
499
+ const core = deps.outputs.coreTypeOf(dt) ?? PRIMITIVE_OUTPUT_TYPE[fallbackPrimitive];
500
+ const primitive = (CORE_TO_PRIMITIVE[core] ?? fallbackPrimitive);
501
+ return { domain_type: dt, core_type: core, primitive };
502
+ });
503
+ }
504
+ function prepareChair(chair, phaseName) {
505
+ // A skill-backed chair runs the skill's deterministic code half — no agent, no model.
506
+ if (chair.skill_slug && (chair.agent_slug ?? "") === "") {
507
+ const dir = deps.skill_dirs?.get(chair.skill_slug);
508
+ if (!dir)
509
+ throw new RuntimeError(`phase "${phaseName}" chair "${chair.role}" is skill-backed ("${chair.skill_slug}") but no skill_dir is registered`);
510
+ const domain_type = chair.output_contract[0] ?? "Signal";
511
+ // Resolve the core via the registry the same way an agent chair does (outputSpecsFor):
512
+ // output_contract[0] may be a DOMAIN type (e.g. triage-verdict → Verdict), not a bare core.
513
+ const core = deps.outputs.coreTypeOf(domain_type) ?? "Signal";
514
+ const primitive = CORE_TO_PRIMITIVE[core] ?? "SENSE";
515
+ const inputs = [];
516
+ for (const dep of chair.depends_on) {
517
+ const recs = producedByRole.get(dep);
518
+ if (!recs?.length)
519
+ throw new RuntimeError(`chair "${chair.role}" depends_on "${dep}" which has not been produced`);
520
+ inputs.push(...recs);
521
+ }
522
+ if (chair.input_contract.length > 0) {
523
+ for (const need of chair.input_contract) {
524
+ // #156: a type satisfied by an upstream record OR by the gig payload (entry-chair seed).
525
+ const fromGig = standardInputs.has(need) && gigInput[need] !== undefined;
526
+ if (!fromGig && !inputs.some((o) => outputSatisfiesType(o, need))) {
527
+ const provided = inputs.map((o) => o.domain_type).join(",");
528
+ // #244 — this disjunction knows WHICH branch failed; don't discard that.
529
+ throw standardInputs.has(need)
530
+ ? missingGigInput(need, chair.role, provided)
531
+ : new RuntimeError(`chair "${chair.role}" input_contract requires "${need}" but upstream outputs only provide [${provided}]`);
532
+ }
533
+ }
534
+ }
535
+ // A skill-backed chair seals exactly one output (its deterministic code returns one blob).
536
+ const output_specs = [{ domain_type, core_type: core, primitive }];
537
+ return { chair, phaseName, skill_dir: dir, primitive, domain_type, output_specs, inputs, skills: [], missing_skills: [] };
538
+ }
539
+ const agent = standard.agents.find((a) => a.slug === chair.agent_slug);
540
+ if (!agent)
541
+ throw new RuntimeError(`phase "${phaseName}" chair "${chair.role}" references unknown agent "${chair.agent_slug}"`);
542
+ const primitive = agent.primitives[0];
543
+ if (!primitive)
544
+ throw new RuntimeError(`agent "${agent.slug}" declares no primitive`);
545
+ const domain_type = agent.output_types[0];
546
+ if (!domain_type)
547
+ throw new RuntimeError(`agent "${agent.slug}" declares no output_type`);
548
+ // Gather upstream inputs. When the chair declares depends_on, use the
549
+ // OutputRecords of those specific roles. When it doesn't (legacy / no-deps
550
+ // chair), fall back to the legacy behavior — all prior outputs whose
551
+ // domain_type this agent's input_types declares it consumes. This keeps
552
+ // runtime.test.ts (which uses legacy `{name, agent}` phases) working.
553
+ let inputs;
554
+ if (chair.depends_on.length > 0) {
555
+ inputs = [];
556
+ for (const dep of chair.depends_on) {
557
+ const recs = producedByRole.get(dep);
558
+ if (!recs?.length) {
559
+ throw new RuntimeError(`chair "${chair.role}" depends_on "${dep}" which has not been produced`);
560
+ }
561
+ inputs.push(...recs);
562
+ }
563
+ }
564
+ else {
565
+ inputs = produced.filter((o) => agent.input_types.some((t) => outputSatisfiesType(o, t)));
566
+ }
567
+ // Runtime input_contract check: every type the chair declares it expects
568
+ // on input must be satisfied by its actual upstream inputs. Subtype-aware
569
+ // (docs/genome-extension.md): a core-type requirement is met by any domain
570
+ // subtype extending it; a domain-type requirement stays exact. Empty skips.
571
+ if (chair.input_contract.length > 0) {
572
+ for (const need of chair.input_contract) {
573
+ // #156: satisfied by an upstream record OR the gig payload (entry-chair typed seed).
574
+ const fromGig = standardInputs.has(need) && gigInput[need] !== undefined;
575
+ if (!fromGig && !inputs.some((o) => outputSatisfiesType(o, need))) {
576
+ const provided = inputs.map((o) => o.domain_type).join(",");
577
+ // #244 — when `need` is a DECLARED gig input, the cause is a missing key in the
578
+ // caller's payload, not a mis-wired pipeline. Blaming "upstream outputs" sent the
579
+ // operator to inspect files that are perfectly correct.
580
+ throw standardInputs.has(need)
581
+ ? missingGigInput(need, chair.role, provided)
582
+ : new RuntimeError(`chair "${chair.role}" input_contract requires "${need}" but upstream outputs only provide [${provided}]`);
583
+ }
584
+ }
585
+ }
586
+ else if (agent.input_types.length > 0) {
587
+ // #245 — an EMPTY input_contract used to skip every input check, so a chair bound to an
588
+ // agent that declares it consumes typed inputs could be invoked with `inputs: []`. The
589
+ // agent, given nothing, invents an answer; the answer then seals with full provenance,
590
+ // real predecessor links and `status: "complete"`. Composition cannot catch this: its
591
+ // upstream-producer check gates on `i > 0`, and a hand-rolled Standard bypasses it
592
+ // entirely — so the runtime is the last line of defence.
593
+ //
594
+ // The floor is the weakest one that still bites: AT LEAST ONE declared input_type must be
595
+ // satisfied, by an upstream record or by the typed gig payload (#156). An agent that
596
+ // consumes one of several alternatives is not forced to receive all of them, and an agent
597
+ // declaring no input_types is untouched.
598
+ const satisfied = agent.input_types.some((t) => (standardInputs.has(t) && gigInput[t] !== undefined) || inputs.some((o) => outputSatisfiesType(o, t)));
599
+ // The ENTRY-CHAIR exemption, and its cost. A first-phase chair with no depends_on reads
600
+ // from the gig payload, and a v0 standard may seed it UNTYPED — `patent-triage-v0`'s
601
+ // `cleave` chair binds an agent declaring `invention-spec` and is fed
602
+ // `{description: "…"}`. The runtime cannot tell that legitimate seed apart from a
603
+ // mis-wired entry chair: both are "declared type, nothing upstream, some payload". So the
604
+ // floor only fires for an entry chair when the payload is EMPTY — the one case where
605
+ // nothing could have supplied the declared type by any route. Closing the rest is a
606
+ // DEFINITION fix, not a runtime one: the standard must declare `input_types` so the seed
607
+ // is typed (#156's mechanism, which patent-triage-v0 predates). Recorded, not guessed.
608
+ // NOTE (integration): #245 reads a gig-level `firstPhase` binding that #244 removed
609
+ // along with the phase-0-only pre-flight it served. Neither lane is broken alone and
610
+ // git merges both without a conflict, so only tsc catches it. Re-derived at the use site.
611
+ const entryChair = phaseName === standard.phases[0]?.name && chair.depends_on.length === 0;
612
+ const seeded = entryChair && Object.keys(gigInput).length > 0;
613
+ if (!satisfied && !seeded) {
614
+ const provided = inputs.map((o) => o.domain_type).join(",");
615
+ throw new RuntimeError(`chair "${chair.role}" agent "${agent.slug}" declares input_types [${agent.input_types.join(",")}] but received none of them — upstream provided [${provided}] and the gig payload supplies no matching type; refusing to invoke on an empty frontier`);
616
+ }
617
+ }
618
+ // Resolve this agent's skill bindings (slugs) against the genome's skills map.
619
+ // resolveSkills REPORTS; this is where the engine DECIDES — and it decides BEFORE the
620
+ // budget deduction below, so a dangling binding costs nothing.
621
+ const { skills, missing } = resolveSkills(agent.skill_slugs, deps.skills);
622
+ if (missing.length > 0) {
623
+ // #242 — `Chair.required_skills` was validated exactly once, at compose time, as a
624
+ // string-subset check against the agent's own declaration. A chair could declare a
625
+ // skill REQUIRED, pass composition because the agent lists the same string, and then
626
+ // run unskilled while sealing normally: the standard's strongest available assertion
627
+ // about a chair's competence was enforced by nobody. There is no graceful-degradation
628
+ // tension here — "required" means required.
629
+ const requiredMissing = missing.filter((s) => (chair.required_skills ?? []).includes(s));
630
+ if (requiredMissing.length > 0) {
631
+ throw new RuntimeError(`phase "${phaseName}" chair "${chair.role}" requires skill(s) [${requiredMissing.join(", ")}] which resolve to no skill package — ` +
632
+ `agent "${agent.slug}" declares the slug but nothing supplies it (a required skill with no package is a dead name; ` +
633
+ `define the skill package or drop it from the chair's required_skills)`);
634
+ }
635
+ // #241 — not required, so the gig lives. But it is never silent again: an unskilled run
636
+ // used to be indistinguishable from a skilled one in the artifact, the ledger AND the diff.
637
+ emit({ type: "skills_unresolved", phase: phaseName, role: chair.role, agent: agent.slug, missing: [...missing] });
638
+ }
639
+ // BUDGET GATE — pre-invocation, and a RESERVATION only (#232). Synchronous so
640
+ // BudgetExhausted (and a TypeError thrown from JSON.stringify on a circular gig_input)
641
+ // propagate unwrapped to the caller rather than being aggregated as a chair failure.
642
+ //
643
+ // The gate compares against `balance - reserved` so a batch of parallel chairs cannot each
644
+ // spend the same balance; the hold converts to `spent` only in settleChairCost, after the
645
+ // invoker actually returns. Before this, `spent += cost` happened HERE — so when a later
646
+ // member of an eagerly-prepared batch tripped the gate, every earlier member was already
647
+ // charged and `invokeAndWriteChair` then ran for nobody. The operator saw spend for work
648
+ // that never started, and that inflated figure is what BudgetExhausted.state reported.
649
+ let reservedCost;
650
+ if (budget) {
651
+ const cost = computeAppendCost({ agent, phase: phaseName, inputs, gig_input: gigInput }, budget.base_cost, budget.k);
652
+ const available = budget.balance - reserved;
653
+ if (available < cost) {
654
+ budget.agent_state = "depleted";
655
+ budget.depleted_agent = agent.slug;
656
+ budget.depleted_at = new Date().toISOString();
657
+ throw new BudgetExhausted(agent.slug, available, cost, budget);
658
+ }
659
+ reserved += cost;
660
+ reservedCost = cost;
661
+ }
662
+ // Seal one record per type THIS CHAIR promises (#174): the output_contract is the SELECTOR,
663
+ // not just a check — a chair bound to a multi-output agent seals only the subset it declares,
664
+ // intersected with the agent's real outputs (so a stray contract entry can't conjure a type
665
+ // the agent doesn't produce; the post-invocation check below still reports that mismatch).
666
+ // Empty contract (legacy hand-rolled chair) → fall back to the agent's full output set.
667
+ const wanted = chair.output_contract.length
668
+ ? agent.output_types.filter((t) => chair.output_contract.includes(t))
669
+ : agent.output_types;
670
+ const output_specs = outputSpecsFor(wanted, primitive);
671
+ return { chair, phaseName, agent, primitive, domain_type, output_specs, inputs, skills, missing_skills: missing, ...(reservedCost !== undefined ? { cost: reservedCost } : {}) };
672
+ }
673
+ // #232 — convert a chair's reservation into settled spend, or release it. `spent` moves ONLY
674
+ // for a chair whose invocation actually returned, which is what the budget contract always
675
+ // claimed. A chair that was prepared and then never invoked (its batch sibling tripped the
676
+ // gate) never reaches here at all — so it is never charged, which is the point.
677
+ function settleChairCost(p, succeeded) {
678
+ if (!budget || p.cost === undefined)
679
+ return;
680
+ reserved -= p.cost;
681
+ if (!succeeded)
682
+ return;
683
+ budget.spent += p.cost;
684
+ budget.balance = budget.opening - budget.spent + budget.credit;
685
+ }
686
+ // Stage 2 — actual invocation + post-invocation output_contract check + write.
687
+ // Errors here ARE aggregated by Promise.allSettled and surfaced as a phase-
688
+ // level RuntimeError naming every failing chair role.
689
+ //
690
+ // The thin wrapper is where a chair's budget RESERVATION settles (#232): a hold becomes
691
+ // `spent` on success and is released on failure. Both paths must run, so the accounting
692
+ // cannot drift no matter how the chair ends.
693
+ async function invokeAndWriteChair(p) {
694
+ try {
695
+ const written = await executeChair(p);
696
+ settleChairCost(p, true);
697
+ return written;
698
+ }
699
+ catch (e) {
700
+ settleChairCost(p, false);
701
+ throw e;
702
+ }
703
+ }
704
+ async function executeChair(p) {
705
+ const { chair, phaseName, inputs, skills, output_specs } = p;
706
+ const t0 = Date.now();
707
+ const producerHint = chair.skill_slug || p.agent?.slug || chair.agent_slug || chair.role;
708
+ emit({ type: "chair_start", phase: phaseName, role: chair.role, producer: producerHint });
709
+ let data;
710
+ let producer_slug;
711
+ let domain;
712
+ // Skill-backed chairs record which skill (version + verified code_hash + tier) sealed the
713
+ // output, so the ledger entry traces back to the exact SkillChainEvent. Undefined for agents.
714
+ let skill_provenance;
715
+ if (p.skill_dir) {
716
+ // SKILL-BACKED chair: run the deterministic code half in the permission cage — the
717
+ // model is never invoked. The skill reads the merged upstream data (or the gig input
718
+ // when it's a root chair). This is the proper fix for "an LLM should not babysit a
719
+ // deterministic command": the command IS the chair.
720
+ const skillInput = inputs.length > 0 ? Object.assign({}, ...inputs.map((i) => i.data)) : gigInput;
721
+ const r = executeSkill(p.skill_dir, skillInput);
722
+ if (!r.ok)
723
+ throw new RuntimeError(`skill chair "${chair.role}" ("${chair.skill_slug}") failed: ${r.error}`);
724
+ data = (r.output && typeof r.output === "object" ? r.output : {});
725
+ producer_slug = chair.skill_slug;
726
+ domain = standard.domain;
727
+ const pkg = loadSkillPackage(p.skill_dir);
728
+ skill_provenance = {
729
+ slug: pkg.meta.slug,
730
+ version: pkg.meta.version,
731
+ code_hash: pkg.codeHash ?? "",
732
+ tier: pkg.meta.permission?.tier ?? 0,
733
+ };
734
+ }
735
+ else {
736
+ const agent = p.agent;
737
+ // #235 — count the invocation BEFORE it runs. An invocation that dies without emitting a
738
+ // usable `result` (the 10-minute SIGKILL bound) still happened and still cost money; the
739
+ // honest record is "started, unattributed", not silence. The counter sits OUTSIDE the
740
+ // try for the same reason: a chair killed by #250's abort still started, and still cost.
741
+ const sink = makeUsageSink();
742
+ startedInvocations++;
743
+ try {
744
+ data = await deps.invoke({
745
+ agent, phase: phaseName, inputs, gig_input: gigInput, skills,
746
+ missing_skills: p.missing_skills, // #241 — what did NOT resolve, so the prompt can't assert it
747
+ output_types: output_specs.map((s) => s.domain_type), // #174 — the chair's promised subset
748
+ // #250 level 2 + #237 — the cancellation signal and the run's depth reach the invocation
749
+ // itself, so an invoker can kill its child and shape what it asks the model for.
750
+ ...(deps.signal ? { signal: deps.signal } : {}),
751
+ ...(deps.depth ? { depth: deps.depth } : {}),
752
+ onEvent: (ev) => { sink.fold(ev); emit({ type: "agent_event", phase: phaseName, role: chair.role, event: ev }); },
753
+ });
754
+ }
755
+ finally {
756
+ if (sink.attributed())
757
+ attributedInvocations++;
758
+ }
759
+ // Runtime output_contract check: every type the chair promised must be covered by the
760
+ // bound agent's declared output_types (compose-time mirror; a hand-rolled literal could
761
+ // still ship a mismatch).
762
+ if (chair.output_contract.length > 0) {
763
+ const producedTypes = new Set(agent.output_types);
764
+ for (const promised of chair.output_contract) {
765
+ if (!producedTypes.has(promised)) {
766
+ throw new RuntimeError(`chair "${chair.role}" output_contract promises "${promised}" but agent "${agent.slug}" produced types [${agent.output_types.join(",")}]`);
767
+ }
768
+ }
769
+ }
770
+ producer_slug = agent.slug;
771
+ domain = agent.domain ?? standard.domain;
772
+ }
773
+ // Seal one record per type this chair seals. The invoker blob may be keyed by domain_type
774
+ // (a SENSE+JUDGE agent returns { hit: {...}, verdict: {...} } → a Signal AND a Judgment from
775
+ // one pass) OR, for a lone unkeyed output, BE the data directly. So: prefer a key matching
776
+ // the type; for a single sealed type, fall back to the whole blob when no such key exists.
777
+ // This keeps narrowing correct even when an over-eager invoker returns extra keys (#174) —
778
+ // the chair seals only its promised types and reads each from its own key.
779
+ // A keyed type may be CONDITIONAL (e.g. a verdict's provisional-draft only on FILEABLE), so a
780
+ // missing key is skipped HERE and adjudicated below: #243 made the output_contract a floor,
781
+ // so an absent type is an error unless the chair declares it in `optional_outputs`. (This
782
+ // comment used to end "the downstream input_contract check fails loudly if a consumer
783
+ // actually needed it" — that reasoning is what #243 reversed, because it holds only where a
784
+ // consumer exists, and a terminal chair has none.) Tag each with `from_role`.
785
+ // #196 — fill placeholder provenance hashes with the REAL content_sha. Agents are asked to emit
786
+ // *_sha fields but have no hashing tool, so they fabricate sentinels (sha256:PLACEHOLDER-…); the
787
+ // sealed record then carries fake hashes and the "byte-reproducible survival chain" is hollow.
788
+ // The engine knows the truth: each consumed input's content_sha + the gig input's hash. Resolve a
789
+ // placeholder `<x>_sha` field to the input whose domain_type shares a name token, or the gig input
790
+ // for a disclosure/input field. Unresolved (e.g. a round-1 predecessor) → "" (honest: no predecessor).
791
+ // A real content hash is 64 hex (optionally `sha256:`-prefixed). A `*_sha` field holding
792
+ // anything else is a fabrication — the model can't hash its inputs, so it emits SOME sentinel,
793
+ // and the exact wording varies run to run ("sha256:PLACEHOLDER-…", "UNSEALED:no-hash-tool-…").
794
+ // Trigger on "not a real hash" rather than matching a known sentinel, so the backfill is robust
795
+ // to whatever the model invents (a hardcoded-sentinel match silently no-ops on new wording).
796
+ //
797
+ // #240 — the resolution rule used to be "shares ANY name token, first hit wins", iterated in
798
+ // `depends_on` order. With inputs `grant-draft` and `draft-review`, the field `draft_sha`
799
+ // matched whichever the chair happened to name first, so a COSMETIC REORDER of a JSON array
800
+ // silently rewrote the audit trail. The mis-attributed value is a real 64-hex content_sha of
801
+ // a real output in the same gig: it passes REAL_SHA, passes schema validation, and looks
802
+ // authentic to output_trace. There is no signal of any kind. That is worse than the
803
+ // admitted fabrication it replaced — a visibly fake `sha256:PLACEHOLDER-…` at least says
804
+ // "unknown"; this says "known" and is wrong. In a system whose value IS a byte-reproducible
805
+ // provenance chain, the engine must not guess. It now resolves only what it can PROVE:
806
+ // an exact type-slug match, or a single token-overlap candidate. Anything else aborts.
807
+ const REAL_SHA = /^(sha256:)?[0-9a-f]{64}$/i;
808
+ const norm = (s) => s.replace(/[_-]+/g, "-").toLowerCase();
809
+ // A type consumed twice with DIFFERENT content is ambiguous for the same reason a token
810
+ // collision is — the old dedup silently kept the first, which is another first-hit-wins guess.
811
+ const shasByType = new Map();
812
+ for (const inp of inputs) {
813
+ const set = shasByType.get(inp.domain_type) ?? new Set();
814
+ set.add(inp.content_sha);
815
+ shasByType.set(inp.domain_type, set);
816
+ }
817
+ // Hash the gig input lazily — only when a placeholder actually resolves to it (most outputs have
818
+ // no *_sha fields, and a hostile/circular gig input shouldn't be canonicalized unless needed).
819
+ let gigInputShaCache;
820
+ const gigInputSha = () => (gigInputShaCache ??= sha256Hex(canonJson(gigInput)));
821
+ const resolveSha = (field) => {
822
+ const bare = field.replace(/_sha$/i, "");
823
+ const only = (type) => {
824
+ const set = shasByType.get(type);
825
+ // same type consumed twice with different bytes — which predecessor is meant is unknowable
826
+ if (set.size > 1)
827
+ return { ambiguous: [`${type} (×${set.size} distinct)`] };
828
+ return { sha: [...set][0] };
829
+ };
830
+ // 1. EXACT type-slug match — `grant_draft_sha` ↔ `grant-draft`. Unambiguous by construction,
831
+ // and it beats a partial token collision (`draft-review` also contains `draft`).
832
+ for (const type of shasByType.keys())
833
+ if (norm(type) === norm(bare))
834
+ return only(type);
835
+ // 2. Token overlap — accepted ONLY when exactly one consumed type matches.
836
+ const tokens = bare.split(/[_-]/).filter(Boolean).map((t) => t.toLowerCase());
837
+ const candidates = [];
838
+ for (const type of shasByType.keys()) {
839
+ const ttok = type.split(/[-_]/).map((t) => t.toLowerCase());
840
+ if (tokens.some((t) => ttok.includes(t)))
841
+ candidates.push(type);
842
+ }
843
+ if (candidates.length === 1)
844
+ return only(candidates[0]);
845
+ // Sorted, not depends_on-ordered: the whole point of #240 is that nothing an operator
846
+ // sees may depend on the order of a JSON array — including the diagnostic.
847
+ if (candidates.length > 1)
848
+ return { ambiguous: candidates.sort() };
849
+ // 3. The gig payload — a disclosure/input field refers to the seed, not a predecessor.
850
+ if (tokens.includes("disclosure") || tokens.includes("input"))
851
+ return { sha: gigInputSha() };
852
+ return undefined;
853
+ };
854
+ // The unresolved case (`?? ""`) is the same defect's benign twin: honest about having no
855
+ // predecessor, but silent about it. It stays "" (a non-hash, so nothing downstream mistakes
856
+ // it for provenance) and is now REPORTED on chair_complete instead of vanishing.
857
+ const unresolvedShaFields = [];
858
+ const backfillShas = (obj) => {
859
+ for (const [k, v] of Object.entries(obj)) {
860
+ if (!/_sha$/i.test(k) || typeof v !== "string" || REAL_SHA.test(v))
861
+ continue;
862
+ const r = resolveSha(k);
863
+ if (r && "ambiguous" in r) {
864
+ throw new RuntimeError(`chair "${chair.role}" cannot stamp provenance field "${k}": it is ambiguous across the inputs this chair consumed [${r.ambiguous.join(", ")}]. The engine refuses to guess which predecessor it refers to — a wrong content_sha is indistinguishable from a right one. Name the field after exactly one consumed type (e.g. "${r.ambiguous[0].split(" ")[0].replace(/-/g, "_")}_sha").`);
865
+ }
866
+ obj[k] = r ? r.sha : "";
867
+ if (!r)
868
+ unresolvedShaFields.push(k);
869
+ }
870
+ };
871
+ const single = output_specs.length === 1;
872
+ const promised = output_specs.map((s) => s.domain_type);
873
+ // #243 — DECIDE BEFORE SEALING. Every check that can throw now runs against resolved
874
+ // slices while nothing has been written yet.
875
+ //
876
+ // The floor check used to sit AFTER the write loop, which created a failure class that did
877
+ // not previously exist: a chair delivering part of its contract sealed those records,
878
+ // append-flushed them to `outputs/<gig_id>.jsonl`, and only THEN threw — so the gig failed
879
+ // with no ledger row while its partial outputs persisted. `output_query`, `output_trace`
880
+ // and `system_health.outputs` all surface those orphans, so the two audit surfaces
881
+ // disagree by construction and any completeness signal computed over them is describing a
882
+ // state that cannot be reconciled. Deciding first makes a chair all-or-nothing.
883
+ const resolved = [];
884
+ for (const spec of output_specs) {
885
+ const keyed = data[spec.domain_type];
886
+ const slice = keyed !== undefined && keyed !== null ? keyed : single ? data : undefined;
887
+ if (slice === undefined || slice === null)
888
+ continue;
889
+ if (typeof slice !== "object" || slice === null) {
890
+ throw new RuntimeError(`chair "${chair.role}" output "${spec.domain_type}" must be a JSON object, got ${typeof slice}`);
891
+ }
892
+ resolved.push({ spec, slice: slice });
893
+ }
894
+ // backfillShas refuses an ambiguous provenance field. Run it over EVERY slice up front so
895
+ // that throw also lands before the first write, rather than midway through them.
896
+ for (const { slice } of resolved)
897
+ backfillShas(slice);
898
+ // The output_contract is a FLOOR, not merely a selector. `written.length === 0` alone let a
899
+ // chair that promised two types and sealed one complete silently. The old in-code
900
+ // justification — a keyed type may be conditional, and a downstream input_contract check
901
+ // fails loudly if a consumer actually needed it — holds only WHERE A CONSUMER EXISTS. For a
902
+ // TERMINAL chair (the gate phase, the one that emits the verdict) nothing consumes it, so
903
+ // the promise evaporated into `status: "complete"`. The one chair whose output an operator
904
+ // acts on was the one with no backstop.
905
+ //
906
+ // Conditional outputs are still legal — they now have to SAY SO, via `optional_outputs`
907
+ // (deny-by-default: promised means required unless declared). An undeclared conditional
908
+ // output is indistinguishable from a chair that simply failed to deliver.
909
+ const present = new Set(resolved.map((r) => r.spec.domain_type));
910
+ const missing = promised.filter((t) => !present.has(t));
911
+ const optional = new Set(chair.optional_outputs ?? []);
912
+ const missingRequired = missing.filter((t) => !optional.has(t));
913
+ // Producing NOTHING is a different failure from dropping one promised type — "the invoker
914
+ // returned junk" vs "one type is absent" — and collapsing them loses that distinction. But
915
+ // it is only a FAILURE when something was actually required. A chair whose every promised
916
+ // type is declared optional is entitled to seal nothing, and the old `written.length === 0`
917
+ // guard fired ABOVE the floor check, which made that unexpressable. Not hypothetical:
918
+ // patent-triage-v1's `draft` chair promises exactly one type and its intent reads "Only on
919
+ // a FILEABLE verdict … On any other verdict, nothing." Single-output and conditional, so
920
+ // `optional_outputs` was a no-op there until this ordering changed.
921
+ if (resolved.length === 0 && missingRequired.length > 0) {
922
+ throw new RuntimeError(`chair "${chair.role}" produced no recognized output — expected one of [${promised.join(", ")}]`);
923
+ }
924
+ if (missingRequired.length > 0) {
925
+ throw new RuntimeError(`chair "${chair.role}" did not deliver its output_contract — missing [${missingRequired.join(", ")}] ` +
926
+ `of promised [${promised.join(", ")}]. ` +
927
+ `If a type is legitimately conditional, declare it in the chair's optional_outputs.`);
928
+ }
929
+ // Contract satisfied. Only now does anything become durable.
930
+ const written = [];
931
+ for (const { spec, slice } of resolved) {
932
+ const rec = deps.outputs.write({
933
+ core_type: spec.core_type,
934
+ domain_type: spec.domain_type,
935
+ domain,
936
+ gig_id,
937
+ agent_slug: producer_slug,
938
+ from_role: chair.role,
939
+ phase: phaseName,
940
+ primitive: spec.primitive,
941
+ data: slice,
942
+ input_refs: inputs.map((i) => i.id),
943
+ input_shas: inputs.map((i) => i.content_sha), // #196 — real predecessor hashes, engine-stamped
944
+ skill_provenance,
945
+ });
946
+ for (const i of inputs)
947
+ deps.outputs.addRef(rec.id, i.id, "derived_from", spec.primitive);
948
+ written.push(rec);
949
+ }
950
+ // A DECLARED-optional absence is still a fact about this run. Legitimising a shortfall is
951
+ // not the same as hiding it, so it keeps its row in the manifest.
952
+ if (missing.length > 0)
953
+ unfulfilledOutputs.push({ role: chair.role, phase: phaseName, missing });
954
+ emit({
955
+ type: "chair_complete", phase: phaseName, role: chair.role, producer: producer_slug,
956
+ output_types: written.map((w) => w.domain_type), duration_ms: Date.now() - t0,
957
+ promised_output_types: output_specs.map((s) => s.domain_type),
958
+ missing_output_types: missing,
959
+ ...(unresolvedShaFields.length > 0 ? { unresolved_sha_fields: unresolvedShaFields } : {}),
960
+ });
961
+ return written;
962
+ }
963
+ const genome_hash = genomeHash(standard);
964
+ // Content-address each output (not its random UUID) so the fingerprint is
965
+ // reproducible: an honest replay of the same outputs recomputes the same
966
+ // hashes, while changed content shifts them. See outputContentHash.
967
+ const output_hashes = produced.map((p) => outputContentHash(p));
968
+ // 5th-class evals: when the standard declares eval_slugs, run each against
969
+ // the produced outputs and collect the scores. A score of 1.0 means the
970
+ // eval's contract holds; 0.0 means it doesn't. v0 wire is intentionally
971
+ // narrow — score is keyed presence; richer eval engines can subclass.
972
+ //
973
+ // #246 — nothing validated eval_slugs against the loaded evals map, and scoreEval returned
974
+ // 0.0 both for "this eval ran and its contract did not hold" and for "no eval by that name
975
+ // exists". The two were byte-identical, INCLUDING inside run_fingerprint — so a typo'd slug
976
+ // was baked into the reproducibility key as though a real contract had been evaluated and
977
+ // found wanting. Same defect family as a dangling skill ref: a broken reference silently
978
+ // degrading into a plausible-looking value instead of a named one. The score stays 0.0
979
+ // (callers key off presence, and "can't attest a contract that isn't defined" is fair), but
980
+ // the run now says WHICH slugs were never resolvable, and the fingerprint carries that
981
+ // separately so the two cases cannot collide.
982
+ const eval_scores = {};
983
+ const unresolved_evals = [];
984
+ for (const slug of standard.eval_slugs ?? []) {
985
+ if (!deps.evals?.has(slug))
986
+ unresolved_evals.push(slug);
987
+ eval_scores[slug] = scoreEval(slug, produced, deps.evals);
988
+ }
989
+ const run_fingerprint = runFingerprint({
990
+ genome_hash,
991
+ model_version: deps.model_version ?? "unknown",
992
+ canonical_form_version: CANONICAL_FORM_VERSION,
993
+ eval_scores,
994
+ output_hashes,
995
+ ...(unresolved_evals.length > 0 ? { unresolved_evals } : {}),
996
+ });
997
+ const settledUsage = finalizeUsage();
998
+ deps.ledger.append({
999
+ kind: "gig",
1000
+ schema_version: LEDGER_SCHEMA_VERSION,
1001
+ entry_id: gig_id,
1002
+ gig_id,
1003
+ standard_slug: standard.slug,
1004
+ genome_hash,
1005
+ run_fingerprint,
1006
+ output_hashes,
1007
+ started_at,
1008
+ finished_at: new Date().toISOString(),
1009
+ // settled model spend (#195) — omitted when nothing was CAPTURED (skill-only gigs, stubbed
1010
+ // invokers, or a run whose every invocation reported no usage payload). #235: an absent
1011
+ // usage block means "not captured", never "$0.00".
1012
+ ...(settledUsage ? { usage: settledUsage } : {}),
1013
+ });
1014
+ // Cycle complete — when a budget was supplied, mark it `settled` and
1015
+ // surface the final state in the manifest. `settled` mirrors the
1016
+ // budget-state.json cycle terminal-state semantics for a closed cycle.
1017
+ if (budget) {
1018
+ budget.agent_state = "settled";
1019
+ budget.settled_usd = usage.total_cost_usd; // #233 — final reconciliation of REAL dollars
1020
+ }
1021
+ const result = { gig_id, standard_slug: standard.slug, genome_hash, run_fingerprint, outputs: produced, eval_scores, status: "complete" };
1022
+ if (settledUsage)
1023
+ result.usage = settledUsage;
1024
+ if (budget)
1025
+ result.budget_state = budget;
1026
+ if (unresolved_evals.length > 0)
1027
+ result.unresolved_evals = unresolved_evals;
1028
+ if (unfulfilledOutputs.length > 0)
1029
+ result.unfulfilled_outputs = unfulfilledOutputs;
1030
+ emit({ type: "gig_complete", outputs: produced.length });
1031
+ return result;
1032
+ }
1033
+ catch (e) {
1034
+ // #236 — real dollars were spent, captured, and then thrown away on every failed gig.
1035
+ // `usage` was written only on the success path, and the async dispatcher's `.catch` set
1036
+ // status/finished_at/error and never state.usage — so a gig that burned $6 across four
1037
+ // chairs and died on the fifth reported zero dollars, everywhere, while the OUTPUTS from
1038
+ // the completed chairs persisted. The artifact survived; the record of what it cost did
1039
+ // not. Attaching the partial accounting to the error is what lets gig_monitor and a
1040
+ // synchronous caller report it. This does NOT write a ledger row — absence-of-row remains
1041
+ // the honest "un-sealed gig" signal (recorder_durability_mid_crash.spec.ts).
1042
+ if (e && typeof e === "object") {
1043
+ const partial = finalizeUsage();
1044
+ if (partial)
1045
+ e["usage"] = partial;
1046
+ if (budget)
1047
+ e["budget_state"] = budget;
1048
+ }
1049
+ throw e;
1050
+ }
1051
+ }
1052
+ // v0 eval-scorer: a minimal scan over the produced outputs. The named eval is
1053
+ // looked up by slug (no shared genome handle in the runtime today), so we use
1054
+ // a deterministic per-slug shape:
1055
+ // * default: 1.0 if any output exists, else 0.0
1056
+ // Future builders should grow this into a real eval engine that reads the eval
1057
+ // file's `asserts`/`on_type`/scoring function and applies it.
1058
+ /**
1059
+ * Real (deterministic) eval judge. Resolves the eval by slug, then:
1060
+ * - unresolvable slug → 0.0 (can't attest a contract that isn't defined)
1061
+ * - `on_type` declared but not produced → 0.0 (the target wasn't made)
1062
+ * - `non_empty_fields` declared → 1.0 iff every named field is present + non-empty
1063
+ * in EVERY produced output of `on_type`, else 0.0
1064
+ * - no structured predicate → presence of a typed target is the (weaker) contract
1065
+ * Deterministic by design: eval_scores feed run_fingerprint, so the judge must not
1066
+ * depend on the model (an LLM-as-judge would make replay non-reproducible).
1067
+ */
1068
+ function scoreEval(slug, produced, evals) {
1069
+ const ev = evals?.get(slug);
1070
+ if (!ev)
1071
+ return 0.0;
1072
+ const onType = typeof ev["on_type"] === "string" ? ev["on_type"] : undefined;
1073
+ // Subtype-aware (genome extension): an eval declared on a CORE type judges any domain
1074
+ // subtype filling it, same as a core-type contract — so polymorphism reaches evals too.
1075
+ const targets = onType ? produced.filter((o) => outputSatisfiesType(o, onType)) : produced;
1076
+ if (targets.length === 0)
1077
+ return 0.0;
1078
+ const fields = Array.isArray(ev["non_empty_fields"]) ? ev["non_empty_fields"] : [];
1079
+ if (fields.length > 0) {
1080
+ const allHold = targets.every((o) => {
1081
+ const data = o.data;
1082
+ return fields.every((f) => isNonEmptyValue(data?.[f]));
1083
+ });
1084
+ return allHold ? 1.0 : 0.0;
1085
+ }
1086
+ return 1.0;
1087
+ }
1088
+ function isNonEmptyValue(v) {
1089
+ if (v === undefined || v === null)
1090
+ return false;
1091
+ if (typeof v === "string")
1092
+ return v.trim().length > 0;
1093
+ if (Array.isArray(v))
1094
+ return v.length > 0;
1095
+ if (typeof v === "object")
1096
+ return Object.keys(v).length > 0;
1097
+ return true; // numbers, booleans — present counts
1098
+ }
1099
+ //# sourceMappingURL=runtime.js.map