@agenticprimitives/devkit 0.0.0-alpha.6 → 0.0.0-alpha.8

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/bin/ap.mjs CHANGED
@@ -6,10 +6,32 @@
6
6
  // ap upgrade --canary | --pin <version|tag> [--dry-run] [--no-install] [--accept-definitions] [--json]
7
7
  // ap upgrade --pin-definitions <registry> <ctx>/<archetype>[,…] pin the contracts the product relies on (398 §10.3)
8
8
  // ap conform a2a|mcp <url> [--bearer <token>] [--timeout <ms>] [--json]
9
+ // ap conform provenance file:<dir>|memory: [--json] (spec 414 §6 — a provenance store binding, C1–C6)
9
10
  // ap test --live-gates <ledger.json> [--only a,b] [--json]
10
11
  // ap create <dir> --template product-repo [--name <n>] [--pin <version>] [--json]
11
12
  // ap mcp [--root <dir>] the read-only Developer MCP on stdio (spec 398 §10.4)
12
13
  // ap runtime join|run|mcp|status … an outside ACP runtime as a workspace member (spec 400 W1)
14
+ // ap eval <cases.eval.json> --home <url> [--persona <handle> [--client <id>] | --session <bearer>] [--report out.json] [--json]
15
+ // cases as data against a live Home; deterministic judge; exit 1 on a failing case
16
+ // ap eval --from-runs --home <url> --persona <handle> [--addressee <name|0x>] [--out cases.eval.json] a dataset from the agent's live runs
17
+ // ap eval window open --store <dir> --home <url> --roster <handle,…> --level L0|L1|L2 [--hours 24] [--purpose …] (spec 414 §8) open a capture window
18
+ // ap eval window close --store <dir> close it: the salt is purged, nothing exported can be re-linked
19
+ // ap eval rescore --store <dir> --plan-id <id> --replay <set.json> --criterion <gold.json> score a stored experiment again (the runs are not re-run)
20
+ // ap eval report --store <dir> --sets <dir> --out <report.json> [--definition <url|file>] (spec 415 §5b; --definition flags test sentences the skills carry as examples) what was tested, whether each intent got the right skill,
21
+ // per-skill results, comparisons in words, and recommendations — what the Lab and skill-web render
22
+ // ap eval gates --store <dir> [--plan-id <id>,… | --all] --sets <dir> --definition <url|file> [--gate rbox|calibration|consistency|recall|behaviour|conformal --map <map.json> [--variant v] | outcome --outcome-gold <file>]
23
+ // ap eval scorecard --store <dir> --sets <dir> --plan-id a,b [--out f.json] (spec 416 §4g) the selection scorecard per experiment × variant
24
+ // ap eval calibrate --store <dir> --plan-id a,b --variant v --sets <dir> --definition <url|file> --alpha 0.05 --out <map.json> (spec 416 W3) fit a calibration map on labelled runs (spec 415 A4)
25
+ // rbox agreement (the exact half re-derives) + judgment calibration (the probabilistic half); exit 1 on FAIL
26
+ // ap eval diff --store <dir> --before <plan-id> --after <plan-id> --replay <set.json> --criterion <gold.json> [--variant model | --variant-before a --variant-after b] [--repaired-pair a,b]
27
+ // two stored experiments on the same set: McNemar on the paired cases, Δ macro with its CI, collateral
28
+ // ap eval compare --replay <set.json> --criterion <gold.json> --variants <name|file,…> --store <dir> --home <url> [--fixtures <starting-states.json>]
29
+ // --persona <handle> --addressee <name|0x> [--split held-out|development|all] [--repeat n] [--skill-map <map.json>]
30
+ // [--plan-id <id>] [--policy primary|any-fire] [--json] (spec 415 A4) a pre-registered comparison, judged from the traces
31
+ // [--keep-text <dir>] keep every case's REPLY and ACTIVITY TREE under <dir>/<plan-id>/ — words included, OUTSIDE the
32
+ // store (which never holds them); only when the open window admits the addressee (a sandbox org)
33
+ // ap generate client|cli|parity --registry <url> --definition <ctx>/<archetype> | --file <definition.json> [--out <path>] [--only a,b]
34
+ // the typed client / CLI schema / parity cases FROM the compiled contract (spec 398 §7.2)
13
35
  //
14
36
  // Exit: 0 clean · 1 findings · 2 usage/config · 3 a rule or dispatch errored.
15
37
  import { spawnSync } from 'node:child_process';
@@ -18,7 +40,7 @@ import { resolve } from 'node:path';
18
40
  import {
19
41
  DevkitError, EXIT, RULE_IDS, runDoctor, formatDoctorReport, loadDoctorConfig, loadRulesCorpus, projectRules, rulesLockEntries,
20
42
  readLock, writeLock, planUpgrade, applyUpgradePlan, formatUpgradePlan, loadConform, loadLiveGatesRunner, loadCreateApp, loadRuntimeCli, formatConformanceReport,
21
- runDeveloperMcpStdio, reviewDefinitions, pinDefinition, definitionKey, formatAuthorityChanges,
43
+ runDeveloperMcpStdio, reviewDefinitions, pinDefinition, definitionKey, formatAuthorityChanges, loadHarnessProjections, fetchDefinitionFromRegistry, loadEval, loadCompare, loadEvalStore, loadDecideFromGrounding, loadProposeFromGrounding, loadHarnessCalibration,
22
44
  } from '../dist/index.js';
23
45
 
24
46
  const argv = process.argv.slice(2);
@@ -39,7 +61,7 @@ const fail = (e) => {
39
61
  process.exit(code === 'E_USAGE' || code === 'E_CONFIG' || code === 'E_RULE_UNKNOWN' ? EXIT.USAGE : EXIT.ERROR);
40
62
  };
41
63
 
42
- const USAGE = `ap <doctor|upgrade|conform|test|create|mcp|runtime> … (see the header of bin/ap.mjs). Rules: ${RULE_IDS.join(', ')}`;
64
+ const USAGE = `ap <doctor|upgrade|conform|test|create|mcp|runtime|generate|eval> … (see the header of bin/ap.mjs). Rules: ${RULE_IDS.join(', ')}`;
43
65
 
