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