@eir-labs/coltrane 0.4.1 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +33 -4
  2. package/dist/src/access_grant.d.ts +21 -0
  3. package/dist/src/access_grant.js +33 -4
  4. package/dist/src/access_grant.js.map +1 -1
  5. package/dist/src/claude_invoker.d.ts +28 -0
  6. package/dist/src/claude_invoker.js +68 -7
  7. package/dist/src/claude_invoker.js.map +1 -1
  8. package/dist/src/cli.d.ts +44 -0
  9. package/dist/src/cli.js +358 -0
  10. package/dist/src/cli.js.map +1 -0
  11. package/dist/src/cli_entry.d.ts +2 -0
  12. package/dist/src/cli_entry.js +23 -0
  13. package/dist/src/cli_entry.js.map +1 -0
  14. package/dist/src/composition.d.ts +2 -0
  15. package/dist/src/composition.js.map +1 -1
  16. package/dist/src/fs_atomic.d.ts +1 -0
  17. package/dist/src/fs_atomic.js +34 -0
  18. package/dist/src/fs_atomic.js.map +1 -0
  19. package/dist/src/genome_schema.d.ts +5 -1
  20. package/dist/src/genome_schema.js +12 -1
  21. package/dist/src/genome_schema.js.map +1 -1
  22. package/dist/src/genome_writer.d.ts +2 -2
  23. package/dist/src/genome_writer.js +17 -4
  24. package/dist/src/genome_writer.js.map +1 -1
  25. package/dist/src/gig_tracker.d.ts +29 -1
  26. package/dist/src/gig_tracker.js +27 -0
  27. package/dist/src/gig_tracker.js.map +1 -1
  28. package/dist/src/index.d.ts +1 -0
  29. package/dist/src/index.js +1 -0
  30. package/dist/src/index.js.map +1 -1
  31. package/dist/src/ledger.d.ts +3 -0
  32. package/dist/src/ledger.js.map +1 -1
  33. package/dist/src/loader.d.ts +2 -0
  34. package/dist/src/loader.js +10 -0
  35. package/dist/src/loader.js.map +1 -1
  36. package/dist/src/mcp.js +82 -20
  37. package/dist/src/mcp.js.map +1 -1
  38. package/dist/src/outputs.d.ts +74 -0
  39. package/dist/src/outputs.js +106 -71
  40. package/dist/src/outputs.js.map +1 -1
  41. package/dist/src/registry.d.ts +13 -0
  42. package/dist/src/registry.js +112 -0
  43. package/dist/src/registry.js.map +1 -1
  44. package/dist/src/reuse.d.ts +276 -0
  45. package/dist/src/reuse.js +215 -0
  46. package/dist/src/reuse.js.map +1 -0
  47. package/dist/src/runtime.d.ts +157 -4
  48. package/dist/src/runtime.js +519 -30
  49. package/dist/src/runtime.js.map +1 -1
  50. package/dist/src/server.d.ts +20 -0
  51. package/dist/src/server.js +733 -28
  52. package/dist/src/server.js.map +1 -1
  53. package/dist/src/server_relay.d.ts +2 -0
  54. package/dist/src/server_relay.js +31 -8
  55. package/dist/src/server_relay.js.map +1 -1
  56. package/dist/src/skill_runner.mjs +8 -2
  57. package/dist/src/skill_subprocess.d.ts +47 -5
  58. package/dist/src/skill_subprocess.js +242 -11
  59. package/dist/src/skill_subprocess.js.map +1 -1
  60. package/dist/src/version.d.ts +1 -1
  61. package/dist/src/version.js +11 -7
  62. package/dist/src/version.js.map +1 -1
  63. package/package.json +8 -7
@@ -7,14 +7,17 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
8
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
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";
10
+ import { loadRegistry, domainTypeDefect } from "./registry.js";
11
11
  import { resolveGenome } from "./loader.js";
12
12
  import { SkillSchema, AgentSchema, StandardSchema } from "./genome_schema.js";
13
+ import { runSkillFixtures, executeSkill, loadFixtures } from "./skill_subprocess.js";
14
+ import { evolveSkill } from "./skills.js";
13
15
  import { sealAgentDefinition, sealDefinition, sealSkillPackage, recordIdentity } from "./genome_writer.js";
14
16
  import { createOutputStore, defaultOutputsPersistDir } from "./outputs.js";
15
17
  import { FileLedger, LedgerError, LEDGER_SCHEMA_VERSION, defaultLedgerPath, } from "./ledger.js";
16
18
  import { standardSimulate } from "./simulate.js";
17
- import { runGig, BudgetExhausted, GigAborted, partialGigUsage, partialBudgetState } from "./runtime.js";
19
+ import { runGig, BudgetExhausted, GigAborted, ResumeRefused, partialGigUsage, partialBudgetState } from "./runtime.js";
20
+ import { createCheckpointStore, createReuseStore } from "./reuse.js";
18
21
  import { makeClaudeInvoker, killLiveChairChildren } from "./claude_invoker.js";
19
22
  import { isDepth, DEPTHS } from "./pricing.js";
20
23
  import { ENGINE_MCP_SERVER } from "./tool_providers.js";
@@ -25,9 +28,10 @@ import { proposeAgentChange, evolveProfile } from "./agent_profile.js";
25
28
  import { checkGrantTTL, validatePlanAgainstGrant } from "./access_grant.js";
26
29
  import { loadCharter, CharterError } from "./charter.js";
27
30
  import { COLTRANE_VERSION } from "./version.js";
28
- import { readFileSync, existsSync, mkdirSync, appendFileSync, readdirSync } from "node:fs";
31
+ import { readFileSync, existsSync, mkdirSync, appendFileSync, readdirSync, writeFileSync, mkdtempSync, rmSync } from "node:fs";
29
32
  import { randomUUID, createHash } from "node:crypto";
30
- import { join } from "node:path";
33
+ import { join, dirname } from "node:path";
34
+ import { tmpdir } from "node:os";
31
35
  import { newGigRun, applyGigProgress, gigEventLogLine, pruneGigRuns } from "./gig_tracker.js";
32
36
  import { isGig } from "./ledger.js";
33
37
  import { SubthreadRecorder, ApiVersionMismatchError } from "./subthread_recorder.js";
@@ -39,6 +43,31 @@ import { canonJson, runFingerprint, CANONICAL_FORM_VERSION } from "./canonical_f
39
43
  // must thread the SAME fields, or one rejects a standard the other accepts (#204 — the cascade
40
44
  // dropped input_types and wrongly failed entry chairs that read their contract from the gig input).
41
45
  const STD_PASSTHROUGH = Object.keys(StandardSchema.shape).filter((k) => !["slug", "domain", "agents", "agent_slugs", "phases"].includes(k));
46
+ /**
47
+ * `window` → the ISO instant to filter from, for the health surfaces (#234).
48
+ *
49
+ * Both `system_health` and `health_check` advertised a `window` and neither read it, so every
50
+ * health reading was over ALL TIME while presenting as a windowed one. That is the failure mode
51
+ * this engine keeps finding: not a missing answer but a confident wrong one — "$412 of spend"
52
+ * is a very different sentence depending on whether it covers a week or a year, and the caller
53
+ * who asked for a week had no way to tell which they got.
54
+ *
55
+ * Returns `{}` for an absent window (all time — the prior behaviour, now the explicit default)
56
+ * and an `error` for one that cannot be parsed. Silently falling back to all-time on a typo is
57
+ * how the argument came to be ignored in the first place.
58
+ */
59
+ export function parseWindow(raw, now) {
60
+ if (raw === undefined || raw === null || raw === "")
61
+ return {};
62
+ const m = /^(\d+)\s*([hdw])$/.exec(String(raw).trim().toLowerCase());
63
+ if (!m)
64
+ return { error: `unrecognized window "${String(raw)}" — use e.g. "24h", "7d", "2w"` };
65
+ const n = Number(m[1]);
66
+ if (n <= 0)
67
+ return { error: `window must be positive, got "${String(raw)}"` };
68
+ const ms = m[2] === "h" ? 3_600_000 : m[2] === "d" ? 86_400_000 : 604_800_000;
69
+ return { after: new Date(now - n * ms).toISOString() };
70
+ }
42
71
  /** Build a governance row. Every governance act names WHAT it was about (`subject_slug`) and
43
72
  * carries its payload (`detail`) — v1 recorded a bare UUID and "n/a" identity (#212). */
