@eir-labs/coltrane 0.4.0 → 0.5.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/dist/src/access_grant.d.ts +21 -0
- package/dist/src/access_grant.js +33 -4
- package/dist/src/access_grant.js.map +1 -1
- package/dist/src/claude_invoker.d.ts +28 -0
- package/dist/src/claude_invoker.js +68 -7
- package/dist/src/claude_invoker.js.map +1 -1
- package/dist/src/cli.d.ts +44 -0
- package/dist/src/cli.js +358 -0
- package/dist/src/cli.js.map +1 -0
- package/dist/src/cli_entry.d.ts +2 -0
- package/dist/src/cli_entry.js +23 -0
- package/dist/src/cli_entry.js.map +1 -0
- package/dist/src/composition.d.ts +2 -0
- package/dist/src/composition.js.map +1 -1
- package/dist/src/fs_atomic.d.ts +1 -0
- package/dist/src/fs_atomic.js +34 -0
- package/dist/src/fs_atomic.js.map +1 -0
- package/dist/src/genome_schema.d.ts +5 -1
- package/dist/src/genome_schema.js +12 -1
- package/dist/src/genome_schema.js.map +1 -1
- package/dist/src/genome_writer.d.ts +2 -2
- package/dist/src/genome_writer.js +17 -4
- package/dist/src/genome_writer.js.map +1 -1
- package/dist/src/gig_tracker.d.ts +29 -1
- package/dist/src/gig_tracker.js +27 -0
- package/dist/src/gig_tracker.js.map +1 -1
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/ledger.d.ts +3 -0
- package/dist/src/ledger.js.map +1 -1
- package/dist/src/loader.d.ts +2 -0
- package/dist/src/loader.js +10 -0
- package/dist/src/loader.js.map +1 -1
- package/dist/src/mcp.js +82 -20
- package/dist/src/mcp.js.map +1 -1
- package/dist/src/outputs.d.ts +74 -0
- package/dist/src/outputs.js +106 -71
- package/dist/src/outputs.js.map +1 -1
- package/dist/src/registry.d.ts +13 -0
- package/dist/src/registry.js +112 -0
- package/dist/src/registry.js.map +1 -1
- package/dist/src/reuse.d.ts +276 -0
- package/dist/src/reuse.js +215 -0
- package/dist/src/reuse.js.map +1 -0
- package/dist/src/runtime.d.ts +157 -4
- package/dist/src/runtime.js +519 -30
- package/dist/src/runtime.js.map +1 -1
- package/dist/src/server.d.ts +20 -0
- package/dist/src/server.js +733 -28
- package/dist/src/server.js.map +1 -1
- package/dist/src/skill_runner.mjs +8 -2
- package/dist/src/skill_subprocess.d.ts +55 -5
- package/dist/src/skill_subprocess.js +215 -11
- package/dist/src/skill_subprocess.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +6 -4
package/dist/src/server.js
CHANGED
|
@@ -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
|
-
|
|
317
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
968
|
-
|
|
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 =
|
|
971
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
|
|
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
|
|
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: {
|
|
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"];
|