44
66
  async function doctor() {
45
67
  if (flag('rules')) {
@@ -132,7 +154,7 @@ async function upgrade() {
132
154
 
133
155
  async function conform() {
134
156
  const [kind, url] = positional();
135
- if (!['a2a', 'mcp'].includes(kind) || !url) throw new DevkitError('E_USAGE', 'ap conform a2a|mcp <url>');
157
+ if (!['a2a', 'mcp', 'provenance'].includes(kind) || !url) throw new DevkitError('E_USAGE', 'ap conform a2a|mcp <url> | ap conform provenance file:<dir>|memory:');
136
158
  const fn = await loadConform(kind);
137
159
  const report = await fn(url, { bearer: opt('bearer') ?? process.env.AP_CONFORM_BEARER, timeoutMs: opt('timeout') ? Number(opt('timeout')) : undefined });
138
160
  out({ ok: report.ok, report }, formatConformanceReport(report));
@@ -170,7 +192,562 @@ async function runtime() {
170
192
  process.exit(await fn(process.argv.slice(3)));
171
193
  }
172
194
 
173
- const commands = { doctor, upgrade, conform, test, create, mcp, runtime };
195
+ // Spec 398 §7.2 — the fourth and fifth surfaces of ONE contract: a typed client operation, a CLI argument schema, and
196
+ // the parity cases — projected from a compiled definition (the registry's, or a file), never authored beside it.
197
+ async function generate() {
198
+ const [what] = positional();
199
+ if (!what || !['client', 'cli', 'parity'].includes(what)) throw new DevkitError('E_USAGE', 'ap generate client|cli|parity --registry <url> --definition <ctx>/<archetype> | --file <definition.json> [--out <path>] [--only a,b]');
200
+ let definition;
201
+ if (opt('file')) {
202
+ const raw = JSON.parse(readFileSync(resolve(root, opt('file')), 'utf8'));
203
+ definition = raw.definition ?? raw;
204
+ } else {
205
+ const registry = opt('registry'); const def = opt('definition');
206
+ if (!registry || !def || !def.includes('/')) throw new DevkitError('E_USAGE', '--registry <url> and --definition <context>/<archetype> (or --file <definition.json>) are required');
207
+ const [context, archetype] = def.split('/');
208
+ const r = await fetch(`${registry.replace(/\/$/, '')}/context/contexts/${encodeURIComponent(context)}/archetypes/${encodeURIComponent(archetype)}/definition`, { headers: { accept: 'application/json', 'user-agent': 'ap-devkit' } });
209
+ if (!r.ok) throw new DevkitError('E_INPUT_MISSING', `${registry}: ${def} answered ${r.status}`);
210
+ const b = await r.json();
211
+ if (!b.definition?.tools) throw new DevkitError('E_INPUT_MISSING', `${registry}: ${def} served no definition`);
212
+ definition = b.definition;
213
+ }
214
+ const only = opt('only') ? new Set(opt('only').split(',').map((s) => s.trim()).filter(Boolean)) : undefined;
215
+ const p = await loadHarnessProjections();
216
+ let text; let obj;
217
+ if (what === 'client') { const c = p.generateCapabilityClient(definition, only ? { only } : {}); text = c.source; obj = { ok: true, operations: c.operations, source: c.source }; }
218
+ else if (what === 'cli') { const tools = (definition.tools ?? []).filter((t) => !only || only.has(t.capability?.id ?? t.id)); obj = { ok: true, schemas: tools.map((t) => p.cliArgumentSchema(t)) }; text = JSON.stringify(obj.schemas, null, 2); }
219
+ else { const cases = p.generateParityCases(definition, only ? { only } : {}); obj = { ok: true, cases }; text = JSON.stringify(cases, null, 2); }
220
+ if (opt('out')) { const { writeFileSync, mkdirSync } = await import('node:fs'); const { dirname } = await import('node:path'); const outPath = resolve(root, opt('out')); mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, text.endsWith('\n') ? text : `${text}\n`); out({ ...obj, out: outPath }, `ap generate ${what} → ${outPath}`); }
221
+ else out(obj, text);
222
+ }
223
+
224
+ // Spec 398 §10 / P1.3 — EVALS AS A PRODUCT. Cases are data; the ask goes to a live Home as a persona (a development
225
+ // estate's demo-signin) or a session bearer the developer holds; the judge is deterministic. An eval never signs: an
226
+ // act's right outcome is `authority_required`, asserted as a check.
227
+ async function evalCmd() {
228
+ const sub = positional()[0];
229
+ if (sub === 'window') return evalWindow();
230
+ if (sub === 'compare') return evalCompare();
231
+ if (sub === 'rescore') return evalRescore();
232
+ if (sub === 'diff') return evalDiff();
233
+ if (sub === 'report') return evalReport();
234
+ if (sub === 'gates') return evalGates();
235
+ if (sub === 'calibrate') return evalCalibrate();
236
+ if (sub === 'scorecard') return evalScorecard();
237
+ if (sub === 'ledger') return evalLedger();
238
+ const home = (opt('home') ?? process.env.HOME_URL ?? '').replace(/\/$/, '');
239
+ if (!home) throw new DevkitError('E_USAGE', '--home <url> (or HOME_URL) is required');
240
+ const ev = await loadEval();
241
+ const j = async (r) => { const t = await r.text(); try { return JSON.parse(t); } catch { return { _raw: t.slice(0, 200), _status: r.status }; } };
242
+ const sessions = new Map();
243
+ const signin = async (handle) => {
244
+ if (sessions.has(handle)) return sessions.get(handle);
245
+ const s = await j(await fetch(`${home}/connect/demo-signin`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ handle, client_id: opt('client') ?? 'demo-jp' }) }));
246
+ if (!s.homeSession) throw new DevkitError('E_INPUT_MISSING', `${home} did not sign ${handle} in (a persona path exists on development estates only): ${JSON.stringify(s).slice(0, 160)}`);
247
+ const v = { token: String(s.homeSession), agent: String(s.agent).toLowerCase() }; sessions.set(handle, v); return v;
248
+ };
249
+ const sessionOf = async (persona) => (opt('session') ? { token: opt('session'), agent: '' } : signin(persona ?? opt('persona') ?? 'alice'));
250
+ const csrfRes = await fetch(`${home}/a2a/auth/csrf`, { headers: { origin: home } });
251
+ const csrf = await j(csrfRes);
252
+ const H = { 'content-type': 'application/json', origin: home, cookie: (csrfRes.headers.get('set-cookie') ?? '').split(';')[0] ?? '', 'x-csrf-token': csrf.token ?? '' };
253
+ const resolve = async (name, fallback) => {
254
+ if (!name) return fallback; if (/^0x[0-9a-fA-F]{40}$/.test(name)) return name.toLowerCase();
255
+ const r = await j(await fetch(`${home}/connect/name-info?name=${encodeURIComponent(name)}`)); if (!r.agent) throw new DevkitError('E_INPUT_MISSING', `${name} does not resolve at ${home}`); return String(r.agent).toLowerCase();
256
+ };
257
+ if (flag('from-runs')) {
258
+ const s = await sessionOf(opt('persona'));
259
+ const addressee = await resolve(opt('addressee'), s.agent);
260
+ const out = await j(await fetch(`${home}/a2a/harness/records`, { method: 'POST', headers: H, body: JSON.stringify({ session: s.token, addressee }) }));
261
+ const file = ev.casesFromRunRecords(out.records ?? [], { persona: opt('persona') ?? 'alice', addressee: opt('addressee') });
262
+ const text = JSON.stringify(file, null, 2);
263
+ if (opt('out')) { const { writeFileSync } = await import('node:fs'); writeFileSync(resolve_(opt('out')), `${text}\n`); out_({ ok: true, cases: file.cases.length, out: resolve_(opt('out')) }, `ap eval --from-runs → ${file.cases.length} case skeleton(s) in ${opt('out')} — edit the checks to what must hold`); }
264
+ else console.log(text);
265
+ return;
266
+ }
267
+ const [path] = positional();
268
+ if (!path) throw new DevkitError('E_USAGE', 'ap eval <cases.eval.json> --home <url> [--persona <handle> | --session <bearer>] [--report out.json]');
269
+ const file = ev.parseEvalFile(JSON.parse(readFileSync(resolve_(path), 'utf8')));
270
+ const report = await ev.runEvalFile(file, async (c) => {
271
+ const s = await sessionOf(c.persona);
272
+ const addressee = await resolve(c.addressee, s.agent);
273
+ const r = await j(await fetch(`${home}/a2a/harness/ask`, { method: 'POST', headers: H, body: JSON.stringify({ session: s.token, addressee, message: c.message, ...(c.plan ? { plan: c.plan } : {}), runRef: `eval-${Date.now().toString(36)}` }) }));
274
+ return ev.outcomeOfReply(r.reply ?? r);
275
+ });
276
+ if (opt('report')) { const { writeFileSync } = await import('node:fs'); writeFileSync(resolve_(opt('report')), `${JSON.stringify({ file: file.name ?? path, home, at: new Date().toISOString(), ...report }, null, 2)}\n`); }
277
+ out_({ ok: report.failed === 0, passed: report.passed, failed: report.failed, results: report.results.map((r) => ({ id: r.id, passed: r.passed, failures: r.failures, ms: r.ms, kind: r.outcome?.kind })) }, `ap eval · ${file.name ?? path} · ${home}\n${ev.formatTruthReport(report)}`);
278
+ process.exit(report.failed ? EXIT.FINDINGS : EXIT.OK);
279
+ }
280
+ const resolve_ = (p) => resolve(root, p);
281
+ const out_ = out;
282
+
283
+ // ── Spec 414 §8 / 415 A4 — THE ESTATE, AS A COMPARISON RUNNER SEES IT: the persona sign-in, the CSRF pair and the three
284
+ // reads (ask · provenance · measures), shared by `compare` and `window`. Names resolve to addresses at the Home; the
285
+ // runner holds no key and signs nothing — a run that needs authority parks at the Home like any other.
286
+ async function estateClient(home) {
287
+ const j = async (r) => { const t = await r.text(); try { return JSON.parse(t); } catch { return { _raw: t.slice(0, 200), _status: r.status }; } };
288
+ const sessions = new Map();
289
+ const signin = async (handle) => {
290
+ if (sessions.has(handle)) return sessions.get(handle);
291
+ const s = await j(await fetch(`${home}/connect/demo-signin`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ handle, client_id: opt('client') ?? 'demo-jp' }) }));
292
+ if (!s.homeSession) throw new DevkitError('E_INPUT_MISSING', `${home} did not sign ${handle} in (a persona path exists on development estates only): ${JSON.stringify(s).slice(0, 160)}`);
293
+ const v = { token: String(s.homeSession), agent: String(s.agent).toLowerCase() }; sessions.set(handle, v); return v;
294
+ };
295
+ // The CSRF pair is cookie-bound and EXPIRES: a comparison longer than its lifetime once recorded 572 runs refused
296
+ // with "csrf invalid". A refusal naming the CSRF fetches a fresh pair and sends the SAME request once more — a
297
+ // bounded retry of the same call, never a different mechanism (ADR-0013).
298
+ const H = { 'content-type': 'application/json', origin: home, cookie: '', 'x-csrf-token': '' };
299
+ const refreshCsrf = async () => {
300
+ const csrfRes = await fetch(`${home}/a2a/auth/csrf`, { headers: { origin: home } });
301
+ const csrf = await j(csrfRes);
302
+ H.cookie = (csrfRes.headers.get('set-cookie') ?? '').split(';')[0] ?? ''; H['x-csrf-token'] = csrf.token ?? '';
303
+ };
304
+ await refreshCsrf();
305
+ const post = async (path, body) => {
306
+ const once = async () => j(await fetch(`${home}/a2a${path}`, { method: 'POST', headers: H, body: JSON.stringify(body) }));
307
+ const r = await once();
308
+ if (r && r.ok !== true && /csrf/i.test(String(r.error ?? r._raw ?? ''))) { await refreshCsrf(); return once(); }
309
+ return r;
310
+ };
311
+ const resolveName = async (name) => {
312
+ if (/^0x[0-9a-fA-F]{40}$/.test(name)) return name.toLowerCase();
313
+ const r = await j(await fetch(`${home}/connect/name-info?name=${encodeURIComponent(name)}`));
314
+ if (r.agent) return String(r.agent).toLowerCase();
315
+ // a persona handle: its own agent
316
+ try { return (await signin(name)).agent; } catch { throw new DevkitError('E_INPUT_MISSING', `${name} does not resolve at ${home}`); }
317
+ };
318
+ const comparison = await j(await fetch(`${home}/a2a/harness/comparison`).catch(() => new Response('{}')));
319
+ return { j, signin, post, resolveName, evalCapture: comparison?.evalCapture === 'on' ? 'on' : 'off', knob: comparison };
320
+ }
321
+ const windowPaths = (store) => ({ dir: resolve_(store.replace(/^file:/, '')), window: resolve(resolve_(store.replace(/^file:/, '')), 'window.json'), salt: resolve(resolve_(store.replace(/^file:/, '')), 'window.salt') });
322
+
323
+ async function evalWindow() {
324
+ const [, action] = positional();
325
+ const store = opt('store');
326
+ if (!store || !['open', 'close'].includes(action ?? '')) throw new DevkitError('E_USAGE', 'ap eval window open|close --store <dir> …');
327
+ const { mkdirSync, writeFileSync, unlinkSync, readFileSync: rf, existsSync: ex } = await import('node:fs');
328
+ const { randomBytes } = await import('node:crypto');
329
+ const cm = await loadCompare();
330
+ const paths = windowPaths(store);
331
+ if (action === 'close') {
332
+ if (!ex(paths.window)) throw new DevkitError('E_INPUT_MISSING', `no window at ${paths.window}`);
333
+ const w = JSON.parse(rf(paths.window, 'utf8'));
334
+ w.expiresAt = new Date().toISOString();
335
+ writeFileSync(paths.window, `${JSON.stringify(w, null, 2)}\n`);
336
+ if (ex(paths.salt)) unlinkSync(paths.salt);
337
+ out_({ ok: true, closed: w.id, saltPurged: true }, `ap eval window · closed ${w.id} — the salt is purged; nothing exported under it can be re-linked`);
338
+ return;
339
+ }
340
+ const home = (opt('home') ?? process.env.HOME_URL ?? '').replace(/\/$/, '');
341
+ if (!home) throw new DevkitError('E_USAGE', '--home <url> (or HOME_URL) is required');
342
+ const level = opt('level') ?? 'L1';
343
+ const roster = (opt('roster') ?? '').split(',').map((s) => s.trim()).filter(Boolean);
344
+ if (!roster.length) throw new DevkitError('E_USAGE', '--roster <handle|name|0x,…> names the test personas and the sandbox org the window admits');
345
+ const est = await estateClient(home);
346
+ const addresses = [];
347
+ for (const n of roster) addresses.push(await est.resolveName(n));
348
+ const hours = Number(opt('hours') ?? 24);
349
+ const opensAt = new Date();
350
+ const w = { type: 'ap.eval-capture-window.v1', id: `w-${opensAt.toISOString().slice(0, 19).replace(/[-:T]/g, '')}`, store: `file:${paths.dir}`, level, roster: addresses, estate: home, opensAt: opensAt.toISOString(), expiresAt: new Date(opensAt.getTime() + hours * 3600_000).toISOString(), ...(opt('purpose') ? { purpose: opt('purpose') } : {}), ...(flag('synthetic') ? { synthetic: true } : {}) };
351
+ const v = cm.validateCaptureWindow(w);
352
+ if (!v.ok) throw new DevkitError('E_USAGE', `the window is not valid: ${v.errors.join('; ')}`);
353
+ mkdirSync(paths.dir, { recursive: true });
354
+ writeFileSync(paths.window, `${JSON.stringify(w, null, 2)}\n`);
355
+ writeFileSync(paths.salt, randomBytes(32).toString('hex'), { mode: 0o600 });
356
+ const digest = await cm.windowDigest(w);
357
+ out_({ ok: true, window: w, digest, estateCapture: est.evalCapture }, `ap eval window · opened ${w.id} (${digest.slice(0, 18)}…) at ${level} for ${hours} h · roster ${roster.join(', ')} · estate ${home} (EVAL_CAPTURE ${est.evalCapture})${est.evalCapture !== 'on' ? '\n ! this estate does not run comparisons: every export will be refused (estate.capture-off) until EVAL_CAPTURE=on' : ''}`);
358
+ }
359
+
360
+ // Spec 415 A4 — SCORE A STORED EXPERIMENT AGAIN. The runs, the judgments and the export are what they were; only the
361
+ // scoring step re-runs (a scorer fix, a threshold change). A run's evidence KIND is a property of how the trace was
362
+ // read — a stamping harness that selected nothing is a stamped observation of no selection — so a record written before
363
+ // that rule ("none" on a `tn` run) is read under it here, and the record says it was rescored.
364
+ async function evalRescore() {
365
+ const store = opt('store'); const planId = opt('plan-id');
366
+ for (const [k, v] of [['store', store], ['plan-id', planId], ['replay', opt('replay')], ['criterion', opt('criterion')]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval rescore --store <dir> --plan-id <id> --replay <set> --criterion <gold>)`);
367
+ const { readFileSync: rf, writeFileSync } = await import('node:fs');
368
+ const cm = await loadCompare();
369
+ const dir = resolve_(store.replace(/^file:/, ''));
370
+ const path = resolve(dir, 'experiments', `${encodeURIComponent(`experiment:${planId}`)}.jsonld`);
371
+ const doc = JSON.parse(rf(path, 'utf8'));
372
+ const rec = doc.record;
373
+ if (!rec?.plan) throw new DevkitError('E_INPUT_MISSING', `${path} holds no experiment record`);
374
+ const setR = cm.validateReplaySet(JSON.parse(rf(resolve_(opt('replay')), 'utf8')));
375
+ if (!setR.ok) throw new DevkitError('E_USAGE', `the replay set is not valid: ${setR.errors.join('; ')}`);
376
+ const criterion = JSON.parse(rf(resolve_(opt('criterion')), 'utf8'));
377
+ const setDigest = await cm.replaySetDigest(setR.set);
378
+ if (rec.plan.replaySetDigest !== setDigest || rec.plan.criterionDigest !== await cm.criterionDigest(criterion)) throw new DevkitError('E_USAGE', 'the set or the criterion is not the one this experiment pre-registered; a rescore never changes what was judged against');
379
+ const { cases, skillIds } = cm.casesOf(setR.set, criterion, rec.plan.split);
380
+ const runs = rec.runs.map((r) => (r.status === 'ok' && r.evidence === 'none' ? { ...r, evidence: 'stamped' } : r));
381
+ const scored = await cm.scoreExperimentRuns({ plan: rec.plan, setDigest, cases, skillIds, pairs: criterion.collisionPairs ?? [], runs, log: (line) => { if (!json) console.error(` ${line}`); } });
382
+ const next = { ...rec, runs, ...scored, rescoredAt: new Date().toISOString() };
383
+ writeFileSync(path, `${JSON.stringify({ ...doc, record: next }, null, 2)}\n`);
384
+ out_({ ok: true, plan: next.plan, planDigest: next.planDigest, runs: next.runs.length, scores: Object.fromEntries(Object.entries(next.scores).map(([k, v]) => [k, v.aggregate])), perSkill: Object.fromEntries(Object.entries(next.scores).map(([k, v]) => [k, v.perSkill])), verdicts: Object.fromEntries(Object.entries(next.verdicts).map(([k, v]) => [k, { status: v.status, reason: v.binding_reason }])), comparisons: next.comparisons }, `ap eval rescore · ${path}\n${cm.formatExperiment(next)}`);
385
+ }
386
+
387
+ // Spec 415 §5b — THE REPORT: every experiment in the store, read with the replay sets and criteria it pre-registered
388
+ // (found in --sets by digest), folded into one deterministic file. Numbers, ids, verdicts and the sets' AUTHORED test
389
+ // sentences — the eval store holds no person's words, so neither does this.
390
+ async function evalReport() {
391
+ const store = opt('store'); const setsDir = opt('sets'); const outPath = opt('out');
392
+ for (const [k, v] of [['store', store], ['sets', setsDir], ['out', outPath]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval report --store <dir> --sets <dir> --out <report.json>)`);
393
+ const { readFileSync: rf, readdirSync, writeFileSync, mkdirSync } = await import('node:fs');
394
+ const { dirname } = await import('node:path');
395
+ const cm = await loadCompare();
396
+ const dir = resolve_(store.replace(/^file:/, ''));
397
+ const expDir = resolve(dir, 'experiments');
398
+ const experiments = readdirSync(expDir).filter((f) => f.startsWith(encodeURIComponent('experiment:')) && f.endsWith('.jsonld'))
399
+ .map((f) => JSON.parse(rf(resolve(expDir, f), 'utf8')).record).filter((r) => r?.plan);
400
+ const sets = {}; const criteria = {};
401
+ const walk = (d) => { for (const e of readdirSync(d, { withFileTypes: true })) { const p = resolve(d, e.name); if (e.isDirectory()) walk(p); else if (e.name.endsWith('.replay.json') || e.name.endsWith('.gold.json')) { const j = JSON.parse(rf(p, 'utf8')); if (j.type === 'ap.replay-set.v1') sets[p] = j; if (j.type === 'ap.selection-criterion.v1') criteria[j.replaySetDigest] = j; } } };
402
+ walk(resolve_(setsDir));
403
+ const byDigest = {};
404
+ for (const s of Object.values(sets)) byDigest[await cm.replaySetDigest(s)] = s;
405
+ // The examples the skills under test declare (their `utterances`, as the compiled playbook carries them) — so a test
406
+ // sentence that nearly repeats one is flagged as measuring the example, not selection. A URL or a file.
407
+ let examples; let toolToSkillForReport;
408
+ if (opt('definition')) {
409
+ const src = opt('definition');
410
+ const d = /^https?:/.test(src) ? await (await fetch(src, { headers: { 'user-agent': 'Mozilla/5.0 (ap eval report)' } })).json() : JSON.parse(rf(resolve_(src), 'utf8'));
411
+ const tools = (d.definition ?? d).tools ?? [];
412
+ examples = tools.flatMap((t) => (t.utterances ?? []).map((u) => ({ skill: t.source?.skillId ?? t.id, says: u.says, ...(u.isNot ? { isNot: true } : {}) })));
413
+ toolToSkillForReport = Object.fromEntries(tools.filter((t) => t.source?.skillId).map((t) => [t.id, t.source.skillId]));
414
+ }
415
+ // `--alias <experiment>:<variant>=<name>[,…]` — a knob value whose meaning changed after the experiment ran is shown
416
+ // as what actually ran. The record is never edited.
417
+ const aliases = {};
418
+ for (const a of (opt('alias') ?? '').split(',').filter(Boolean)) { const m = /^([^:]+):([^=]+)=(.+)$/.exec(a); if (!m) throw new DevkitError('E_USAGE', `--alias ${a}: expected <experiment>:<variant>=<name>`); (aliases[m[1]] ??= {})[m[2]] = m[3]; }
419
+ const report = cm.skillAssessmentReportOf({ experiments, sets: byDigest, criteria, ...(examples ? { examples } : {}), ...(Object.keys(aliases).length ? { aliases } : {}), ...(toolToSkillForReport ? { toolToSkill: toolToSkillForReport } : {}) });
420
+ mkdirSync(dirname(resolve_(outPath)), { recursive: true });
421
+ writeFileSync(resolve_(outPath), `${JSON.stringify(report, null, 2)}\n`);
422
+ const recs = report.recommendations;
423
+ out_({ ok: true, out: resolve_(outPath), experiments: report.experiments.length, recommendations: recs.length, headline: report.headline }, `ap eval report · ${report.experiments.length} experiment(s) · ${recs.length} recommendation(s) → ${resolve_(outPath)}\n${report.headline.map((h) => ` ${h}`).join('\n')}\n${recs.filter((r) => r.severity === 'act').slice(0, 8).map((r) => ` ▸ ${r.title}`).join('\n')}`);
424
+ }
425
+
426
+ // Spec 415 A4 — THE SELECTION GATES over stored experiments: rbox agreement (every ontology decision re-derives from its
427
+ // recorded grounding under the playbook it ran with) and judgment calibration (the judge's confidence against being right).
428
+ // FAIL exits 1; INCOMPLETE (nothing to check, or too few judged runs) is said and exits 0 — never reported as a pass.
429
+ async function evalGates() {
430
+ const store = opt('store'); const setsDir = opt('sets'); const defSrc = opt('definition');
431
+ for (const [k, v] of [['store', store], ['sets', setsDir], ['definition', defSrc]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval gates --store <dir> --sets <dir> --definition <url|file> [--plan-id a,b | --all])`);
432
+ const { readFileSync: rf, readdirSync } = await import('node:fs');
433
+ const cm = await loadCompare();
434
+ const decide = await loadDecideFromGrounding();
435
+ const propose = await loadProposeFromGrounding();
436
+ const dir = resolve(resolve_(store.replace(/^file:/, '')), 'experiments');
437
+ const want = opt('plan-id') ? new Set(opt('plan-id').split(',')) : null;
438
+ const records = readdirSync(dir).filter((f) => f.startsWith(encodeURIComponent('experiment:'))).map((f) => JSON.parse(rf(resolve(dir, f), 'utf8')).record).filter((r) => r?.plan && (!want || want.has(r.plan.id)));
439
+ const d = /^https?:/.test(defSrc) ? await (await fetch(defSrc, { headers: { 'user-agent': 'Mozilla/5.0 (ap eval gates)' } })).json() : JSON.parse(rf(resolve_(defSrc), 'utf8'));
440
+ const definition = d.definition ?? d;
441
+ const toolToSkill = Object.fromEntries((definition.tools ?? []).filter((t) => t.source?.skillId).map((t) => [t.id, t.source.skillId]));
442
+ const criteria = {};
443
+ const walk = (p) => { for (const e of readdirSync(p, { withFileTypes: true })) { const q = resolve(p, e.name); if (e.isDirectory()) walk(q); else if (e.name.endsWith('.gold.json')) { const j = JSON.parse(rf(q, 'utf8')); if (j.type === 'ap.selection-criterion.v1') criteria[j.replaySetDigest] = j; } } };
444
+ walk(resolve_(setsDir));
445
+ const which = opt('gate');
446
+ const only = (g) => !which || which === g;
447
+ const rbox = !only('rbox') ? null : cm.rboxAgreementOf({ experiment: { runs: records.flatMap((r) => r.runs) }, definition, decide, propose });
448
+ const calRuns = records.flatMap((r) => r.runs.map((x) => ({ ...x, __crit: criteria[r.plan.replaySetDigest] })));
449
+ const casesUnion = { cases: Object.assign({}, ...Object.values(criteria).map((c) => c.cases)) };
450
+ // Repeats count only WITHIN one experiment: across experiments the playbook, the judge or the model may differ.
451
+ const consistency = !only('consistency') ? null : cm.judgmentConsistencyOf({ experiment: { runs: records.flatMap((r) => r.runs.map((x) => ({ ...x, variant: `${r.plan.id}/${x.variant}` }))) } });
452
+ const calibration = !only('calibration') ? null : cm.judgmentCalibrationOf({ experiment: { runs: calRuns }, criterion: casesUnion, toolToSkill });
453
+ // Spec 416 W3 — conformal coverage of a fitted map, on runs of ONE variant (the map's binding), through the harness's set.
454
+ let conformal = null;
455
+ if (which === 'conformal') {
456
+ if (!opt('map')) throw new DevkitError('E_USAGE', '--gate conformal needs --map <map.json> (ap eval calibrate writes one)');
457
+ const map = JSON.parse(rf(resolve_(opt('map')), 'utf8'));
458
+ const fns = await loadHarnessCalibration();
459
+ const variant = opt('variant') ?? map.binding?.variant;
460
+ const fitted = new Set(map.fittedOn ?? []);
461
+ const overlap = records.filter((r) => fitted.has(r.plan.id)).map((r) => r.plan.id);
462
+ if (overlap.length) throw new DevkitError('E_USAGE', `coverage must be measured on runs the map was NOT fitted on — ${overlap.join(', ')} ${overlap.length === 1 ? 'is' : 'are'} in its fittedOn`);
463
+ conformal = cm.conformalCoverageOf({ experiment: { runs: calRuns.filter((x) => !variant || x.variant === variant) }, criterion: casesUnion, toolToSkill, map, fns });
464
+ }
465
+ const behaviour = !only('behaviour') ? null : cm.selectionBehaviourOf({ experiment: { runs: calRuns }, criterion: casesUnion });
466
+ // Spec 417 — OUTCOME PLANS: each variant's chains against gold plans (`--outcome-gold <file>`: { intentId: { plan, missing? } }).
467
+ // A measurement, not a pass/fail yet: the gate the spec names (plan-exact ≥ 0.8, no needless step on held inputs) is read off it.
468
+ let outcome = null;
469
+ if (which === 'outcome') {
470
+ if (!opt('outcome-gold')) throw new DevkitError('E_USAGE', '--gate outcome needs --outcome-gold <file>');
471
+ const gold = JSON.parse(rf(resolve_(opt('outcome-gold')), 'utf8')).cases;
472
+ const rows = cm.outcomePlanScoresOf(records.flatMap((r) => r.runs.map((x) => ({ ...x, variant: `${r.plan.id}/${x.variant}` }))), gold);
473
+ const pct = (a, b) => (b ? `${a}/${b}` : '—');
474
+ const planExact = rows.length ? Math.min(...rows.filter((r) => /outcome/.test(r.variant)).map((r) => r.exact / Math.max(1, r.n))) : 0;
475
+ outcome = { status: rows.length ? 'MEASURED' : 'INCOMPLETE', rows, reason: rows.length ? `outcome-arm plan-exact min ${planExact.toFixed(2)}` : 'no run of a gold intent' };
476
+ }
477
+ const lines = [`ap eval gates · ${records.length} experiment(s) · playbook ${String(d.digest ?? '').slice(0, 14)}`];
478
+ if (rbox) lines.push(` check:rbox-agreement ${rbox.status} — ${rbox.reason}${rbox.disagreements.length ? `\n${rbox.disagreements.slice(0, 8).map((x) => ` ✗ ${x.intentId} (${x.variant}) ${x.field}: recorded ${JSON.stringify(x.recorded)} ≠ derived ${JSON.stringify(x.derived)}`).join('\n')}` : ''}`);
479
+ if (calibration) lines.push(` check:judgment-calibration ${calibration.status} — ${calibration.reason}${calibration.n ? ` · accuracy ${(calibration.accuracy * 100).toFixed(0)}% at mean confidence ${(calibration.meanConfidence * 100).toFixed(0)}%` : ''}${calibration.overconfident.length ? `\n confident and wrong (p ≥ 0.8): ${calibration.overconfident.slice(0, 6).map((x) => `${x.intentId}→${x.predicted.split('/').pop()} (${x.p.toFixed(2)})`).join(', ')}` : ''}`);
480
+ if (consistency) lines.push(` check:judgment-consistency ${consistency.status} — ${consistency.reason}${consistency.unstable.length ? `\n changed its answer: ${consistency.unstable.slice(0, 6).map((x) => `${x.intentId} (${x.variant}) ${x.tops.map((t) => t.split('.').pop()).join('/')}`).join(', ')}` : ''}`);
481
+ const recall = !only('recall') ? null : cm.candidateRecallOf({ experiment: { runs: calRuns }, criterion: casesUnion, toolToSkill });
482
+ if (recall) lines.push(` check:candidate-recall ${recall.status} — ${recall.reason}${recall.n ? ` · mean ${recall.meanProposed.toFixed(1)} proposed · gold reached by ${Object.entries(recall.byVia).map(([k, v]) => `${k} ${v}`).join(', ')}` : ''}${recall.missed.length ? `\n never proposed: ${recall.missed.slice(0, 8).map((x) => `${x.intentId}→${x.gold.split('/').pop()}`).join(', ')}` : ''}`);
483
+ if (conformal) lines.push(` check:conformal-coverage ${conformal.status} — ${conformal.reason}${conformal.n ? ` · mean set size ${conformal.meanSetSize.toFixed(2)} · sizes ${JSON.stringify(conformal.sizes)}` : ''}${conformal.uncovered.length ? `\n not covered: ${conformal.uncovered.slice(0, 8).map((x) => `${x.intentId} gold ${x.gold.split('.').pop()} set [${x.set.join(', ')}]`).join('; ')}` : ''}`);
484
+ if (behaviour) lines.push(` check:selection-behaviour ${behaviour.status} — ${behaviour.reason}${behaviour.n ? ` · ${Object.entries(behaviour.counts).map(([k, v]) => `${k} ${v}`).join(', ')}` : ''}${behaviour.wrong.length ? `\n not right: ${behaviour.wrong.slice(0, 8).map((x) => `${x.intentId} (${x.behaviour}) ${x.outcome}${x.chose ? ` ${x.chose}` : ''}`).join(', ')}` : ''}`);
485
+ if (outcome) lines.push(` outcome-plans ${outcome.status} — ${outcome.reason}\n${outcome.rows.map((r) => ` ${r.variant}: exact ${r.exact}/${r.n} · needless ${r.needless} · short ${r.short} · asked ${r.asked.right}/${r.asked.of} · steps ${r.stepsScore} · ${Object.entries(r.bySteps).map(([k, v]) => `${k} ${v.exact}/${v.n}`).join(', ')}`).join('\n')}`);
486
+ const failed = [rbox, calibration, consistency, recall, conformal, behaviour].some((g) => g?.status === 'FAIL');
487
+ out_({ ok: !failed, rbox, calibration, consistency, recall, conformal, behaviour, outcome }, lines.join('\n'));
488
+ process.exit(failed ? EXIT.FINDINGS : EXIT.OK);
489
+ }
490
+
491
+ // Spec 416 §4g — THE SELECTION SCORECARD: the same table every iteration (right, declines, ask p50/p90, time to pick,
492
+ // tokens to pick / in total / cached, model calls and plan steps per ask), over stored experiments; `--out` keeps it, so
493
+ // improvements are tracked as data, not remembered.
494
+ // Spec 418 §4 — THE TECHNIQUE LEDGER: every technique's paired comparisons (quality by McNemar, cost by paired
495
+ // bootstrap medians) from the stored experiments, with a declared verdict rule. Gold plans are read from the sets'
496
+ // *.outcome-gold.json files. `ap eval ledger --store <dir> --techniques <file> --sets <dir> --out <file>`.
497
+ async function evalLedger() {
498
+ const store = opt('store'); const techFile = opt('techniques'); const setsDir = opt('sets'); const outPath = opt('out');
499
+ for (const [k, v] of [['store', store], ['techniques', techFile], ['sets', setsDir], ['out', outPath]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval ledger --store <dir> --techniques <file> --sets <dir> --out <file>)`);
500
+ const { readFileSync: rf, readdirSync, writeFileSync, mkdirSync } = await import('node:fs');
501
+ const { dirname } = await import('node:path');
502
+ const cm = await loadCompare();
503
+ const dir = resolve(resolve_(store.replace(/^file:/, '')), 'experiments');
504
+ const records = readdirSync(dir).filter((f) => f.startsWith(encodeURIComponent('experiment:'))).map((f) => JSON.parse(rf(resolve(dir, f), 'utf8')).record).filter((r) => r?.plan);
505
+ const goldBySet = {};
506
+ const walk = (p) => { for (const e of readdirSync(p, { withFileTypes: true })) { const q = resolve(p, e.name); if (e.isDirectory()) walk(q); else if (e.name.endsWith('.outcome-gold.json')) { const j = JSON.parse(rf(q, 'utf8')); if (j.replaySetDigest) goldBySet[j.replaySetDigest] = j.cases; } } };
507
+ walk(resolve_(setsDir));
508
+ const techniques = JSON.parse(rf(resolve_(techFile), 'utf8')).techniques;
509
+ const ledger = cm.techniqueLedgerOf({ records, techniques, goldBySet });
510
+ mkdirSync(dirname(resolve_(outPath)), { recursive: true });
511
+ writeFileSync(resolve_(outPath), `${JSON.stringify(ledger, null, 2)}\n`);
512
+ const f = (c) => (c?.deltaPct === null || c?.deltaPct === undefined ? '—' : `${c.deltaPct > 0 ? '+' : ''}${c.deltaPct}%`);
513
+ const lines = ledger.rows.map((r) => ` ${r.verdict.padEnd(24)} ${r.technique.name}${r.technique.ontologyDriven ? ' ◆' : ''}${r.comparisons.map((c) => `\n ${c.experiment} ${c.baseline}→${c.treatment} n=${c.n} · ${c.quality.metric} ${c.quality.base}→${c.quality.treat} (+${c.quality.fixed}/−${c.quality.broken} p=${c.quality.p}) · pick tok ${f(c.cost.pickTokens)} · pick ms ${f(c.cost.pickMs)} · ask ms ${f(c.cost.askMs)} · ${c.verdict}`).join('')}${(r.pooled ?? []).map((q) => `\n POOLED vs ${q.baseline} (${q.experiments} experiments, n=${q.n}): +${q.fixed}/−${q.broken} p=${q.p} · pick tok ${q.pickTokensPct ?? '—'}% · ask ms ${q.askMsPct ?? '—'}% · ${q.verdict}`).join('')}${r.missing.length ? `\n missing: ${r.missing.join(', ')}` : ''}`);
514
+ out_({ ok: true, out: resolve_(outPath), rows: ledger.rows.length }, `ap eval ledger · ${ledger.rows.length} technique(s) → ${resolve_(outPath)} (◆ ontology-driven)\n${lines.join('\n')}\n rule: ${ledger.rule}`);
515
+ }
516
+
517
+ async function evalScorecard() {
518
+ const store = opt('store'); const setsDir = opt('sets'); const planIds = opt('plan-id');
519
+ for (const [k, v] of [['store', store], ['sets', setsDir], ['plan-id', planIds]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval scorecard --store <dir> --sets <dir> --plan-id a,b [--out <file.json>])`);
520
+ const { readFileSync: rf, readdirSync, writeFileSync, mkdirSync } = await import('node:fs');
521
+ const { dirname } = await import('node:path');
522
+ const cm = await loadCompare();
523
+ const dir = resolve(resolve_(store.replace(/^file:/, '')), 'experiments');
524
+ const order = planIds.split(',');
525
+ const records = order.map((id) => { const f = resolve(dir, `${encodeURIComponent('experiment:' + id)}.jsonld`); try { return JSON.parse(rf(f, 'utf8')).record; } catch { throw new DevkitError('E_INPUT_MISSING', `no stored experiment ${id}`); } });
526
+ const criteria = {};
527
+ const walk = (p) => { for (const e of readdirSync(p, { withFileTypes: true })) { const q = resolve(p, e.name); if (e.isDirectory()) walk(q); else if (e.name.endsWith('.gold.json')) { const j = JSON.parse(rf(q, 'utf8')); if (j.type === 'ap.selection-criterion.v1') Object.assign(criteria, j.cases); } } };
528
+ walk(resolve_(setsDir));
529
+ const rows = cm.scorecardOf(records, criteria);
530
+ const s = (ms) => (ms === null || ms === undefined ? '—' : `${(ms / 1000).toFixed(1)}s`);
531
+ const k = (n) => (n === null || n === undefined ? '—' : n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(Math.round(n)));
532
+ const lines = [`ap eval scorecard · ${records.length} experiment(s)`, ` ${'experiment/variant'.padEnd(46)} right pos neg ask p50/p90 pick pick tok in/out total in/out cached calls steps quality`];
533
+ for (const r of rows) lines.push(` ${`${r.experiment}/${r.variant}`.slice(0, 46).padEnd(46)} ${`${r.right}/${r.runs}`.padEnd(6)} ${`${r.rightOnPositives}/${r.positives}`.padEnd(6)} ${`${r.declined}/${r.negatives}`.padEnd(6)} ${`${s(r.askP50)}/${s(r.askP90)}`.padEnd(13)} ${s(r.pickP50).padEnd(5)} ${`${k(r.selectionIn)}/${k(r.selectionOut)}`.padEnd(15)} ${`${k(r.totalIn)}/${k(r.totalOut)}`.padEnd(13)} ${k(r.cachedIn).padEnd(6)} ${String(r.modelCalls ?? '—').padEnd(5)} ${String(r.planSteps ?? '—').padEnd(5)} ${r.quality === null || r.quality === undefined ? '—' : `${r.quality.toFixed(2)} (${r.qualityRuns})`}`);
534
+ for (const r of rows) if (r.preference) lines.push(` ${r.variant} side by side (${r.preferenceRuns}): ${Object.entries(r.preference).map(([k, v]) => `${k} ${(v * 100).toFixed(0)}%`).join(' · ')}`);
535
+ for (const r of rows) { const st = Object.entries(r.stagesP50 ?? {}).slice(0, 6); if (st.length) lines.push(` ${r.variant} stages p50: ${st.map(([n, v]) => `${n} ${s(v)}`).join(' · ')}`); }
536
+ if (opt('out')) { mkdirSync(dirname(resolve_(opt('out'))), { recursive: true }); writeFileSync(resolve_(opt('out')), `${JSON.stringify({ type: 'ap.selection-scorecard.v1', at: new Date().toISOString(), rows }, null, 2)}\n`); lines.push(` → ${resolve_(opt('out'))}`); }
537
+ out_({ ok: true, rows }, lines.join('\n'));
538
+ }
539
+
540
+ // Spec 416 W3 — FIT A CALIBRATION MAP on labelled runs of one variant (one binding): a temperature and a conformal q̂ at
541
+ // level alpha, through the harness's own math. The map is data with a digest; `ap eval compare` passes it as the variant's
542
+ // `acceptance`, and `ap eval gates --gate conformal --map` measures it on runs it was not fitted on.
543
+ async function evalCalibrate() {
544
+ const store = opt('store'); const setsDir = opt('sets'); const defSrc = opt('definition'); const planIds = opt('plan-id'); const variant = opt('variant'); const out = opt('out');
545
+ for (const [k, v] of [['store', store], ['sets', setsDir], ['definition', defSrc], ['plan-id', planIds], ['variant', variant], ['out', out]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval calibrate --store <dir> --plan-id a,b --variant v --sets <dir> --definition <url|file> --alpha 0.05 --out <map.json>)`);
546
+ const { readFileSync: rf, readdirSync, writeFileSync, mkdirSync } = await import('node:fs');
547
+ const { dirname } = await import('node:path');
548
+ const cm = await loadCompare();
549
+ const fns = await loadHarnessCalibration();
550
+ const dir = resolve(resolve_(store.replace(/^file:/, '')), 'experiments');
551
+ const want = new Set(planIds.split(','));
552
+ const records = readdirSync(dir).filter((f) => f.startsWith(encodeURIComponent('experiment:'))).map((f) => JSON.parse(rf(resolve(dir, f), 'utf8')).record).filter((r) => r?.plan && want.has(r.plan.id));
553
+ const missing = [...want].filter((id) => !records.some((r) => r.plan.id === id));
554
+ if (missing.length) throw new DevkitError('E_INPUT_MISSING', `no stored experiment ${missing.join(', ')}`);
555
+ const providers = [...new Set(records.map((r) => r.plan.variants?.[variant]?.provider ?? null))];
556
+ if (providers.length > 1) throw new DevkitError('E_USAGE', `the experiments ran ${variant} on different providers (${providers.join(', ')}) — a map is fitted for ONE binding`);
557
+ const d = /^https?:/.test(defSrc) ? await (await fetch(defSrc, { headers: { 'user-agent': 'Mozilla/5.0 (ap eval calibrate)' } })).json() : JSON.parse(rf(resolve_(defSrc), 'utf8'));
558
+ const definition = d.definition ?? d;
559
+ const toolToSkill = Object.fromEntries((definition.tools ?? []).filter((t) => t.source?.skillId).map((t) => [t.id, t.source.skillId]));
560
+ const criteria = {};
561
+ const walk = (p) => { for (const e of readdirSync(p, { withFileTypes: true })) { const q = resolve(p, e.name); if (e.isDirectory()) walk(q); else if (e.name.endsWith('.gold.json')) { const j = JSON.parse(rf(q, 'utf8')); if (j.type === 'ap.selection-criterion.v1') criteria[j.replaySetDigest] = j; } } };
562
+ walk(resolve_(setsDir));
563
+ const criterion = { cases: Object.assign({}, ...records.map((r) => criteria[r.plan.replaySetDigest]?.cases ?? {})) };
564
+ const map = await cm.fitConformalMap({ experiments: records, variant, judge: fns.judge, ...(providers[0] ? { provider: providers[0] } : {}), criterion, toolToSkill, alpha: Number(opt('alpha') ?? 0.05), fns });
565
+ mkdirSync(dirname(resolve_(out)), { recursive: true });
566
+ writeFileSync(resolve_(out), `${JSON.stringify(map, null, 2)}\n`);
567
+ out_({ ok: true, out: resolve_(out), map }, `ap eval calibrate · ${map.binding.judge} on ${map.binding.provider ?? 'the default provider'} · ${map.n} labelled runs from ${map.fittedOn.join(', ')}\n temperature ${map.temperature} · q̂ ${map.qhat} at α ${map.alpha} (set threshold ${(1 - map.qhat).toFixed(3)}) · ${map.mapDigest}\n → ${resolve_(out)}`);
568
+ }
569
+
570
+ // Spec 415 A4 / methodology §4.3 — A REPAIR, VALIDATED: the same set judged before and after ONE change (a description,
571
+ // a doctrine line, a playbook version), compared on the paired cases. The two experiments are what they were; the
572
+ // comparison refuses a different set or criterion, and says when the validating split is not locked.
573
+ async function evalDiff() {
574
+ const store = opt('store');
575
+ for (const [k, v] of [['store', store], ['before', opt('before')], ['after', opt('after')], ['replay', opt('replay')], ['criterion', opt('criterion')]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval diff --store <dir> --before <plan-id> --after <plan-id> --replay <set> --criterion <gold>)`);
576
+ const { readFileSync: rf } = await import('node:fs');
577
+ const cm = await loadCompare();
578
+ const dir = resolve_(store.replace(/^file:/, ''));
579
+ const load = (id) => { const path = resolve(dir, 'experiments', `${encodeURIComponent(`experiment:${id}`)}.jsonld`); const doc = JSON.parse(rf(path, 'utf8')); if (!doc.record?.plan) throw new DevkitError('E_INPUT_MISSING', `${path} holds no experiment record`); return doc.record; };
580
+ const before = load(opt('before')); const after = load(opt('after'));
581
+ const setR = cm.validateReplaySet(JSON.parse(rf(resolve_(opt('replay')), 'utf8')));
582
+ if (!setR.ok) throw new DevkitError('E_USAGE', `the replay set is not valid: ${setR.errors.join('; ')}`);
583
+ const criterion = JSON.parse(rf(resolve_(opt('criterion')), 'utf8'));
584
+ const setDigest = await cm.replaySetDigest(setR.set); const critDigest = await cm.criterionDigest(criterion);
585
+ for (const [n, r] of [['before', before], ['after', after]]) if (r.plan.replaySetDigest !== setDigest || r.plan.criterionDigest !== critDigest) throw new DevkitError('E_USAGE', `${n} (${r.plan.id}) pre-registered a different set or criterion; a diff never crosses them`);
586
+ if (before.plan.split !== after.plan.split) throw new DevkitError('E_USAGE', `the splits differ (${before.plan.split} vs ${after.plan.split}); paired cases need the same split`);
587
+ const vBefore = opt('variant-before') ?? opt('variant') ?? 'model';
588
+ const vAfter = opt('variant-after') ?? opt('variant') ?? 'model';
589
+ for (const [n, r, v] of [['before', before, vBefore], ['after', after, vAfter]]) if (!r.scores?.[v]) throw new DevkitError('E_INPUT_MISSING', `${n} (${r.plan.id}) has no scored variant "${v}" — ap eval rescore it first`);
590
+ const variant = `${vBefore} → ${vAfter}`;
591
+ const { cases } = cm.casesOf(setR.set, criterion, before.plan.split);
592
+ const side = async (r, v) => ({ datasetDigest: setDigest, variantDigest: await cm.variantRequestDigest(r.plan.variants[v]), split: r.plan.split === 'held-out' ? 'held-out' : 'development', score: r.scores[v], cases });
593
+ const pair = opt('repaired-pair') ? opt('repaired-pair').split(',').map((x) => x.trim()).filter(Boolean) : undefined;
594
+ const cmp = cm.compareSelectionRuns(await side(before, vBefore), await side(after, vAfter), pair ? { repairedPair: pair } : {});
595
+ const f = (v) => (v === null || v === undefined ? 'n/a' : Number(v).toFixed(3));
596
+ const a = before.scores[vBefore].aggregate, b = after.scores[vAfter].aggregate;
597
+ const line = (k) => ` ${k.padEnd(22)} ${f(a[k]?.value)} → ${f(b[k]?.value)}`;
598
+ out_({ ok: true, before: before.plan.id, after: after.plan.id, variant, comparison: cmp, aggregates: { before: a, after: b } }, `ap eval diff · ${before.plan.id} → ${after.plan.id} (${variant}, split ${before.plan.split})\n${['macro_ta', 'micro_ta', 'hold_rate', 'routing_purity', 'collision_containment', 'over_fire_rate'].map(line).join('\n')}\n Δ macro ${f(cmp.delta_macro)} [${f(cmp.ci_delta?.[0])}, ${f(cmp.ci_delta?.[1])}] · McNemar fixed ${cmp.fixed} broken ${cmp.broken} p=${Number(cmp.p_value).toFixed(4)} (${cmp.mcnemar_scope}, n=${cmp.n_paired})${cmp.regression ? ' · REGRESSION' : cmp.degraded ? ' · degraded' : ''}${cmp.warnings.length ? `\n ! ${cmp.warnings.join('; ')}` : ''}${cmp.note ? `\n ${cmp.note}` : ''}`);
599
+ }
600
+
601
+ const VARIANT_PRESETS = { model: {}, declared: { selection: 'declared' }, ontology: { selection: 'ontology' }, judgment: { selection: 'judgment' }, 'ontology+judgment': { selection: 'ontology+judgment' }, 'propose+judgment': { selection: 'propose+judgment' }, 'ontology-first': { selection: 'ontology-first' }, 'framed-judgment': { selection: 'framed-judgment' }, outcome: { selection: 'outcome', judgeProfile: 'fast' }, 'outcome-selective': { selection: 'outcome-selective', judgeProfile: 'fast' }, rules: { plannerKind: 'rule-based' }, 'kb-off': { toggles: { 'retrieval/kb': 'off' } }, 'kb-tool': { toggles: { 'retrieval/kb': 'tool' } }, 'kb-playbook': { toggles: { 'retrieval/kb': 'playbook' } } };
602
+
603
+ /** THE ACTIVITIES A RUN SPAWNED, from its PROV bundle: the run (ServiceExecution) at the root, under it every
604
+ * SkillExecution (a step; wasInformedBy / isStepOfPlan say which) and every ModelInvocation, each with what it
605
+ * generated (receipts, results) and who it ran as. A handed-off or routed step appears as its own activity under the
606
+ * step that spawned it. Read from `graph`; nothing is inferred that the bundle does not say. */
607
+ function activityTreeOf(bundle, record) {
608
+ const nodes = Array.isArray(bundle?.graph) ? bundle.graph : Array.isArray(bundle?.['@graph']) ? bundle['@graph'] : [];
609
+ const stepAt = (id) => { const m = /:s(\d+)$/.exec(String(id)); return m && record?.steps ? record.steps[Number(m[1])] : undefined; };
610
+ const callAt = (id) => { const m = /:model:(\d+)$/.exec(String(id)); return m && record?.modelCalls ? record.modelCalls[Number(m[1])] : undefined; };
611
+ const byId = new Map(nodes.map((n) => [n.id ?? n['@id'], n]));
612
+ const types = (n) => [].concat(n.type ?? n['@type'] ?? []);
613
+ const acts = nodes.filter((n) => types(n).includes('Activity'));
614
+ const generated = (id) => nodes.filter((n) => n.wasGeneratedBy === id).map((n) => ({ id: n.id, type: types(n).filter((t) => t !== 'Entity'), ...(n.digest ? { digest: n.digest } : {}), ...(n.commitment ? { commitment: n.commitment } : {}) }));
615
+ const parentOf = (n) => n.wasInformedBy ?? n.wasStartedBy ?? n.partOf ?? (typeof n.id === 'string' && /:(s\d+|model:\d+)$/.test(n.id) ? n.id.replace(/:(s\d+|model:\d+)$/, '') : undefined);
616
+ const node = (n) => ({
617
+ id: n.id, kind: types(n).filter((t) => t !== 'Activity')[0] ?? 'Activity',
618
+ ...(n.startedAtTime ? { startedAt: n.startedAtTime } : {}), ...(n.endedAtTime ? { endedAt: n.endedAtTime } : {}),
619
+ ...(n.wasAssociatedWith ? { ranAs: n.wasAssociatedWith } : {}), ...(n.used ? { used: [].concat(n.used) } : {}),
620
+ ...(n.hadPlan ? { plan: n.hadPlan } : {}), ...(n.model ? { model: n.model } : {}), ...(n.provider ? { provider: n.provider } : {}), ...(n.role ? { role: n.role } : {}),
621
+ ...(n.status ? { status: n.status } : {}), ...(n.toolId ? { toolId: n.toolId } : {}),
622
+ ...((s) => s ? { toolId: s.toolId, status: s.status, ...(s.risk ? { risk: s.risk } : {}) } : {})(stepAt(n.id)),
623
+ ...((c) => c ? { role: c.role, provider: c.provider, model: c.model, ...(c.tokensIn !== undefined ? { tokensIn: c.tokensIn, tokensOut: c.tokensOut } : {}), ...(c.routeReason ? { because: c.routeReason } : {}), ...(c.stepRef ? { forStep: c.stepRef } : {}) } : {})(callAt(n.id)),
624
+ generated: generated(n.id), children: [],
625
+ });
626
+ const built = new Map(acts.map((a) => [a.id, node(a)]));
627
+ const roots = [];
628
+ for (const a of acts) { const p = parentOf(a); if (p && built.has(p) && p !== a.id) built.get(p).children.push(built.get(a.id)); else roots.push(built.get(a.id)); }
629
+ const order = (xs) => { xs.sort((x, y) => String(x.startedAt ?? '').localeCompare(String(y.startedAt ?? ''))); for (const x of xs) order(x.children); return xs; };
630
+ void byId;
631
+ return order(roots);
632
+ }
633
+
634
+ function keptRunMarkdown(planId, entry, tree) {
635
+ const lines = [`# ${entry.intentId} · ${entry.variant} · repeat ${entry.repeat}`, '', `plan \`${planId}\` · run \`${entry.runRef}\` · selection **${entry.verdict?.outcome ?? '?'}** (${entry.selected.join(', ') || 'nothing chosen'})${entry.outcome ? ` · outcome check **${entry.outcome.score}**` : ''}${entry.quality ? ` · quality ${entry.quality.score}` : ''}`, '', '## The ask', '', `> ${entry.message.replace(/\n/g, '\n> ')}`, ''];
636
+ if (entry.outcome) lines.push('## Outcome check — classes delivered', '', ...Object.entries(entry.outcome.classes).map(([k, v]) => `- ${v.toFixed(2)} ${k.split('#').pop()}`), '');
637
+ if (entry.reply?.results && Array.isArray(entry.reply.results)) { lines.push('## Results per step', ''); for (const r of entry.reply.results) lines.push(`### ${r.toolId ?? r.stepRef ?? 'step'}`, '', '```json', JSON.stringify(r.result ?? r, null, 2).slice(0, 20000), '```', ''); }
638
+ lines.push('## The reply', '', entry.reply?.text ?? entry.reply?.error ?? '(the ask port carried no reply)', '');
639
+ lines.push('## Activities the run spawned', '');
640
+ const walk = (xs, depth) => { for (const x of xs) { lines.push(`${' '.repeat(depth)}- **${x.kind}** \`${String(x.id).replace(/^urn:ap:prov:act:/, '')}\`${x.toolId ? ` · ${x.toolId}${x.status ? ` (${x.status})` : ''}` : ''}${x.role ? ` · ${x.role}` : ''}${x.model ? ` · ${x.provider ?? ''} ${x.model}` : ''}${x.tokensIn !== undefined ? ` · ${x.tokensIn} → ${x.tokensOut} tokens` : ''}${x.startedAt && x.endedAt ? ` · ${Math.max(0, Date.parse(x.endedAt) - Date.parse(x.startedAt))} ms` : ''}${x.generated.length ? ` → generated ${x.generated.map((g) => `${g.type.join('/')} ${String(g.id).replace(/^urn:ap:prov:/, '').slice(0, 40)}…`).join(', ')}` : ''}`); walk(x.children, depth + 1); } };
641
+ if (tree.length) walk(tree, 0); else lines.push('(no provenance bundle)');
642
+ lines.push('');
643
+ return lines.join('\n');
644
+ }
645
+
646
+ async function evalCompare() {
647
+ const home = (opt('home') ?? process.env.HOME_URL ?? '').replace(/\/$/, '');
648
+ const store = opt('store');
649
+ for (const [k, v] of [['home', home], ['replay', opt('replay')], ['criterion', opt('criterion')], ['variants', opt('variants')], ['store', store], ['persona', opt('persona')], ['addressee', opt('addressee')]]) if (!v) throw new DevkitError('E_USAGE', `--${k} is required (ap eval compare --replay <set> --criterion <gold> --variants a,b --store <dir> --home <url> --persona <handle> --addressee <name>)`);
650
+ const { existsSync: ex, readFileSync: rf } = await import('node:fs');
651
+ const cm = await loadCompare();
652
+ const fileStore = await loadEvalStore();
653
+ const setR = cm.validateReplaySet(JSON.parse(rf(resolve_(opt('replay')), 'utf8')));
654
+ if (!setR.ok) throw new DevkitError('E_USAGE', `the replay set is not valid: ${setR.errors.join('; ')}`);
655
+ const criterion = JSON.parse(rf(resolve_(opt('criterion')), 'utf8'));
656
+ if (criterion?.type !== 'ap.selection-criterion.v1') throw new DevkitError('E_USAGE', 'the criterion is not an ap.selection-criterion.v1 record');
657
+ const variants = {};
658
+ for (const nameOrFile of opt('variants').split(',').map((s) => s.trim()).filter(Boolean)) {
659
+ if (VARIANT_PRESETS[nameOrFile]) variants[nameOrFile] = VARIANT_PRESETS[nameOrFile];
660
+ else if (ex(resolve_(nameOrFile))) variants[nameOrFile.replace(/^.*\//, '').replace(/\.json$/, '')] = JSON.parse(rf(resolve_(nameOrFile), 'utf8'));
661
+ else throw new DevkitError('E_USAGE', `variant "${nameOrFile}" is neither a preset (${Object.keys(VARIANT_PRESETS).join(', ')}) nor a file`);
662
+ }
663
+ const split = opt('split') ?? 'held-out';
664
+ const repeats = Number(opt('repeat') ?? 1);
665
+ const skillMap = opt('skill-map') ? JSON.parse(rf(resolve_(opt('skill-map')), 'utf8')) : undefined;
666
+ const actGoldRaw = opt('act-gold') ? JSON.parse(rf(resolve_(opt('act-gold')), 'utf8')) : undefined;
667
+ if (actGoldRaw && actGoldRaw.type !== 'ap.act-gold.v1') throw new DevkitError('E_USAGE', 'the act gold is not an ap.act-gold.v1 record');
668
+ const paths = windowPaths(store);
669
+ const windowRaw = ex(paths.window) ? JSON.parse(rf(paths.window, 'utf8')) : null;
670
+ const wv = windowRaw ? cm.validateCaptureWindow(windowRaw) : null;
671
+ if (wv && !wv.ok) throw new DevkitError('E_USAGE', `the window at ${paths.window} is not valid: ${wv.errors.join('; ')}`);
672
+ const salt = ex(paths.salt) ? rf(paths.salt, 'utf8').trim() : null;
673
+ const est = await estateClient(home);
674
+ const me = await est.signin(opt('persona'));
675
+ const addressee = await est.resolveName(opt('addressee'));
676
+ // Spec 418 §12 — the act gold names the estate's agents; resolved once, here, by the estate's own resolver.
677
+ const actGold = actGoldRaw ? await cm.resolveActGold(actGoldRaw, (n) => est.resolveName(n)) : undefined;
678
+ const plan = cm.experimentPlan({ id: opt('plan-id') ?? `${setR.set.id}@${setR.set.version}-${Object.keys(variants).join('+')}-${split}-r${repeats}`, replaySetDigest: await cm.replaySetDigest(setR.set), criterionDigest: await cm.criterionDigest(criterion), split, repeats, variants, ...(opt('policy') ? { policy: opt('policy') } : {}) });
679
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
680
+ // --keep-text <dir>: the reply's words and the run's activity tree, per case, on this disk and nowhere else. The store
681
+ // never holds words (415 S6); this is the one door for them, and it opens only onto a synthetic estate — the open
682
+ // window must admit the addressee (a domain sandbox org), the same test the capture firewall applies.
683
+ const keepDir = opt('keep-text') ? resolve_(opt('keep-text')) : null;
684
+ if (keepDir) {
685
+ const admitted = wv?.ok && wv.window.roster.map((a) => a.toLowerCase()).includes(addressee.toLowerCase());
686
+ if (!admitted) throw new DevkitError('E_USAGE', `--keep-text keeps words: the open window at ${paths.window} must admit ${opt('addressee')} (${addressee}) — open one with it on the roster (ap eval window open … --roster <persona>,${opt('addressee')})`);
687
+ }
688
+ const ports = {
689
+ ask: async ({ intentId, message, variant, variantName, repeat }) => {
690
+ const runRef = `cmp-${Date.now().toString(36)}-${variantName}-${intentId}-${repeat}`.replace(/[^A-Za-z0-9._:-]/g, '-');
691
+ // Spec 420 W4 — a UX ACTION case supplies the plan its Home button sends; its words ride as the message.
692
+ const supplied = actGold?.cases?.[intentId]?.supplied;
693
+ // A LIVE arm (an empty variant `{}`) sends no variant at all: the estate accepts variants only from the agent or its
694
+ // steward, so a MEMBER's cases (spec 420 set 5a) run as plain asks under the deployment's defaults — paired across two
695
+ // deployments by case, never within one run.
696
+ const live = variant && typeof variant === 'object' && Object.keys(variant).length === 0;
697
+ const r = await est.post('/harness/ask', { session: me.token, addressee, message, ...(live ? {} : { variant }), runRef, ...(supplied ? { plan: supplied } : {}) });
698
+ if (r?.ok === false || r?.error) return { ok: false, error: `${r.refused ? `${r.refused}: ` : ''}${r.error ?? JSON.stringify(r).slice(0, 160)}` };
699
+ // A turn the MODEL HOST refused (overloaded, rate-limited) never planned: a failed run, named as the provider's — it
700
+ // says nothing about the harness under test and must not be scored as its miss (spec 418 §12, seen 2026-09-28).
701
+ const said = String(r?.reply?.error ?? r?.reply?.text ?? '');
702
+ if (r?.reply?.kind === 'refused' && /planner_failed:.*HTTP (402|429|5\d\d)/.test(said)) return { ok: false, error: `provider: ${said.slice(0, 160)}` };
703
+ const rt = r?.hasProvenance?.recordType;
704
+ return { ok: true, runRef: typeof rt === 'string' && rt.startsWith('run.provenance:') ? rt.slice('run.provenance:'.length) : runRef, ...(r?.reply?.plannerTrace?.selection ? { selection: r.reply.plannerTrace.selection } : {}), ...(r?.reply?.plannerTrace ? { trace: r.reply.plannerTrace } : {}), ...(keepDir && r?.reply ? { reply: r.reply } : {}), ...(actGold ? { act: cm.actCaptureOf(r?.reply) } : {}) };
705
+ },
706
+ ...(keepDir ? { keep: async (entry) => {
707
+ const { mkdirSync, writeFileSync } = await import('node:fs');
708
+ const dir = `${keepDir}/${plan.id}/${entry.variant}`; mkdirSync(dir, { recursive: true });
709
+ const safe = (s) => String(s).replace(/[^A-Za-z0-9._-]/g, '-');
710
+ const base = `${dir}/${safe(entry.intentId)}#${entry.repeat}`;
711
+ // The record view says what the bundle's activities do not: each step's tool and status, each model call's role,
712
+ // provider, model and tokens. Merged by position (`:s<n>` ↔ steps[n]; `:model:<n>` ↔ modelCalls[n]).
713
+ const rv = await est.post('/harness/provenance', { session: me.token, addressee, runRef: entry.runRef, format: 'record' }).catch(() => null);
714
+ const tree = activityTreeOf(entry.provenance, rv?.ok ? rv.record : null);
715
+ writeFileSync(`${base}.json`, `${JSON.stringify({ type: 'ap.kept-run.v1', planId: plan.id, ...entry, ...(rv?.ok ? { record: rv.record } : {}), activities: tree }, null, 2)}\n`);
716
+ writeFileSync(`${base}.md`, keptRunMarkdown(plan.id, entry, tree));
717
+ } } : {}),
718
+ readProvenance: async (runRef) => {
719
+ for (let i = 0; i < 20; i++) {
720
+ const r = await est.post('/harness/provenance', { session: me.token, addressee, runRef });
721
+ if (r?.ok && r.provenance) return r.provenance;
722
+ await wait(3000);
723
+ }
724
+ return null;
725
+ },
726
+ readMeasures: async (runRef) => {
727
+ for (let i = 0; i < 10; i++) {
728
+ const r = await est.post('/harness/provenance', { session: me.token, addressee, runRef, format: 'measures' });
729
+ if (r?.ok && r.measures) return r.measures;
730
+ if (r?.status === 'refused') return null;
731
+ await wait(3000);
732
+ }
733
+ return null;
734
+ },
735
+ };
736
+ const rec = await cm.runComparison({ ...(opt('fixtures') ? { fixtures: JSON.parse(rf(resolve_(opt('fixtures')), 'utf8')).fixtures } : {}),
737
+ set: setR.set, criterion, plan, window: wv ? wv.window : null, estate: home, estateCapture: est.evalCapture, asker: me.agent, addressee,
738
+ store: fileStore(paths.dir), pseudo: cm.pseudonymiser(salt ?? 'no-window'), ports, ...(skillMap ? { skillMap } : {}), ...(actGold ? { actGold } : {}),
739
+ log: (line) => { if (!json) console.error(` ${line}`); },
740
+ });
741
+ // Spec 420 — the act laboratory's verdicts per DOMAIN PRINCIPLE (each case counts toward every principle it cites).
742
+ if (actGold && !json) {
743
+ const cov = cm.principleCoverageOf(rec.runs.map((r) => r.act));
744
+ const rows = Object.entries(cov).sort(([a], [b]) => a.localeCompare(b));
745
+ if (rows.length) console.error(` principles: ${rows.map(([p, v]) => `${p} ${v.right}/${v.total}`).join(' · ')}`);
746
+ }
747
+ out_({ ok: true, plan: rec.plan, planDigest: rec.planDigest, runs: rec.runs.length, refused: rec.refused, scores: Object.fromEntries(Object.entries(rec.scores).map(([k, v]) => [k, v.aggregate])), verdicts: Object.fromEntries(Object.entries(rec.verdicts).map(([k, v]) => [k, { status: v.status, reason: v.binding_reason }])), comparisons: rec.comparisons }, `ap eval compare · ${home} · store ${paths.dir}\n${cm.formatExperiment(rec)}`);
748
+ }
749
+
750
+ const commands = { doctor, upgrade, conform, test, create, mcp, runtime, generate, eval: evalCmd };
174
751
  try {
175
752
  if (!cmd || flag('help') || cmd === '--help' || !commands[cmd]) {
176
753
  if (json) console.log(JSON.stringify({ ok: !cmd || cmd === '--help', usage: USAGE }));