44
73
  function governanceRow(event, subject_slug, detail, subject_gig_id) {
@@ -218,6 +247,26 @@ async function runImpl(slug, args, deps, approval) {
218
247
  types = types.filter((t) => t.domain === args["domain"]);
219
248
  if (args["extends"])
220
249
  types = types.filter((t) => t.extends === args["extends"]);
250
+ // #234/#203 — `status` was advertised here and never applied. The two issues compound:
251
+ // #203 gave domain types a lifecycle, and the tool for finding types offered to filter
252
+ // on it while returning retired ones anyway. An operator browsing for what they may
253
+ // build on got the retired definitions back with nothing marking them.
254
+ //
255
+ // The registry default is "active", so an undeclared type answers to `status:"active"`
256
+ // rather than being invisible to every filter.
257
+ if (args["status"]) {
258
+ const want = String(args["status"]);
259
+ types = types.filter((t) => (t.status ?? "active") === want);
260
+ }
261
+ // #234 — `min_usage` likewise advertised and ignored. Usage is the count of sealed
262
+ // outputs of that type, the same derivation system_audit uses to call a type unused.
263
+ if (typeof args["min_usage"] === "number") {
264
+ const min = args["min_usage"];
265
+ const usage = new Map();
266
+ for (const o of deps.outputs.all())
267
+ usage.set(o.domain_type, (usage.get(o.domain_type) ?? 0) + 1);
268
+ types = types.filter((t) => (usage.get(t.slug) ?? 0) >= min);
269
+ }
221
270
  return { ok: true, requires_approval: approval, data: { types, stats: { count: types.length } } };
222
271
  }
223
272
  case "type_register": {
@@ -236,7 +285,7 @@ async function runImpl(slug, args, deps, approval) {
236
285
  const res = deps.registry.registerType(def);
237
286
  // substrate seal: persist a loadable domain_types/<slug>.json (full record) + ledger.
238
287
  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");
288
+ const sealed = sealDefinition("type_register", def.slug, fileDef, deps.ledger, deps.genome_dir, "domain_types", args["reason"] != null ? { reason: args["reason"] } : undefined);
240
289
  return { ok: true, requires_approval: approval, data: { ...res, content_hash: sealed.content_hash, dependency_hash: sealed.dependency_hash, effective_hash: sealed.effective_hash } };
241
290
  }
242
291
  case "standard_simulate": {
@@ -308,13 +357,65 @@ async function runImpl(slug, args, deps, approval) {
308
357
  outs = outs.filter((o) => o.gig_id === args["gig_id"]);
309
358
  if (args["agent_slug"])
310
359
  outs = outs.filter((o) => o.agent_slug === args["agent_slug"]);
360
+ // #234 — `data_filter` was advertised and ignored, so a caller narrowing a query by
361
+ // payload got the UNFILTERED set back and a `total_count` describing it. Every key must
362
+ // match (AND), compared structurally so an object or array value filters as written.
363
+ const dataFilter = args["data_filter"];
364
+ if (dataFilter && typeof dataFilter === "object" && !Array.isArray(dataFilter)) {
365
+ const entries = Object.entries(dataFilter);
366
+ outs = outs.filter((o) => {
367
+ const data = (o.data ?? {});
368
+ return entries.every(([k, v]) => canonJson(data[k]) === canonJson(v));
369
+ });
370
+ }
311
371
  return { ok: true, requires_approval: approval, data: { outputs: outs, total_count: outs.length } };
312
372
  }
313
373
  case "output_trace": {
314
374
  const id = String(args["output_id"] ?? "");
315
375
  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) } };
376
+ // #234 `direction` was advertised and ignored: every trace walked UPSTREAM, so a
377
+ // caller asking "what was derived FROM this draft?" received its ancestors instead and
378
+ // nothing said the answer was to a different question. `outputs.trace` is inherently
379
+ // backward (it follows input_refs), so downstream is walked here over the same store.
380
+ const direction = String(args["direction"] ?? "upstream").toLowerCase();
381
+ if (!["upstream", "downstream", "both"].includes(direction)) {
382
+ return { ok: false, requires_approval: approval, error: `unrecognized direction "${direction}" — use "upstream", "downstream" or "both"` };
383
+ }
384
+ const upstream = direction === "downstream"
385
+ ? []
386
+ : deps.outputs.trace(id, maxDepth !== undefined ? { max_depth: maxDepth } : undefined);
387
+ // Forward walk: a node's children are the outputs naming it in their input_refs.
388
+ const downstream = [];
389
+ if (direction !== "upstream") {
390
+ const all = deps.outputs.all();
391
+ const seen = new Set([id]);
392
+ let frontier = [id];
393
+ for (let depth = 0; frontier.length && (maxDepth === undefined || depth < maxDepth); depth++) {
394
+ const next = [];
395
+ for (const o of all) {
396
+ if (seen.has(o.id))
397
+ continue;
398
+ if (o.input_refs.some((r) => frontier.includes(r))) {
399
+ seen.add(o.id);
400
+ downstream.push(o);
401
+ next.push(o.id);
402
+ }
403
+ }
404
+ frontier = next;
405
+ }
406
+ }
407
+ const nodes = direction === "upstream" ? upstream
408
+ : direction === "downstream" ? downstream
409
+ : [...upstream, ...downstream.filter((d) => !upstream.some((u) => u.id === d.id))];
410
+ return {
411
+ ok: true, requires_approval: approval,
412
+ data: {
413
+ graph: { nodes }, direction,
414
+ root_signals: nodes.filter((o) => o.input_refs.length === 0),
415
+ // The other end of the chain: outputs nothing else was derived from.
416
+ terminal_outputs: nodes.filter((o) => !deps.outputs.all().some((x) => x.input_refs.includes(o.id))),
417
+ },
418
+ };
318
419
  }
