@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.
- package/LICENSE +201 -0
- package/README.md +85 -0
- package/agents/anticipation-mapper.json +34 -0
- package/agents/claim-amender.json +34 -0
- package/agents/claim-architect.json +34 -0
- package/agents/claim-rewriter.json +32 -0
- package/agents/delivery-finalizer.json +40 -0
- package/agents/diamond-cutter.json +33 -0
- package/agents/disclosure-analyst.json +26 -0
- package/agents/domain-explorer.json +37 -0
- package/agents/e2e-runner.json +34 -0
- package/agents/e2e-suite-judge.json +26 -0
- package/agents/event-clusterer.json +29 -0
- package/agents/novelty-searcher.json +40 -0
- package/agents/patent-browser-scout.json +49 -0
- package/agents/patent-examiner.json +36 -0
- package/agents/phase_agents/claim-rewriter.md +44 -0
- package/agents/phase_agents/delivery-finalizer.md +30 -0
- package/agents/phase_agents/diamond-cutter.md +47 -0
- package/agents/phase_agents/domain-explorer.md +27 -0
- package/agents/phase_agents/novelty-searcher.md +50 -0
- package/agents/phase_agents/problem-definer.md +32 -0
- package/agents/phase_agents/solution-developer.md +26 -0
- package/agents/phase_agents/verdict-judger.md +70 -0
- package/agents/players/audience-modeler.md +40 -0
- package/agents/players/chain-audit-keeper.md +41 -0
- package/agents/players/illumination-reviewer.md +42 -0
- package/agents/players/methodology-cadence-keeper.md +42 -0
- package/agents/players/substrate-edge-keeper.md +45 -0
- package/agents/prior-art-scout.json +45 -0
- package/agents/problem-definer.json +35 -0
- package/agents/seeds/steve_seed_a.json +12 -0
- package/agents/seeds/steve_seed_b.json +12 -0
- package/agents/seeds/steve_seed_c.json +12 -0
- package/agents/seeds/steve_seed_d.json +12 -0
- package/agents/sensor.json +22 -0
- package/agents/solution-developer.json +35 -0
- package/agents/source-walker.json +38 -0
- package/agents/spec-drafter.json +31 -0
- package/agents/sub_thread_soft_judge.json +26 -0
- package/agents/sub_thread_trace_sensor.json +22 -0
- package/agents/summarizer.json +25 -0
- package/agents/synthesis-writer.json +29 -0
- package/agents/triage-judge.json +39 -0
- package/agents/user_flow_judge.json +26 -0
- package/agents/user_flow_sensor.json +22 -0
- package/agents/verdict-judger.json +39 -0
- package/core_types/artifact.json +17 -0
- package/core_types/interpretation.json +16 -0
- package/core_types/judgment.json +16 -0
- package/core_types/plan.json +16 -0
- package/core_types/signal.json +16 -0
- package/core_types/verdict.json +27 -0
- package/dist/src/access_grant.d.ts +46 -0
- package/dist/src/access_grant.js +76 -0
- package/dist/src/access_grant.js.map +1 -0
- package/dist/src/acoustics.d.ts +9 -0
- package/dist/src/acoustics.js +65 -0
- package/dist/src/acoustics.js.map +1 -0
- package/dist/src/agent_profile.d.ts +36 -0
- package/dist/src/agent_profile.js +57 -0
- package/dist/src/agent_profile.js.map +1 -0
- package/dist/src/bifrost_invoker.d.ts +33 -0
- package/dist/src/bifrost_invoker.js +85 -0
- package/dist/src/bifrost_invoker.js.map +1 -0
- package/dist/src/canonical_core_types.d.ts +2 -0
- package/dist/src/canonical_core_types.js +111 -0
- package/dist/src/canonical_core_types.js.map +1 -0
- package/dist/src/canonical_form.d.ts +55 -0
- package/dist/src/canonical_form.js +93 -0
- package/dist/src/canonical_form.js.map +1 -0
- package/dist/src/charter.d.ts +37 -0
- package/dist/src/charter.js +86 -0
- package/dist/src/charter.js.map +1 -0
- package/dist/src/circle_of_fifths.d.ts +27 -0
- package/dist/src/circle_of_fifths.js +78 -0
- package/dist/src/circle_of_fifths.js.map +1 -0
- package/dist/src/claude_invoker.d.ts +102 -0
- package/dist/src/claude_invoker.js +760 -0
- package/dist/src/claude_invoker.js.map +1 -0
- package/dist/src/coltrane_profile.d.ts +87 -0
- package/dist/src/coltrane_profile.js +132 -0
- package/dist/src/coltrane_profile.js.map +1 -0
- package/dist/src/composition.d.ts +57 -0
- package/dist/src/composition.js +447 -0
- package/dist/src/composition.js.map +1 -0
- package/dist/src/core_types.d.ts +6 -0
- package/dist/src/core_types.js +25 -0
- package/dist/src/core_types.js.map +1 -0
- package/dist/src/document_factory.d.ts +89 -0
- package/dist/src/document_factory.js +176 -0
- package/dist/src/document_factory.js.map +1 -0
- package/dist/src/genome_schema.d.ts +512 -0
- package/dist/src/genome_schema.js +168 -0
- package/dist/src/genome_schema.js.map +1 -0
- package/dist/src/genome_writer.d.ts +67 -0
- package/dist/src/genome_writer.js +179 -0
- package/dist/src/genome_writer.js.map +1 -0
- package/dist/src/gig_song.d.ts +29 -0
- package/dist/src/gig_song.js +56 -0
- package/dist/src/gig_song.js.map +1 -0
- package/dist/src/gig_tracker.d.ts +54 -0
- package/dist/src/gig_tracker.js +109 -0
- package/dist/src/gig_tracker.js.map +1 -0
- package/dist/src/harmonic_validation.d.ts +11 -0
- package/dist/src/harmonic_validation.js +35 -0
- package/dist/src/harmonic_validation.js.map +1 -0
- package/dist/src/hooks.d.ts +27 -0
- package/dist/src/hooks.js +2 -0
- package/dist/src/hooks.js.map +1 -0
- package/dist/src/index.d.ts +36 -0
- package/dist/src/index.js +37 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/jsong.d.ts +47 -0
- package/dist/src/jsong.js +130 -0
- package/dist/src/jsong.js.map +1 -0
- package/dist/src/judges/user_flow_judge.d.ts +89 -0
- package/dist/src/judges/user_flow_judge.js +200 -0
- package/dist/src/judges/user_flow_judge.js.map +1 -0
- package/dist/src/learner.d.ts +37 -0
- package/dist/src/learner.js +30 -0
- package/dist/src/learner.js.map +1 -0
- package/dist/src/ledger.d.ts +225 -0
- package/dist/src/ledger.js +306 -0
- package/dist/src/ledger.js.map +1 -0
- package/dist/src/loader.d.ts +87 -0
- package/dist/src/loader.js +504 -0
- package/dist/src/loader.js.map +1 -0
- package/dist/src/mcp.d.ts +23 -0
- package/dist/src/mcp.js +125 -0
- package/dist/src/mcp.js.map +1 -0
- package/dist/src/modulation_path.d.ts +42 -0
- package/dist/src/modulation_path.js +60 -0
- package/dist/src/modulation_path.js.map +1 -0
- package/dist/src/output_validation.d.ts +19 -0
- package/dist/src/output_validation.js +51 -0
- package/dist/src/output_validation.js.map +1 -0
- package/dist/src/outputs.d.ts +125 -0
- package/dist/src/outputs.js +452 -0
- package/dist/src/outputs.js.map +1 -0
- package/dist/src/overtones.d.ts +42 -0
- package/dist/src/overtones.js +98 -0
- package/dist/src/overtones.js.map +1 -0
- package/dist/src/player_to_claude_code.d.ts +17 -0
- package/dist/src/player_to_claude_code.js +141 -0
- package/dist/src/player_to_claude_code.js.map +1 -0
- package/dist/src/playwright_cage.d.ts +28 -0
- package/dist/src/playwright_cage.js +47 -0
- package/dist/src/playwright_cage.js.map +1 -0
- package/dist/src/polyphony.d.ts +46 -0
- package/dist/src/polyphony.js +91 -0
- package/dist/src/polyphony.js.map +1 -0
- package/dist/src/portfolio.d.ts +52 -0
- package/dist/src/portfolio.js +186 -0
- package/dist/src/portfolio.js.map +1 -0
- package/dist/src/pricing.d.ts +23 -0
- package/dist/src/pricing.js +16 -0
- package/dist/src/pricing.js.map +1 -0
- package/dist/src/registry.d.ts +46 -0
- package/dist/src/registry.js +199 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/runtime.d.ts +304 -0
- package/dist/src/runtime.js +1099 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/server.d.ts +88 -0
- package/dist/src/server.js +1692 -0
- package/dist/src/server.js.map +1 -0
- package/dist/src/server_entry.d.ts +2 -0
- package/dist/src/server_entry.js +40 -0
- package/dist/src/server_entry.js.map +1 -0
- package/dist/src/server_relay.d.ts +82 -0
- package/dist/src/server_relay.js +341 -0
- package/dist/src/server_relay.js.map +1 -0
- package/dist/src/simulate.d.ts +60 -0
- package/dist/src/simulate.js +72 -0
- package/dist/src/simulate.js.map +1 -0
- package/dist/src/skill_runner.mjs +34 -0
- package/dist/src/skill_subprocess.d.ts +65 -0
- package/dist/src/skill_subprocess.js +132 -0
- package/dist/src/skill_subprocess.js.map +1 -0
- package/dist/src/skills.d.ts +85 -0
- package/dist/src/skills.js +258 -0
- package/dist/src/skills.js.map +1 -0
- package/dist/src/subthread_recorder.d.ts +68 -0
- package/dist/src/subthread_recorder.js +166 -0
- package/dist/src/subthread_recorder.js.map +1 -0
- package/dist/src/tensor_read.d.ts +38 -0
- package/dist/src/tensor_read.js +60 -0
- package/dist/src/tensor_read.js.map +1 -0
- package/dist/src/test_honest_broker.d.ts +34 -0
- package/dist/src/test_honest_broker.js +155 -0
- package/dist/src/test_honest_broker.js.map +1 -0
- package/dist/src/test_topology.d.ts +22 -0
- package/dist/src/test_topology.js +137 -0
- package/dist/src/test_topology.js.map +1 -0
- package/dist/src/tones.d.ts +20 -0
- package/dist/src/tones.js +81 -0
- package/dist/src/tones.js.map +1 -0
- package/dist/src/tool_providers.d.ts +45 -0
- package/dist/src/tool_providers.js +104 -0
- package/dist/src/tool_providers.js.map +1 -0
- package/dist/src/type_versioning.d.ts +23 -0
- package/dist/src/type_versioning.js +31 -0
- package/dist/src/type_versioning.js.map +1 -0
- package/dist/src/version.d.ts +28 -0
- package/dist/src/version.js +86 -0
- package/dist/src/version.js.map +1 -0
- package/domain_types/claim-draft.json +34 -0
- package/domain_types/coverage-report.json +31 -0
- package/domain_types/draft-agent-profile.json +31 -0
- package/domain_types/draft-domain-type.json +28 -0
- package/domain_types/draft-standard.json +46 -0
- package/domain_types/e2e-observation.json +32 -0
- package/domain_types/e2e-report.json +30 -0
- package/domain_types/e2e-target.json +26 -0
- package/domain_types/e2e-verdict.json +26 -0
- package/domain_types/examine-round-record.json +40 -0
- package/domain_types/examiner-rejection.json +28 -0
- package/domain_types/failure-modes.json +31 -0
- package/domain_types/invention-analysis.json +34 -0
- package/domain_types/invention-spec.json +34 -0
- package/domain_types/novelty-analysis.json +28 -0
- package/domain_types/novelty-verdict.json +35 -0
- package/domain_types/parsed-conversation-trace.json +38 -0
- package/domain_types/pattern-extraction.json +62 -0
- package/domain_types/prior-art-hit.json +50 -0
- package/domain_types/project-charter.json +71 -0
- package/domain_types/provisional-draft.json +34 -0
- package/domain_types/raw-note.json +19 -0
- package/domain_types/repo-survey.json +28 -0
- package/domain_types/seeding-verdict.json +70 -0
- package/domain_types/soft-verdict.json +55 -0
- package/domain_types/summary.json +19 -0
- package/domain_types/triage-verdict.json +59 -0
- package/domain_types/user-flow-transcript.json +43 -0
- package/domain_types/user-flow-verdict.json +60 -0
- package/domain_types/verdict-record.json +39 -0
- package/package.json +82 -0
- package/skills/citation-verify/fixtures/basic.json +14 -0
- package/skills/citation-verify/meta.json +10 -0
- package/skills/citation-verify/skill.mjs +26 -0
- package/skills/claim-element-decompose/fixtures/basic.json +11 -0
- package/skills/claim-element-decompose/meta.json +10 -0
- package/skills/claim-element-decompose/skill.mjs +14 -0
- package/skills/diamond-cutting-discipline/fixtures/basic.json +13 -0
- package/skills/diamond-cutting-discipline/meta.json +19 -0
- package/skills/diamond-cutting-discipline/skill.md +46 -0
- package/skills/element-mapping-matrix/fixtures/basic.json +13 -0
- package/skills/element-mapping-matrix/meta.json +10 -0
- package/skills/element-mapping-matrix/skill.mjs +20 -0
- package/skills/number-adder/fixtures/basic.json +9 -0
- package/skills/number-adder/meta.json +10 -0
- package/skills/number-adder/skill.mjs +12 -0
- package/skills/patent-fetch/fixtures/parse.json +18 -0
- package/skills/patent-fetch/meta.json +11 -0
- package/skills/patent-fetch/skill.mjs +34 -0
- package/skills/query-expand/fixtures/basic.json +14 -0
- package/skills/query-expand/meta.json +10 -0
- package/skills/query-expand/skill.mjs +29 -0
- package/skills/run-vitest-band/fixtures/basic.json +9 -0
- package/skills/run-vitest-band/meta.json +18 -0
- package/skills/run-vitest-band/skill.mjs +22 -0
- package/skills/statutory-checklist/fixtures/basic.json +16 -0
- package/skills/statutory-checklist/meta.json +10 -0
- package/skills/statutory-checklist/skill.mjs +25 -0
- package/skills/summarize-tight/fixtures/basic.json +13 -0
- package/skills/summarize-tight/meta.json +20 -0
- package/skills/summarize-tight/skill.md +1 -0
- package/skills/verdict-gate/fixtures/basic.json +9 -0
- package/skills/verdict-gate/meta.json +10 -0
- package/skills/verdict-gate/skill.mjs +82 -0
- package/standards/e2e-suite-v0.json +44 -0
- package/standards/patent-triage-v0.json +98 -0
- package/standards/patent-triage-v1.json +84 -0
- package/standards/seed-from-local-repos-v0.json +115 -0
- package/standards/sub_thread_invocation.json +50 -0
- package/standards/summarize.json +40 -0
- package/standards/synthesis-walk-v0.json +108 -0
- 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
|