319
420
  case "output_write": {
320
421
  // §6 universal output write: validates against core+domain schema AT WRITE
@@ -342,6 +443,8 @@ async function runImpl(slug, args, deps, approval) {
342
443
  domain: String(args["domain"] ?? ""),
343
444
  gig_id: String(args["gig_id"] ?? ""),
344
445
  agent_slug: String(args["agent_slug"] ?? ""),
446
+ ...(typeof args["model"] === "string" ? { model: args["model"] } : {}),
447
+ ...(typeof args["model_tier"] === "string" ? { model_tier: args["model_tier"] } : {}),
345
448
  phase: args["phase"],
346
449
  primitive,
347
450
  data,
@@ -375,6 +478,29 @@ async function runImpl(slug, args, deps, approval) {
375
478
  const standard = deps.standards.get(slug2);
376
479
  if (!standard)
377
480
  return { ok: false, requires_approval: approval, error: `unknown standard "${slug2}"` };
481
+ // #203, the READ side. Preserving `status` through the loader was only half of it: the
482
+ // symptom recorded on the issue — "a retired standard stays dispatchable and nothing
483
+ // says otherwise" — survived the field being kept, because nothing consulted it. A
484
+ // declaration that round-trips and changes nothing is worse than one that is dropped;
485
+ // the round-trip is evidence it took effect.
486
+ //
487
+ // Placed ABOVE the wait/async split deliberately. Both modes have their own body below,
488
+ // and a guard sitting inside the synchronous branch would leave the DEFAULT path — the
489
+ // one the product dispatches through — open.
490
+ //
491
+ // deprecated ALLOWS and warns; retired REFUSES. Were both refused, `deprecated` would
492
+ // be a spelling of `retired` and there would be no way to say the softer thing.
493
+ const stdStatus = standard.status;
494
+ if (stdStatus === "retired") {
495
+ return {
496
+ ok: false, requires_approval: approval,
497
+ error: `standard "${slug2}" is retired and cannot be dispatched. ` +
498
+ `Promote it back to active (standard_promote) if it should run again.`,
499
+ };
500
+ }
501
+ const warnings = stdStatus === "deprecated"
502
+ ? [`standard "${slug2}" is deprecated — it still runs, but should not be built on.`]
503
+ : [];
378
504
  // Optional budget arg — when present, runtime enforces per-gig cost-budget
379
505
  // and raises BudgetExhausted on depletion (PR for T10 gap, see runtime.ts).
380
506
  const budgetArg = args["budget"];
@@ -393,6 +519,39 @@ async function runImpl(slug, args, deps, approval) {
393
519
  if (depthArg.error)
394
520
  return { ok: false, requires_approval: approval, error: depthArg.error };
395
521
  const depth = depthArg.depth;
522
+ // ── reuse a sealed output instead of re-deriving it ──────────────────────────────
523
+ // Both halves are opt-in, and both are named on the dispatch call so the decision is
524
+ // recorded where the run is requested rather than inferred from server configuration.
525
+ const resumeArg = args["resume_gig_id"] === undefined || args["resume_gig_id"] === null
526
+ ? undefined
527
+ : String(args["resume_gig_id"]);
528
+ if (resumeArg !== undefined && resumeArg.trim() === "") {
529
+ return { ok: false, requires_approval: approval, error: `gig_dispatch: "resume_gig_id" must be a gig id, not an empty string` };
530
+ }
531
+ if (resumeArg !== undefined && !deps.checkpoints) {
532
+ return { ok: false, requires_approval: approval, error: `gig_dispatch: resume_gig_id was supplied but this server has no checkpoint store wired, so no run is resumable` };
533
+ }
534
+ // A live run holds the AbortController for that gig_id; resuming into it would put two
535
+ // runs on one gig, writing to the same outputs file and racing the same checkpoint.
536
+ if (resumeArg !== undefined && deps.gig_runs?.get(resumeArg)?.status === "running") {
537
+ return { ok: false, requires_approval: approval, error: `gig_dispatch: gig "${resumeArg}" is still running — abort it before resuming` };
538
+ }
539
+ const reuseOn = args["reuse"] === true;
540
+ if (reuseOn && !deps.reuse) {
541
+ return { ok: false, requires_approval: approval, error: `gig_dispatch: reuse was requested but this server has no reuse store wired` };
542
+ }
543
+ const reuseWiring = {
544
+ ...(deps.checkpoints ? { checkpoints: deps.checkpoints } : {}),
545
+ ...(resumeArg !== undefined ? { resume_from: resumeArg } : {}),
546
+ ...(reuseOn && deps.reuse ? { reuse: deps.reuse } : {}),
547
+ };
548
+ /** What a run skipped, and why — echoed on every reply so a saving is never silent. */
549
+ const savings = (res) => ({
550
+ ...(res.skipped ? { skipped: res.skipped } : {}),
551
+ ...(res.resumed_from ? { resumed_from: res.resumed_from } : {}),
552
+ ...(res.reuse ? { reuse: res.reuse } : {}),
553
+ ...(res.checkpoint_error ? { checkpoint_error: res.checkpoint_error } : {}),
554
+ });
396
555
  // Synchronous mode (opt-in via wait:true) — block, return the manifest. The
397
556
  // deterministic test path and any caller that wants the answer in one call.
398
557
  const wait = args["wait"] === true;
@@ -401,22 +560,30 @@ async function runImpl(slug, args, deps, approval) {
401
560
  const res = await runGig(standard, gigInput, {
402
561
  outputs: deps.outputs, ledger: deps.ledger, invoke: deps.invoke,
403
562
  model_version: deps.model_version, skills: deps.skills, skill_dirs: deps.skill_dirs, evals: deps.evals, budget,
404
- ...(depth ? { depth } : {}),
563
+ ...(depth ? { depth } : {}), ...reuseWiring,
405
564
  });
406
565
  return {
407
566
  ok: true, requires_approval: approval,
408
567
  data: {
409
568
  gig_id: res.gig_id,
410
569
  ...(depth ? { depth } : {}),
570
+ warnings,
411
571
  manifest: {
412
572
  genome_hash: res.genome_hash, run_fingerprint: res.run_fingerprint, output_count: res.outputs.length,
413
573
  ...(res.usage ? { usage: res.usage } : {}), // #195 — settled model spend
414
574
  ...(res.budget_state ? { budget_state: res.budget_state } : {}),
575
+ ...savings(res),
415
576
  },
416
577
  },
417
578
  };
418
579
  }
419
580
  catch (e) {
581
+ // A refused resume is a REFUSAL, not a crash: nothing ran, nothing was spent, and
582
+ // the caller needs the drift list to decide whether to re-dispatch cold.
583
+ if (e instanceof ResumeRefused) {
584
+ return { ok: false, requires_approval: approval, error: e.message,
585
+ data: { resume_refused: true, gig_id: e.gig_id, drift: e.drift } };
586
+ }
420
587
  if (e instanceof BudgetExhausted) {
421
588
  // #236 — the synchronous half: a depleted gig also burned real dollars before it
422
589
  // stopped, and the operator needs them in the same reply as the depletion notice.
@@ -431,8 +598,17 @@ async function runImpl(slug, args, deps, approval) {
431
598
  // Async mode (default) — register live state, run in the background, return the id
432
599
  // immediately so the caller can poll gig_monitor + tail the per-chair logs instead of
433
600
  // blocking for the whole run ("synchronous dispatch is not a good pattern").
434
- const gigId = randomUUID();
601
+ // A resumed run CONTINUES the gig it resumes — same id — so the restored outputs stay
602
+ // in-gig and `output_trace` still reaches them. The live-state entry for the earlier
603
+ // attempt is replaced: that gig is running again, and showing its old `failed` state
604
+ // while it runs would be a lie the operator acts on.
605
+ const gigId = resumeArg ?? randomUUID();
435
606
  const runs = deps.gig_runs ?? (deps.gig_runs = new Map());
607
+ // #278 review — keep the prior attempt's record so a REFUSED resume can put it back.
608
+ // Overwriting it is right when the resume proceeds (that gig is running again), and
609
+ // destructive when it does not: the operator loses the `failed` status and error they
610
+ // were resuming in response to, and is left with a gig stuck at `running` forever.
611
+ const priorState = runs.get(gigId);
436
612
  const state = newGigRun(gigId, slug2, standard.phases.length, new Date().toISOString());
437
613
  // #249/#250 — the cancellation handle, held for as long as the run is live. This is the
438
614
  // object gig_abort reaches; before it existed there was nothing to reach.
@@ -462,11 +638,24 @@ async function runImpl(slug, args, deps, approval) {
462
638
  catch { /* best-effort */ }
463
639
  }
464
640
  };
465
- void runGig(standard, gigInput, {
641
+ const runPromise = runGig(standard, gigInput, {
466
642
  outputs: deps.outputs, ledger: deps.ledger, invoke: deps.invoke,
467
643
  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
- })
644
+ gig_id: gigId, onProgress, signal: controller.signal, ...(depth ? { depth } : {}), ...reuseWiring,
645
+ });
646
+ // A REFUSED resume must be answered in THIS reply, not discovered later by polling. The
647
+ // gate throws in runGig's SYNCHRONOUS phase — before its first `await`, which is exactly
648
+ // what "a refused resume spends nothing" means — so the promise is already rejected by
649
+ // the time we get here, and registering this handler first queues it ahead of the
650
+ // microtask that resumes the `await` below. tests/phase_resume_and_reuse pins that
651
+ // ordering property so it cannot silently regress into a "running" reply for a run that
652
+ // never started. (The main chain below still handles the rejection; this only observes.)
653
+ let resumeRefusal;
654
+ if (resumeArg !== undefined) {
655
+ void runPromise.catch((e) => { if (e instanceof ResumeRefused)
656
+ resumeRefusal = e; });
657
+ }
658
+ void runPromise
470
659
  .then((res) => {
471
660
  state.status = "complete";
472
661
  state.finished_at = new Date().toISOString();
@@ -475,6 +664,13 @@ async function runImpl(slug, args, deps, approval) {
475
664
  state.outputs_count = res.outputs.length;
476
665
  if (res.usage)
477
666
  state.usage = res.usage; // #195 — surface settled spend to gig_monitor
667
+ // Say what was skipped. On the async path the manifest never reaches the caller, so
668
+ // gig_monitor is the ONLY place a saving can be reported — and an unreported saving
669
+ // is indistinguishable from chairs that quietly failed to run.
670
+ if (res.skipped)
671
+ state.skipped_chairs = res.skipped.map((s) => ({ phase: s.phase, role: s.role, reason: s.reason, source_gig_id: s.source_gig_id, output_types: s.output_types }));
672
+ if (res.reuse && res.reuse.rejected.length > 0)
673
+ state.reuse_rejected = res.reuse.rejected.map((r) => ({ phase: r.phase, role: r.role, reason: r.reason, ...(r.detail !== undefined ? { detail: r.detail } : {}) }));
478
674
  // #236 — the synchronous reply has carried budget_state since the budget existed;
479
675
  // the async path never did, so the DEFAULT dispatch mode could not answer "what did
480
676
  // this consume?" even on success.
@@ -511,9 +707,30 @@ async function runImpl(slug, args, deps, approval) {
511
707
  onProgress({ type: "gig_failed", error: state.error });
512
708
  })
513
709
  .finally(() => { state.controller = undefined; }); // don't pin a controller past settle
710
+ if (resumeArg !== undefined) {
711
+ await Promise.resolve(); // one turn — see the ordering note above
712
+ if (resumeRefusal) {
713
+ // The gig never started, so it must not be left masquerading as a live run — and
714
+ // RESTORING beats deleting. Deleting turned a `failed` gig into an unknown one and
715
+ // left any poller waiting on a run that no longer existed; the failure the operator
716
+ // was acting on is exactly what they still need to see.
717
+ if (priorState)
718
+ deps.gig_runs?.set(gigId, priorState);
719
+ else
720
+ deps.gig_runs?.delete(gigId);
721
+ return { ok: false, requires_approval: approval, error: resumeRefusal.message,
722
+ data: { resume_refused: true, gig_id: resumeRefusal.gig_id, drift: resumeRefusal.drift } };
723
+ }
724
+ }
514
725
  return {
515
726
  ok: true, requires_approval: approval,
516
- data: { gig_id: gigId, status: "running", ...(depth ? { depth } : {}), ...(logDir ? { log_dir: logDir } : {}) },
727
+ data: {
728
+ gig_id: gigId, status: "running", ...(depth ? { depth } : {}), warnings, ...(logDir ? { log_dir: logDir } : {}),
729
+ // Echo the opt-ins back. A caller who typo'd `reuse` and paid full price for a run
730
+ // they believed was cached has no other way to find out.
731
+ ...(resumeArg !== undefined ? { resumed_from: resumeArg } : {}),
732
+ ...(reuseOn ? { reuse: true } : {}),
733
+ },
517
734
  };
518
735
  }
519
736
  case "gig_monitor": {
@@ -540,6 +757,12 @@ async function runImpl(slug, args, deps, approval) {
540
757
  // `unit: "append-units"` and the real `settled_usd` alongside (#233), so nothing
541
758
  // reads the synthetic proxy as dollars.
542
759
  ...(live.budget_state ? { budget_state: live.budget_state } : {}),
760
+ // A run that skipped phases must SAY which and why. On the async path this is the
761
+ // only surface that can carry it, and a run showing 6 phases complete in 4 seconds
762
+ // is otherwise indistinguishable from one whose chairs quietly did nothing.
763
+ ...(live.skipped_chairs ? { skipped_chairs: live.skipped_chairs } : {}),
764
+ ...(live.resumed_from ? { resumed_from: live.resumed_from } : {}),
765
+ ...(live.reuse_rejected ? { reuse_rejected: live.reuse_rejected } : {}),
543
766
  ...(live.abort_reason ? { abort_reason: live.abort_reason } : {}), // why it stopped (#251)
544
767
  ...(live.error ? { error: live.error } : {}),
545
768
  ...(live.finished_at ? { finished_at: live.finished_at } : {}),
@@ -701,6 +924,21 @@ async function runImpl(slug, args, deps, approval) {
701
924
  slug: baseDef.slug, version: 1, extends: baseDef.extends, domain: baseDef.domain,
702
925
  status: "active", schema: { type: "object", properties: baseProps }, required_fields: baseDef.required_fields,
703
926
  };
927
+ // THE THIRD DOOR. `{...baseProps, ...addProps}` above is the exact merge #264 is
928
+ // about, and this handler reached `recordIdentity` without ever consulting
929
+ // `registerType` or `domainTypeDefect` — so `type_extend` could persist and version a
930
+ // definition the engine had just declared illegal. #264 names this tool explicitly.
931
+ //
932
+ // The thesis of that fix was "there are two doors into the type table, and a rule
933
+ // enforced at one of them is a rule with a way around it." There were three.
934
+ const extendDefect = domainTypeDefect({
935
+ slug: baseDef.slug,
936
+ extends: baseDef.extends,
937
+ schema: { properties: nextProps },
938
+ });
939
+ if (extendDefect) {
940
+ return { ok: false, requires_approval: approval, error: `type_extend rejected: ${extendDefect}` };
941
+ }
704
942
  const next = {
705
943
  ...base, schema: { type: "object", properties: nextProps }, required_fields: nextRequired,
706
944
  };
@@ -709,7 +947,7 @@ async function runImpl(slug, args, deps, approval) {
709
947
  // substrate seal: the new version's identity is recorded in the ledger (file
710
948
  // materialization of versioned types follows the version-aware loader path).
711
949
  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;
950
+ const tx = deps.genome_dir ? recordIdentity("type_extend", `${base.slug}@v${proposal.next_version}`, versioned, deps.ledger, args["reason"] != null ? { reason: args["reason"] } : undefined) : undefined;
713
951
  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
952
  }
715
953
  case "charter_read": {
@@ -764,7 +1002,13 @@ async function runImpl(slug, args, deps, approval) {
764
1002
  // and `cost` describe different ledgers IN THE SAME RESPONSE. `read()`'s own docstring
765
1003
  // promises "a single read pass shared by query / count / integrity, so the three can
766
1004
  // never disagree"; the call site was undoing that.
767
- const gigRows = deps.ledger.query({ kind: "gig" });
1005
+ // #234 `window` was advertised and ignored, so every reading below silently covered
1006
+ // all time. It is a filter on the SAME single read pass, so the totals still cannot
1007
+ // disagree with each other.
1008
+ const shWindow = parseWindow(args["window"], Date.now());
1009
+ if (shWindow.error)
1010
+ return { ok: false, requires_approval: approval, error: shWindow.error };
1011
+ const gigRows = deps.ledger.query({ kind: "gig", ...(shWindow.after ? { after: shWindow.after } : {}) });
768
1012
  const gigs_run = gigRows.length;
769
1013
  // Settled spend where we have it (#195) — a real number now that gig rows are
770
1014
  // separable, instead of a row-count proxy standing in for dollars.
@@ -920,10 +1164,18 @@ async function runImpl(slug, args, deps, approval) {
920
1164
  case "health_check": {
921
1165
  const targetSlug = String(args["slug"] ?? "");
922
1166
  const targetKind = String(args["kind"] ?? args["entity_type"] ?? "");
923
- const all = deps.outputs.all();
1167
+ // #234 the advertised-and-ignored `window`, same as system_health.
1168
+ const hcWindow = parseWindow(args["window"], Date.now());
1169
+ if (hcWindow.error)
1170
+ return { ok: false, requires_approval: approval, error: hcWindow.error };
1171
+ // The window has to reach BOTH stores. Applying it only to the ledger would make a
1172
+ // windowed health_check on a standard mean one thing and on an agent mean another.
1173
+ const all = hcWindow.after
1174
+ ? deps.outputs.all().filter((o) => o.created_at >= hcWindow.after)
1175
+ : deps.outputs.all();
924
1176
  // standards live in the ledger (executions); agents/types in the outputs store.
925
1177
  const gigRows = targetKind === "standard"
926
- ? deps.ledger.query({ kind: "gig", standard_slug: targetSlug }).filter(isGig)
1178
+ ? deps.ledger.query({ kind: "gig", standard_slug: targetSlug, ...(hcWindow.after ? { after: hcWindow.after } : {}) }).filter(isGig)
927
1179
  : [];
928
1180
  const execution_count = gigRows.length;
929
1181
  const filtered = targetKind === "agent"
@@ -964,18 +1216,71 @@ async function runImpl(slug, args, deps, approval) {
964
1216
  case "system_audit": {
965
1217
  // Real derivation over the genome: a registered domain type with zero
966
1218
  // 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));
1219
+ // #234 `scope` and `check` were advertised and ignored, so a caller auditing one
1220
+ // domain received findings for every domain and had no way to tell.
1221
+ const auditScope = args["scope"] !== undefined ? String(args["scope"]) : undefined;
1222
+ const auditCheck = args["check"] !== undefined ? String(args["check"]) : undefined;
1223
+ const KNOWN_CHECKS = ["unused_type"];
1224
+ if (auditCheck !== undefined && !KNOWN_CHECKS.includes(auditCheck)) {
1225
+ return { ok: false, requires_approval: approval, error: `unknown check "${auditCheck}" — known checks: ${KNOWN_CHECKS.join(", ")}` };
1226
+ }
1227
+ // `scope` narrows to a domain; the counts reported must describe the SAME slice the
1228
+ // findings do, or the response contradicts itself.
1229
+ const allTypes = deps.registry.listTypes();
1230
+ const types = auditScope ? allTypes.filter((t) => t.domain === auditScope) : allTypes;
1231
+ const allOutputs = deps.outputs.all();
1232
+ const scopedOutputs = auditScope
1233
+ ? allOutputs.filter((o) => types.some((t) => t.slug === o.domain_type))
1234
+ : allOutputs;
1235
+ const usedTypes = new Set(allOutputs.map((o) => o.domain_type));
969
1236
  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 } };
1237
+ const findings = (auditCheck === undefined || auditCheck === "unused_type")
1238
+ ? unused_types.map((slug) => ({ kind: "unused_type", slug, severity: "info" }))
1239
+ : [];
1240
+ return { ok: true, requires_approval: approval, data: { findings, unused_types, type_count: types.length, output_count: scopedOutputs.length, ...(auditScope ? { scope: auditScope } : {}), ...(auditCheck ? { check: auditCheck } : {}) } };
972
1241
  }
1242
+ // #234 — these two minted a UUID, discarded every argument, and reported success.
1243
+ //
1244
+ // Not a no-op: a fabricated `proposal_id` is a RECEIPT. A caller handed one has been told
1245
+ // their proposal was recorded and can be looked up, and neither was true — the slug, spec
1246
+ // and reason went nowhere, and nothing anywhere could be found under that id.
1247
+ //
1248
+ // Their own tests sat in a describe block named "proposal tools (LEDGER-BACKED)", where
1249
+ // the `proposal_create` case asserts `ledger.query().length === 1` and these two assert
1250
+ // only `typeof proposal_id === "string"` — which `randomUUID()` satisfies forever. A
1251
+ // regression guard elsewhere states "all are wired against real in-repo impl now". The
1252
+ // fabricated id is precisely what made both look true.
1253
+ //
1254
+ // So: record what the caller sent, through the same `governanceRow` path proposal_create
1255
+ // uses. Deprecation is a governance act on the tool registry; a proposal to remove a tool
1256
+ // that leaves no trace is worse than one that is refused, because the refusal is visible.
1257
+ //
1258
+ // Kept as two case blocks rather than one fallthrough: they take different arguments, and
1259
+ // a shared body makes each tool appear to read the other's — which is exactly the
1260
+ // schema/handler drift this issue is about, reintroduced in the fix for it.
973
1261
  case "tool_propose": {
1262
+ const toolSlug = String(args["slug"] ?? "");
1263
+ if (!toolSlug)
1264
+ return { ok: false, requires_approval: approval, error: "tool_propose requires slug" };
974
1265
  const proposal_id = randomUUID();
1266
+ deps.ledger.append(governanceRow("tool_propose", toolSlug, {
1267
+ proposal_id,
1268
+ reason: args["reason"] ?? null,
1269
+ tool_type: args["type"] ?? null,
1270
+ spec: args["spec"] ?? null,
1271
+ }));
975
1272
  return { ok: true, requires_approval: true, data: { proposal_id } };
976
1273
  }
977
1274
  case "tool_deprecate_propose": {
1275
+ const toolSlug = String(args["slug"] ?? "");
1276
+ if (!toolSlug)
1277
+ return { ok: false, requires_approval: approval, error: "tool_deprecate_propose requires slug" };
978
1278
  const proposal_id = randomUUID();
1279
+ deps.ledger.append(governanceRow("tool_deprecate_propose", toolSlug, {
1280
+ proposal_id,
1281
+ reason: args["reason"] ?? null,
1282
+ usage_stats: args["usage_stats"] ?? null,
1283
+ }));
979
1284
  return { ok: true, requires_approval: true, data: { proposal_id, affected_agents: [] } };
980
1285
  }
981
1286
  case "proposal_create": {
@@ -997,14 +1302,31 @@ async function runImpl(slug, args, deps, approval) {
997
1302
  case "capability_research": {
998
1303
  // Real local gap-search over the genome: does any existing tool or domain
999
1304
  // 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) : [];
1305
+ //
1306
+ // #234 this handler read `query`/`capability` while the tool advertised `need`/
1307
+ // `context`. The two sets did not overlap, so EVERY caller following the schema
1308
+ // searched for the empty string. That is not a no-op: an empty search matches nothing,
1309
+ // nothing matched means `gap: true`, and the tool answered "no existing capability —
1310
+ // propose a new tool/type" for every capability the engine has. The one tool whose job
1311
+ // is to stop redundant definitions recommended a new one, unconditionally, to anyone
1312
+ // who used it as documented — inverting this repo's "reuse and evolve, don't duplicate"
1313
+ // rule at precisely the step that rule is meant to govern.
1314
+ //
1315
+ // `need` is now primary (it is the advertised name); `query`/`capability` stay as
1316
+ // accepted aliases so existing callers keep working, and are advertised too.
1317
+ const q = String(args["need"] ?? args["query"] ?? args["capability"] ?? "").trim().toLowerCase();
1318
+ // An empty search is REFUSED rather than answered. Reporting `gap: true` for a question
1319
+ // nobody asked is the specific failure above: a confident wrong answer, not a missing one.
1320
+ if (!q) {
1321
+ return { ok: false, requires_approval: approval, error: "capability_research needs a non-empty `need` (the capability to search for)" };
1322
+ }
1323
+ const toolMatches = MCP_TOOLS.filter((t) => t.slug.toLowerCase().includes(q)).map((t) => t.slug);
1324
+ const typeMatches = deps.registry.listTypes().filter((t) => t.slug.toLowerCase().includes(q)).map((t) => t.slug);
1003
1325
  const existing_matches = [...toolMatches, ...typeMatches];
1004
1326
  const gap = existing_matches.length === 0;
1005
1327
  return {
1006
1328
  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" },
1329
+ data: { need: q, query: q, existing_matches, gap, approaches: [], mcp_options: toolMatches, recommendation: gap ? "no existing capability — propose a new tool/type" : "reuse existing" },
1008
1330
  };
1009
1331
  }
1010
1332
  case "gig_abort": {
@@ -1122,7 +1444,15 @@ async function runImpl(slug, args, deps, approval) {
1122
1444
  // the caller told the call failed, and no audit row at all — the audit trail could not
1123
1445
  // answer "who granted this capability, and when".
1124
1446
  const registration_id = randomUUID();
1125
- deps.ledger.append(governanceRow("tool_register", targetSlug, { registration_id }));
1447
+ // #234 — `type`, `spec` and `category` were advertised and discarded. This row IS the
1448
+ // audit answer to "who granted this capability, and what did they grant?"; without the
1449
+ // spec it could only answer the first half.
1450
+ deps.ledger.append(governanceRow("tool_register", targetSlug, {
1451
+ registration_id,
1452
+ tool_type: args["type"] ?? null,
1453
+ spec: args["spec"] ?? null,
1454
+ category: args["category"] ?? null,
1455
+ }));
1126
1456
  REGISTERED_TOOL_SLUGS.add(targetSlug);
1127
1457
  // Keep the #185 provider bridge live: a freshly-registered tool must resolve for a same-
1128
1458
  // session agent_define→dispatch (the registry and provider map share lifecycle).
@@ -1148,7 +1478,11 @@ async function runImpl(slug, args, deps, approval) {
1148
1478
  : null;
1149
1479
  // substrate seal: the evolved version's identity (lineage claim) is recorded in
1150
1480
  // 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;
1481
+ const evolveDetail = {
1482
+ ...(args["reason"] != null ? { reason: args["reason"] } : {}),
1483
+ ...(args["evidence"] != null ? { evidence: args["evidence"] } : {}),
1484
+ };
1485
+ const ev = (evolved && deps.genome_dir) ? recordIdentity("agent_evolve", `${base.slug}@v${new_version}`, evolved, deps.ledger, evolveDetail) : undefined;
1152
1486
  return {
1153
1487
  ok: true, requires_approval: change.approval_required,
1154
1488
  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: [] } },
@@ -1281,6 +1615,159 @@ async function runImpl(slug, args, deps, approval) {
1281
1615
  deps.skills?.set(skSlug, def);
1282
1616
  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
1617
  }
1618
+ // ── the skill iteration loop ─────────────────────────────────────────────────────
1619
+ // Until now the surface was define + promote: a skill could be created and given
1620
+ // production status, and never RUN, TESTED, LISTED or REVISED through the engine. The
1621
+ // fixture gate on promotion made that gap sharper — you could be refused for failing
1622
+ // fixtures with no way to run them and see why.
1623
+ case "skill_browse": {
1624
+ if (!deps.skills)
1625
+ return { ok: false, not_implemented: true, requires_approval: approval, error: "skill_browse needs a skills map (bootstrap from a genome)" };
1626
+ let list = [...deps.skills.values()];
1627
+ if (args["domain"])
1628
+ list = list.filter((k) => k["domain"] === args["domain"]);
1629
+ if (args["status"])
1630
+ list = list.filter((k) => (k["status"] ?? "draft") === args["status"]);
1631
+ if (args["skill_type"])
1632
+ list = list.filter((k) => k["skill_type"] === args["skill_type"]);
1633
+ // `has_code` is the axis that matters for the promotion gate: only a code half can be
1634
+ // held to fixtures, so it is the filter an operator actually reaches for.
1635
+ if (args["has_code"] !== undefined) {
1636
+ const want = args["has_code"] === true || args["has_code"] === "true";
1637
+ list = list.filter((k) => (k["code_hash"] != null) === want);
1638
+ }
1639
+ const skills = list
1640
+ .map((k) => ({
1641
+ slug: k["slug"], version: k["version"], domain: k["domain"], status: k["status"] ?? null,
1642
+ skill_type: k["skill_type"], input_type: k["input_type"], output_type: k["output_type"],
1643
+ has_code: k["code_hash"] != null, code_hash: k["code_hash"] ?? null,
1644
+ tier: k["permission"]?.tier ?? 0,
1645
+ }))
1646
+ .sort((a, b) => (String(a.slug) < String(b.slug) ? -1 : 1));
1647
+ return { ok: true, requires_approval: approval, data: { skills, count: skills.length } };
1648
+ }
1649
+ case "skill_inspect": {
1650
+ if (!deps.skills)
1651
+ return { ok: false, not_implemented: true, requires_approval: approval, error: "skill_inspect needs a skills map (bootstrap from a genome)" };
1652
+ const target = String(args["slug"] ?? "");
1653
+ if (!target)
1654
+ return { ok: false, requires_approval: approval, error: "skill_inspect requires slug" };
1655
+ const sk = deps.skills.get(target);
1656
+ if (!sk)
1657
+ return { ok: false, requires_approval: approval, error: `unknown skill "${target}"` };
1658
+ const dir = sk["package_dir"];
1659
+ // Fixtures are the skill's contract with the promotion gate, so they are what an
1660
+ // operator most needs to see. Inputs only — an expected_output is an answer key.
1661
+ const fixtures = dir ? loadFixtures(dir).map((f) => ({ id: f.id, input: f.input, has_expected: f.expected_output !== undefined, assertions: (f.assertions ?? []).length })) : [];
1662
+ return {
1663
+ ok: true, requires_approval: approval,
1664
+ data: {
1665
+ slug: sk["slug"], version: sk["version"], domain: sk["domain"], status: sk["status"] ?? null,
1666
+ skill_type: sk["skill_type"], input_type: sk["input_type"], output_type: sk["output_type"],
1667
+ description: sk["description"] ?? null,
1668
+ permission: sk["permission"] ?? { tier: 0 },
1669
+ has_code: sk["code_hash"] != null, code_hash: sk["code_hash"] ?? null,
1670
+ has_md: sk["md"] !== undefined,
1671
+ fixture_count: fixtures.length, fixtures,
1672
+ package_dir: dir ?? null,
1673
+ // Said plainly, because it is the difference between "will promote" and "cannot".
1674
+ promotable: sk["code_hash"] == null ? true : fixtures.length > 0,
1675
+ },
1676
+ };
1677
+ }
1678
+ case "skill_execute": {
1679
+ if (!deps.skills)
1680
+ return { ok: false, not_implemented: true, requires_approval: approval, error: "skill_execute needs a skills map (bootstrap from a genome)" };
1681
+ const target = String(args["slug"] ?? "");
1682
+ if (!target)
1683
+ return { ok: false, requires_approval: approval, error: "skill_execute requires slug" };
1684
+ const sk = deps.skills.get(target);
1685
+ if (!sk)
1686
+ return { ok: false, requires_approval: approval, error: `unknown skill "${target}"` };
1687
+ const dir = sk["package_dir"];
1688
+ if (!dir || sk["code_hash"] == null) {
1689
+ return { ok: false, requires_approval: approval, error: `skill "${target}" has no code half — there is nothing to execute (it is a reasoning skill)` };
1690
+ }
1691
+ // mode:"test" runs the skill's own fixtures instead of a caller's input. This is the
1692
+ // command that makes the promotion gate actionable: refused for failing fixtures, run
1693
+ // this, see which and why.
1694
+ if (args["mode"] === "test") {
1695
+ const report = runSkillFixtures(dir);
1696
+ const threshold = report.deterministic ? 1.0 : 0.8;
1697
+ return {
1698
+ ok: true, requires_approval: approval,
1699
+ data: { ...report, threshold, would_promote: report.total > 0 && report.pass_rate >= threshold },
1700
+ };
1701
+ }
1702
+ const started = Date.now();
1703
+ const res = executeSkill(dir, args["input"] ?? {}, typeof args["timeout_ms"] === "number" ? args["timeout_ms"] : undefined);
1704
+ // A skill that threw is not a tool that failed: the CALL succeeded and its answer is
1705
+ // "the code errored". Collapsing those loses the distinction a caller needs.
1706
+ return {
1707
+ ok: true, requires_approval: approval,
1708
+ data: { slug: target, ...res, duration_ms: res.duration_ms ?? Date.now() - started },
1709
+ };
1710
+ }
1711
+ case "skill_evolve": {
1712
+ if (!deps.skills)
1713
+ return { ok: false, not_implemented: true, requires_approval: approval, error: "skill_evolve needs a skills map (bootstrap from a genome)" };
1714
+ const target = String(args["slug"] ?? "");
1715
+ const code = args["code"];
1716
+ if (!target || typeof code !== "string" || code.trim() === "") {
1717
+ return { ok: false, requires_approval: approval, error: "skill_evolve requires slug and a non-empty code half" };
1718
+ }
1719
+ const sk = deps.skills.get(target);
1720
+ if (!sk)
1721
+ return { ok: false, requires_approval: approval, error: `unknown skill "${target}"` };
1722
+ const dir = sk["package_dir"];
1723
+ if (!dir || sk["code_hash"] == null) {
1724
+ return { ok: false, requires_approval: approval, error: `skill "${target}" has no code half to evolve` };
1725
+ }
1726
+ if (loadFixtures(dir).length === 0) {
1727
+ return { ok: false, requires_approval: approval, error: `skill "${target}" has no fixtures, so there is nothing to hold a candidate to — add fixtures before evolving it` };
1728
+ }
1729
+ // The candidate runs against the CURRENT fixtures in a throwaway copy. Nothing is
1730
+ // written unless it passes, which is the whole point: a skill cannot regress through
1731
+ // this door. `evolveSkill` has implemented exactly this since before the open-source
1732
+ // split and had no caller.
1733
+ const tmpCode = join(mkdtempSync(join(tmpdir(), "coltrane-candidate-")), "skill.mjs");
1734
+ let verdict;
1735
+ try {
1736
+ writeFileSync(tmpCode, code, "utf8");
1737
+ verdict = evolveSkill(dir, tmpCode);
1738
+ }
1739
+ catch (e) {
1740
+ return { ok: false, requires_approval: approval, error: `could not evaluate the candidate: ${e instanceof Error ? e.message : String(e)}` };
1741
+ }
1742
+ finally {
1743
+ try {
1744
+ rmSync(dirname(tmpCode), { recursive: true, force: true });
1745
+ }
1746
+ catch { /* best-effort */ }
1747
+ }
1748
+ if (!verdict.accepted) {
1749
+ return {
1750
+ ok: false, requires_approval: approval,
1751
+ error: `candidate for "${target}" is REJECTED — it fails fixture(s) the current code passes: ${verdict.failing_fixtures.join(", ")}`,
1752
+ data: { accepted: false, failing_fixtures: verdict.failing_fixtures },
1753
+ };
1754
+ }
1755
+ // Accepted: land the code and seal the new identity. Version bumps, because the bytes
1756
+ // that run changed — an evolved skill under an unchanged version is the edit-under-a-
1757
+ // stable-slug shape that `producers_sha` exists to catch.
1758
+ const nextVersion = Number(sk["version"] ?? 1) + 1;
1759
+ writeFileSync(join(dir, "skill.mjs"), code, "utf8");
1760
+ const sealed = recordIdentity("skill_evolve", `${target}@v${nextVersion}`, { slug: target, version: nextVersion, code }, deps.ledger, args["reason"] != null ? { reason: args["reason"] } : undefined);
1761
+ sk["version"] = nextVersion;
1762
+ return {
1763
+ ok: true, requires_approval: approval,
1764
+ data: {
1765
+ slug: target, accepted: true, new_version: nextVersion,
1766
+ content_hash: sealed.content_hash, effective_hash: sealed.effective_hash,
1767
+ note: "the code half changed; re-promote to carry the new version to active",
1768
+ },
1769
+ };
1770
+ }
1284
1771
  case "agent_promote":
1285
1772
  case "standard_promote":
1286
1773
  case "skill_promote": {
@@ -1299,6 +1786,9 @@ async function runImpl(slug, args, deps, approval) {
1299
1786
  const targetSlug = String(args["slug"] ?? "");
1300
1787
  const target = String(args["status"] ?? "");
1301
1788
  const current = args["current"] != null ? String(args["current"]) : null;
1789
+ // Carried into the ledger row: a promotion that passed a fixture gate should record the
1790
+ // evidence it passed on, or the audit trail says only that someone asked.
1791
+ let fixtureReport;
1302
1792
  if (!targetSlug || !target) {
1303
1793
  return { ok: false, requires_approval: approval, error: "missing slug or status" };
1304
1794
  }
@@ -1364,16 +1854,75 @@ async function runImpl(slug, args, deps, approval) {
1364
1854
  error: `${slug}: skill "${targetSlug}" does not pass validation and must not become "${target}" — ${why}`,
1365
1855
  };
1366
1856
  }
1857
+ // ── THE FIXTURE GATE ────────────────────────────────────────────────────────────
1858
+ // Promotion to `active` is the moment a definition acquires production status. For a
1859
+ // skill with a CODE half that has to mean its code demonstrably works, not that its
1860
+ // metadata parses — schema validity says nothing about behaviour.
1861
+ //
1862
+ // Restored from the pre-open-source engine, which enforced exactly this at
1863
+ // skill_evolve and skill_promote and refused the write on failure. The runner has been
1864
+ // here the whole time (`runSkillFixtures`) with no caller outside tests: a real gate
1865
+ // with nothing invoking it, the same shape as the capability gate this release closed.
1866
+ //
1867
+ // The threshold keys off MEASURED determinism, not the declared `determinism_ratio`:
1868
+ // a skill whose runs agree is held to every fixture passing; one that varies is held
1869
+ // to a supermajority. Claiming determinism therefore costs something, which is what
1870
+ // stops the claim being free.
1871
+ const gated = target === "active";
1872
+ const pkgDir = sk.package_dir;
1873
+ const hasCode = sk.code_hash != null;
1874
+ if (gated && hasCode && pkgDir) {
1875
+ let report;
1876
+ try {
1877
+ report = runSkillFixtures(pkgDir);
1878
+ }
1879
+ catch (e) {
1880
+ return {
1881
+ ok: false, requires_approval: approval,
1882
+ error: `${slug}: could not run "${targetSlug}"'s fixtures, so it must not become "${target}" — ${e instanceof Error ? e.message : String(e)}`,
1883
+ };
1884
+ }
1885
+ // No fixtures is not a pass. A code skill nobody can test is precisely the thing
1886
+ // that must not carry production status, and silently allowing it would make this
1887
+ // gate opt-out by omission.
1888
+ if (report.total === 0) {
1889
+ return {
1890
+ ok: false, requires_approval: approval,
1891
+ error: `${slug}: skill "${targetSlug}" ships executable code and no fixtures, so nothing establishes that it works — add fixtures before promoting it to "${target}"`,
1892
+ data: { fixture_report: report },
1893
+ };
1894
+ }
1895
+ const threshold = report.deterministic ? 1.0 : 0.8;
1896
+ if (report.pass_rate < threshold) {
1897
+ const failing = report.results.filter((r) => !r.passed).map((r) => r.id);
1898
+ return {
1899
+ ok: false, requires_approval: approval,
1900
+ error: `${slug}: skill "${targetSlug}" passed ${report.passed}/${report.total} fixtures ` +
1901
+ `(${(report.pass_rate * 100).toFixed(0)}%), below the ${(threshold * 100).toFixed(0)}% required of a ` +
1902
+ `${report.deterministic ? "deterministic" : "non-deterministic"} skill — failing: ${failing.join(", ")}`,
1903
+ data: { fixture_report: report },
1904
+ };
1905
+ }
1906
+ fixtureReport = report;
1907
+ }
1367
1908
  }
1368
1909
  const promotion_id = randomUUID();
1369
1910
  // v1 recorded neither WHICH entity was promoted nor the transition — standard_slug held
1370
1911
  // the TOOL name. A lifecycle transition is exactly the event an audit trail exists for.
1371
1912
  deps.ledger.append(governanceRow(slug, targetSlug, {
1372
1913
  promotion_id, from_status: current, to_status: target,
1914
+ // The evidence the promotion rested on. A gate that passes and records nothing leaves
1915
+ // the audit trail saying only that someone asked, not what was true when they did.
1916
+ ...(fixtureReport
1917
+ ? { fixtures: { total: fixtureReport.total, passed: fixtureReport.passed, pass_rate: fixtureReport.pass_rate, deterministic: fixtureReport.deterministic } }
1918
+ : {}),
1373
1919
  }));
1374
1920
  return {
1375
1921
  ok: true, requires_approval: approval,
1376
- data: { slug: targetSlug, status: target, promoted: true, promotion_id },
1922
+ data: {
1923
+ slug: targetSlug, status: target, promoted: true, promotion_id,
1924
+ ...(fixtureReport ? { fixture_report: fixtureReport } : {}),
1925
+ },
1377
1926
  };
1378
1927
  }
1379
1928
  case "session_review_write": {
@@ -1391,11 +1940,150 @@ async function runImpl(slug, args, deps, approval) {
1391
1940
  // agent_slug / output_id / quality_scores were validated above and then thrown away,
1392
1941
  // because v1 LedgerEntry had nowhere to put them. That discard is the root cause of the
1393
1942
  // cross-agent evidence bug in learning_synthesize (#215).
1943
+ // #234 — `agent_version`, `domain` and `notes` were advertised and dropped on the
1944
+ // floor. `notes` is the reviewer's actual reasoning; discarding it while recording the
1945
+ // scores keeps the number and loses the why, which is the half a later evolution
1946
+ // decision needs. Recorded as null when absent rather than omitted, so a review with no
1947
+ // note is distinguishable from one written before the field was kept.
1394
1948
  deps.ledger.append(governanceRow("session_review_write", agent_slug, {
1395
1949
  review_id, output_id, quality_scores,
1950
+ agent_version: args["agent_version"] ?? null,
1951
+ domain: args["domain"] ?? null,
1952
+ notes: args["notes"] ?? null,
1396
1953
  }, gig_id));
1397
1954
  return { ok: true, requires_approval: approval, data: { review_id, recorded: true, agent_slug, gig_id } };
1398
1955
  }
1956
+ // ── improvement, as a measurement rather than a count ────────────────────────────
1957
+ // `learning_synthesize` answers "is there enough evidence to act?" — a count. It cannot
1958
+ // answer the question the whole typed-and-sealed design exists to make answerable: did
1959
+ // this producer get BETTER, and what did that cost?
1960
+ //
1961
+ // Every input was already sealed and nothing joined them. Outputs carry `agent_slug`,
1962
+ // `cost_usd` and `created_at`; reviews carry `quality_scores` against a specific
1963
+ // `output_id` and `agent_version`; `agent_evolve` rows carry the version boundaries. The
1964
+ // join is arithmetic over records this engine already writes — no new instrumentation,
1965
+ // which is precisely why a consumer cannot compute this for themselves from a bill.
1966
+ case "improvement_report": {
1967
+ const subject = String(args["agent_slug"] ?? "");
1968
+ if (!subject)
1969
+ return { ok: false, requires_approval: approval, error: "improvement_report requires agent_slug" };
1970
+ const win = parseWindow(args["window"], Date.now());
1971
+ if (win.error)
1972
+ return { ok: false, requires_approval: approval, error: win.error };
1973
+ const outs = deps.outputs.all().filter((o) => o.agent_slug === subject && (!win.after || o.created_at >= win.after));
1974
+ const reviews = deps.ledger.query({
1975
+ kind: "governance", event: "session_review_write", subject_slug: subject,
1976
+ ...(win.after ? { after: win.after } : {}),
1977
+ });
1978
+ // A review names the output it judged, so quality attaches to a specific sealed record
1979
+ // rather than to a time bucket. That is what makes the cost and the score describe the
1980
+ // same unit of work.
1981
+ const scoreOf = (d) => {
1982
+ const qs = d?.["quality_scores"];
1983
+ if (!qs || typeof qs !== "object")
1984
+ return null;
1985
+ const nums = Object.values(qs).filter((v) => typeof v === "number" && Number.isFinite(v));
1986
+ return nums.length ? nums.reduce((a, b) => a + b, 0) / nums.length : null;
1987
+ };
1988
+ const reviewByOutput = new Map();
1989
+ for (const r of reviews) {
1990
+ const oid = String(r.detail?.["output_id"] ?? "");
1991
+ if (!oid)
1992
+ continue;
1993
+ const v = r.detail?.["agent_version"];
1994
+ reviewByOutput.set(oid, { score: scoreOf(r.detail), version: typeof v === "number" ? v : null });
1995
+ }
1996
+ const buckets = new Map();
1997
+ const keyOf = (v) => (v === null ? "unversioned" : String(v));
1998
+ for (const o of outs) {
1999
+ const rev = reviewByOutput.get(o.id);
2000
+ const k = keyOf(rev?.version ?? null);
2001
+ const b = buckets.get(k) ?? { version: rev?.version ?? null, outputs: 0, reviewed: 0, cost: [], scores: [] };
2002
+ b.outputs += 1;
2003
+ if (typeof o.cost_usd === "number" && Number.isFinite(o.cost_usd))
2004
+ b.cost.push(o.cost_usd);
2005
+ if (rev && rev.score !== null) {
2006
+ b.reviewed += 1;
2007
+ b.scores.push(rev.score);
2008
+ }
2009
+ buckets.set(k, b);
2010
+ }
2011
+ const mean = (xs) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
2012
+ const versions = [...buckets.values()]
2013
+ .sort((a, b) => (a.version ?? -1) - (b.version ?? -1))
2014
+ .map((b) => ({
2015
+ version: b.version,
2016
+ outputs: b.outputs,
2017
+ reviewed: b.reviewed,
2018
+ // NULL, not 0, when nothing was measured. A zero here would read as "free" and
2019
+ // "worthless" respectively, which is the exact class of fabricated number this
2020
+ // engine spent a release removing.
2021
+ mean_cost_usd: mean(b.cost),
2022
+ mean_quality: mean(b.scores),
2023
+ cost_basis: b.cost.length === b.outputs ? "complete"
2024
+ : b.cost.length === 0 ? "no output carried a cost"
2025
+ : `partial: ${b.cost.length} of ${b.outputs} outputs carried a cost`,
2026
+ quality_basis: b.reviewed === 0 ? "no output was reviewed"
2027
+ : `${b.reviewed} of ${b.outputs} outputs reviewed`,
2028
+ }));
2029
+ // The comparison, only where both ends are measured. A delta against an unmeasured
2030
+ // version would be a number with nothing behind it.
2031
+ const deltas = [];
2032
+ for (let i = 1; i < versions.length; i++) {
2033
+ const prev = versions[i - 1], cur = versions[i];
2034
+ if (prev.version === null || cur.version === null)
2035
+ continue;
2036
+ deltas.push({
2037
+ from_version: prev.version, to_version: cur.version,
2038
+ quality_delta: prev.mean_quality !== null && cur.mean_quality !== null ? cur.mean_quality - prev.mean_quality : null,
2039
+ cost_delta_usd: prev.mean_cost_usd !== null && cur.mean_cost_usd !== null ? cur.mean_cost_usd - prev.mean_cost_usd : null,
2040
+ // The sentence a person acts on. Only stated when BOTH ends are measured.
2041
+ verdict: prev.mean_quality !== null && cur.mean_quality !== null && prev.mean_cost_usd !== null && cur.mean_cost_usd !== null
2042
+ ? (cur.mean_quality >= prev.mean_quality && cur.mean_cost_usd <= prev.mean_cost_usd ? "better and cheaper"
2043
+ : cur.mean_quality > prev.mean_quality ? "better, and more expensive"
2044
+ : cur.mean_cost_usd < prev.mean_cost_usd ? "cheaper, and worse"
2045
+ : "worse and more expensive")
2046
+ : null,
2047
+ });
2048
+ }
2049
+ const tierBuckets = new Map();
2050
+ for (const o of outs) {
2051
+ const k = o.model_tier ?? o.model ?? "unrecorded";
2052
+ const b = tierBuckets.get(k) ?? { outputs: 0, reviewed: 0, cost: [], scores: [] };
2053
+ b.outputs += 1;
2054
+ if (typeof o.cost_usd === "number" && Number.isFinite(o.cost_usd))
2055
+ b.cost.push(o.cost_usd);
2056
+ const rev = reviewByOutput.get(o.id);
2057
+ if (rev && rev.score !== null) {
2058
+ b.reviewed += 1;
2059
+ b.scores.push(rev.score);
2060
+ }
2061
+ tierBuckets.set(k, b);
2062
+ }
2063
+ const tiers = [...tierBuckets.entries()].map(([tier, b]) => ({
2064
+ tier,
2065
+ outputs: b.outputs,
2066
+ reviewed: b.reviewed,
2067
+ mean_cost_usd: mean(b.cost),
2068
+ mean_quality: mean(b.scores),
2069
+ })).sort((a2, b2) => (a2.tier < b2.tier ? -1 : 1));
2070
+ const measurable = versions.filter((v) => v.version !== null && v.mean_quality !== null).length;
2071
+ return {
2072
+ ok: true, requires_approval: approval,
2073
+ data: {
2074
+ agent_slug: subject,
2075
+ ...(win.after ? { since: win.after } : {}),
2076
+ total_outputs: outs.length,
2077
+ versions, deltas, tiers,
2078
+ // Said plainly, because a report that cannot answer its own question should say so
2079
+ // rather than return empty arrays that read as "no change".
2080
+ comparable: measurable >= 2,
2081
+ basis: measurable >= 2
2082
+ ? `${measurable} versions carry both cost and quality`
2083
+ : "not comparable yet — a version-to-version delta needs reviews recorded against outputs from at least two versions (session_review_write with agent_version)",
2084
+ },
2085
+ };
2086
+ }
1399
2087
  case "learning_synthesize": {
1400
2088
  // §11 learning loop, half 2: aggregate session reviews into evolution evidence
1401
2089
  // for one agent. Returns evidence_sufficient=true only when review count meets
@@ -1411,8 +2099,22 @@ async function runImpl(slug, args, deps, approval) {
1411
2099
  // echoed agent_slug back, so five reviews of five different agents opened the
1412
2100
  // evolution gate for a sixth with none (#215). The typed discriminators replace a
1413
2101
  // load-bearing String.startsWith on a synthetic gig_id.
2102
+ // #234 — `since` was advertised and ignored, so "has this agent earned an evolution on
2103
+ // RECENT evidence?" was always answered over its entire history. That is the wrong
2104
+ // answer in the direction that matters: five poor reviews from a year ago kept counting
2105
+ // toward a gate that exists to act on how the agent behaves now.
2106
+ const sinceRaw = args["since"];
2107
+ let since;
2108
+ if (sinceRaw !== undefined && sinceRaw !== null && sinceRaw !== "") {
2109
+ const parsed = new Date(String(sinceRaw));
2110
+ if (Number.isNaN(parsed.getTime())) {
2111
+ return { ok: false, requires_approval: approval, error: `unparseable since "${String(sinceRaw)}" — use an ISO timestamp` };
2112
+ }
2113
+ since = parsed.toISOString();
2114
+ }
1414
2115
  const reviews = deps.ledger.query({
1415
2116
  kind: "governance", event: "session_review_write", subject_slug: agent_slug,
2117
+ ...(since ? { after: since } : {}),
1416
2118
  });
1417
2119
  const review_count = reviews.length;
1418
2120
  const evidence_sufficient = review_count >= min_reviews;
@@ -1569,6 +2271,9 @@ export function bootstrapServerDeps(genomeRoot) {
1569
2271
  provenance: genome.provenance, // ← genome extension — which layer supplied each def
1570
2272
  gig_runs: new Map(), // ← async dispatch — live gig state gig_monitor reads
1571
2273
  gig_log_base: defaultOutputsPersistDir(), // ← per-gig agent logs at <base>/gigs/<id>/<role>.jsonl
2274
+ // Checkpoints + the reuse cache live alongside outputs/ and refs/ under the same root.
2275
+ checkpoints: createCheckpointStore(defaultOutputsPersistDir()),
2276
+ reuse: createReuseStore(defaultOutputsPersistDir()),
1572
2277
  };
1573
2278
  }
1574
2279
  const SHUTDOWN_SIGNALS = ["SIGTERM", "SIGINT"];