@ngockhoale/ukit 3.1.9 → 3.3.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.
Files changed (34) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/package.json +1 -1
  3. package/src/cli/commands/memory.js +11 -87
  4. package/src/cli/commands/selfImprove.js +55 -0
  5. package/src/cli/commands/telemetry.js +45 -3
  6. package/src/cli/index.js +7 -0
  7. package/src/core/agentRuntime/adapters.js +177 -0
  8. package/src/core/agentRuntime/contract.js +77 -0
  9. package/src/core/agentRuntime/diagnostics.js +343 -42
  10. package/src/core/agentRuntime/eventStore.js +139 -0
  11. package/src/core/agentRuntime/planCompiler.js +45 -6
  12. package/src/core/agentRuntime/planLibrary.js +43 -7
  13. package/src/core/agentRuntime/plans/bugfix-loop.json +1 -0
  14. package/src/core/agentRuntime/plans/flag-promotion.json +138 -0
  15. package/src/core/agentRuntime/plans/handoff-review-batch.json +1 -0
  16. package/src/core/agentRuntime/plans/release-check.json +2 -1
  17. package/src/core/agentRuntime/promotion.js +147 -9
  18. package/src/core/agentRuntime/runtimeSupport.js +18 -0
  19. package/src/core/agentRuntime/supervisor.js +44 -2
  20. package/src/core/agentRuntime/telemetry.js +123 -0
  21. package/src/core/agentRuntime/vmEngine.js +51 -10
  22. package/src/core/memory/episodes.js +168 -0
  23. package/src/core/metadata.js +21 -0
  24. package/src/core/runInstallPipeline.js +6 -1
  25. package/src/core/runtimeConfig.js +71 -40
  26. package/src/decision/client.js +4 -5
  27. package/src/decision/runtimeDecide.js +183 -5
  28. package/src/learning/selfImprove.js +205 -0
  29. package/src/learning/tunedOverlay.js +112 -0
  30. package/template_project/.claude/hooks/session-episode.sh +35 -15
  31. package/template_project/.claude/ukit/index/route-task.mjs +13 -0
  32. package/template_project/.claude/ukit/index/unic-decision.mjs +1 -2
  33. package/template_project/.claude/ukit/runtime/self-improve-trigger.mjs +98 -0
  34. package/template_project/ukit/storage/config.json +68 -17
package/CHANGELOG.md CHANGED
@@ -2,10 +2,37 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.3.0 - 2026-09-27
6
+
7
+ **Zero-config self-improvement: every feature stage ships on, UKit collects and learns from its own data.** Fixes the gap where 2.6.8–3.2.0 built the machinery (flight recorder, memory v2, learning, decision plane) but nothing on the live hook path fed it — measured 1 telemetry record and 0 memory-v2 records after days of use.
8
+
9
+ - **`ukit self-improve` — the automatic collect → learn → apply pass** (`src/learning/selfImprove.js`, `src/cli/commands/selfImprove.js`). One bounded, rate-limited (10 min), lock-guarded pass that runs after every session: (1) backfills an episode for every idle exec-ledger — engine-agnostic, fixes omp session-id mismatch and Codex having no lifecycle hooks; (2) refreshes diagnostics (failure patterns, feedback, skill accuracy); (3) computes tuning suggestions and **auto-applies** them under `learning.tuning.applyMode: 'auto'` (new default) as clamped one-step moves into `.ukit/storage/learning/tuned.json` with a 24h per-key cooldown; (4) writes repeated failure patterns as pending pattern candidates (promotion still needs `ukit memory approve`); (5) ingests stored hook/ledger telemetry, flushes, refreshes the support view. Never throws; each step isolated.
10
+ - **`self-improve-trigger.mjs`** (`template_project/.claude/ukit/runtime/`): fire-and-forget launcher called from Claude `SessionEnd`/`session-episode.sh`, omp `session_stop` via the bridge, and Codex `route-task.mjs` (its only per-task entry). Detached child, returns in ms, silent no-op on any failure; opt out via `learning.selfImprove.enabled: false` or `UKIT_SELF_IMPROVE=0`.
11
+ - **CLI locator** (`.ukit/storage/cli.json`, `writeCliLocator` in `metadata.js`, written by `ukit install`): absolute node + bin paths so hooks spawning the CLI work on GUI-launched hosts whose PATH lacks nvm/volta. `resolveCliLocator` validates node basename, `bin/ukit` shape and `@ngockhoale/ukit` package identity before trusting it; PATH lookup stays the fallback.
12
+ - **All stage flags default on**: `routing.*` → `default`; `memoryV2.decision` → `default`; `learning.candidates`/`overlays` → `default`; `continuity.resumableRun` → `default`; `decisionRuntime.*` → `shadow` (diagnostics → `default`); `experiments.deliberation`/`dynamicWorkflow` → enabled; `observability.stage` added to code defaults (`default`). `learning.tuning.applyMode` accepts `'auto'`; tuned overlay merges via `inspectRuntimeConfig`. Rollback: per-key `stage: "off"`, `applyMode: 'manual'`, or delete `tuned.json`.
13
+ - **`ukit memory episode` refactored** onto shared `src/core/memory/episodes.js` (`resolveLedger`, `episodeText`, `writeEpisode`, `backfillEpisodes`) — one owner for the record shape between the single-session command and the self-improve backfill.
14
+ - Tests: 28 new (tunedOverlay clamp/merge/cooldown, episodes resolve/write/backfill dedupe, trigger locator validation + spawn contract); stale stage-default pins updated to the 3.3.0 contract.
15
+
16
+
17
+
18
+ ## 3.2.0 - 2026-09-26
19
+
20
+ **Agent VM / Language-Compiled Runtime phase 2 (V-01..V-06)** — IR v2 flag-gated opcodes, decisionRuntime.vm stage-promotion machinery, plan library pins, VM decision nodes via unic-decision, host parity adapters, sanitized support bundle. All new surfaces ship `off`.
21
+
22
+ - **Host lane adapters for the owned runner (agent-vm-runtime V2).** `adapters.js` gains the frozen `HOST_ADAPTERS` registry plus `detectHost` (env-marker best-effort ambient engine resolution, ambiguous → `null`) and `probeHostAdapter` — honest per-host capability statuses `supported` / `unsupported-probe` / `unsupported` with typed reasons (`unknown_host`, `process_group_kill_unavailable`, `host_spawn_unavailable`, `live_host_e2e_unproven`); `supported` is reachable only via explicit `liveHostE2E` evidence, never inferred from primitive presence (G-V2). `buildHostCapabilityMap` in `runtimeSupport.js` exposes the map per lane; every lane reports `completionApi: 'eventStore-journal'` — no engine exposes a host-owned durable completion API. `createSupervisor` accepts `opts.host` and per-launch `spec.host` (precedence spec → opts → ambient detect → no lane); a lane probed `unsupported` refuses `start()` typed (`host_unknown`/`host_unsupported`, zero fs writes) while `unsupported-probe` lanes still run the identical spawn → journal → terminal contract. Live-E2E gap for claude-code/codex is documented in the task file — never claimed as supported.
23
+ - **Agent VM V-01 — IR v2 flag gate + versioned IR contract.** IR v2 ops (`PARALLEL`, `TIMEOUT`) now parse only behind an explicit opt-in — `ir.planVersion: 'v2'` on the artifact, `opts.irVersion: 'v2'` on `compilePlan`, a caller `allowedOps` list naming them, or a non-`off` `decisionRuntime.vm` stage forwarded by `planLibrary.loadPlan({config})`. Ungated v2 IR fails with `ir_v2_disabled`; unknown version literals fail `invalid_ir_version`. `contract.js` exports `IR_VERSIONS` (`['v1','v2']`) and `ValidatedPlan` carries `irVersion` derived from ops actually used — v1 plans compile byte-identical under every flag state (planVersion unchanged). RETRY lands as the bounded per-node `retry` policy (side-effect-class aware: non-auto-retryable classes can never claim `maxAttempts > 1`; a crashed non-idempotent node stays `recovery_required` and is never re-run blind). New fault coverage: TIMEOUT firing mid-node on deliver → deterministic `escalate` replay-identically; duplicate delivery at the join is idempotent across a crash (single `completed` fold in the wrapper journal); bounded fan-out honors `maxNodes`.
24
+
25
+ - **V4 plan library: release pinning + spec text.** `planLibrary.loadPlan` now returns `specText` (the human-authored spec each plan was compiled from) and `planVersion`, and enforces `PLAN_PINS` — a shipped plan whose compiled hash drifts from its pin fails with `plan_version_mismatch` instead of silently running a different workflow. Fourth reference plan `flag-promotion` (stage-promotion gate: baseline replay → shadow runs → promote/rollback branch, mirroring `promotion.js`) joins `bugfix-loop`, `handoff-review-batch`, `release-check`. Coverage: artifact-hash stability, vmEngine replay determinism per plan, malformed-artifact rejection, pin drift.
26
+ - **VM decision nodes via `unic-decision` (agent-vm-runtime V5).** `vmEngine` nodes may now carry a bounded `question` block (`{decisionKey?, instruction?, candidates?}`, validated by `validateNodeQuestion` in `contract.js` and carried through `planCompiler`). When `decisionRuntime.vm.stage` is promoted past `off` and no `classifyFn` is injected, unclassifiable events are routed by `createVmClassifyFn` (`src/decision/runtimeDecide.js`) through one bounded `runtime.node_route.v1` / `runtime.node_classify.v1` question — the only two keys a node may ask (registered `rolloutStage: 'off'`, owner `vmEngine`, deterministic escalate fallback). Model answers are data only (`tool_calls` never dispatched); `model_not_found`/timeout/invalid/adapter faults all resolve to the deterministic escalation lane with a `fallbackCode`, never a hard failure — a throwing `classifyFn` now escalates too. Stage `off` (default, absent, or malformed) installs no adapter: zero decision calls, byte-identical behaviour.
27
+
28
+ - **Agent VM V-06 — per-run support bundle** (`src/core/agentRuntime/diagnostics.js` `exportSupportBundle`, `eventStore.js` `readJournalExcerpt` + `summarizeEvent`, `telemetry.js` `buildTraceExcerpt`, `ukit telemetry export-run <operationId>`): one sanitized bundle per operation lands in the Data Foundation support layout — `Documents/UKit Support/proj-<sha256>/run-<sha256>/` with `journal.jsonl` (bounded code summaries only), `trace.jsonl` (double-gated span excerpt, deterministic pseudonyms), `SUMMARY.md` and a `ukit-support/1` manifest with sha256 checksums, importable via `ukit telemetry import`. Privacy: redaction before persist — secret/path/PII rules + `sanitizeForSupport` on trace records + a final assembled-bytes re-scan that blocks all writes on any residual secret; no raw prompt/diff/path content can appear. `decisionRuntime.diagnostics.stage: 'off'` → `skipped` with zero writes. Missing/corrupt journals still emit bundles with declared `gap` markers; a 256 KiB cap drops excerpt lines deterministically (`coverage.dropped_lines`).
29
+
5
30
  ## 3.1.9 - 2026-09-26
6
31
 
7
32
  - **Checkpoint locked to `unic-decision` for all classes.** Owner decision: the single `unic-decision` model answers every decision (English, multilingual, unknown) — the provider swaps the backend (Lava/JEV) behind that name, so UKit no longer routes per-language. `checkpoints.{default,english,unknownLanguage}` all resolve to `unic-decision` in code + shipped config; `unic-decision-multilingual` removed from defaults (remains configurable via `decisionPlane.checkpoints` if ever needed). Endpoint config unchanged: `ukit decision` / `gatewayDecision.json` / `UKIT_DECISION_*` env.
8
33
 
34
+ - **Agent VM V-03 — stage-promotion machinery** (`src/core/agentRuntime/promotion.js`, `src/core/runtimeConfig.js`): new `resolveDecisionRuntimeStage(config, key)` resolves `decisionRuntime.<key>.stage` with `decisionPlane` semantics (absent/malformed → `off`); new pure `promote(config, evidence)` advances one family flag `off → shadow → canary → default` only when the frozen `PROMOTION_CRITERIA` hold on ≥ `minSampledRuns` sampled runs (quality delta ≥ `-QUALITY_SCORE_FLOOR`, wall-p95 and cost ratios ≤ baseline), and instant rollback (`evidence.rollback` or any forbidden failure) resolves `off` — deterministic owners authoritative, zero VM calls. `off` never self-promotes and one noisy run never flips a default. All `decisionRuntime.*` stages stay `off`: machinery only, no rollout flip.
35
+
9
36
  ## 3.1.8 - 2026-09-26
10
37
 
11
38
  - **Agent VM IR v2**: `PARALLEL` (bounded fan-out, `join:'all'`) and `TIMEOUT` (deadline wrapper, `onTimeout: fail|escalate`, 30-minute ceiling) opcodes added to `planCompiler` + `vmEngine`; fault-injection coverage for crash-between-fan-out-and-join, non-idempotent child recovery, duplicate delivery. v2 plans hash under a `v2:`-prefixed serialization; v1 hashes unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.1.9",
3
+ "version": "3.3.0",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,7 +29,7 @@ import { buildRuntimePaths } from '../../core/runtimePaths.js';
29
29
  import { buildUserPaths } from '../../core/userPaths.js';
30
30
  import { pathExists } from '../../core/fileOps.js';
31
31
  import { detectProjectContext } from '../../context/detectProjectContext.js';
32
- import { listLedgerFiles, LEDGER_DIR_REL } from '../../diagnostics/ledgerFiles.js';
32
+ import { episodeText, resolveLedger, writeEpisode } from '../../core/memory/episodes.js';
33
33
  import fs from 'node:fs/promises';
34
34
  import os from 'node:os';
35
35
  import path from 'node:path';
@@ -520,53 +520,8 @@ async function runMemoryPromote(projectRoot, homeDir, args) {
520
520
  }
521
521
 
522
522
  // ---- `ukit memory episode` — session episode from exec-ledger (SPEC §7b) ----
523
-
524
- // Mirrors execution-ledger.mjs safeSegment — src/ cannot import the runtime
525
- // module, so the segment rule is duplicated deliberately.
526
- function safeSegment(value) {
527
- return String(value || 'default')
528
- .trim()
529
- .replace(/[^a-zA-Z0-9._-]/g, '_')
530
- .slice(0, 96) || 'default';
531
- }
532
-
533
- async function readJsonIfExists(filePath) {
534
- try {
535
- return JSON.parse(await fs.readFile(filePath, 'utf8'));
536
- } catch {
537
- return null;
538
- }
539
- }
540
-
541
- // Resolution order: --session <id> → UKIT_SESSION_ID env → most-recent ledger.
542
- async function resolveLedger(projectRoot, sessionFlag) {
543
- const dir = path.join(projectRoot, LEDGER_DIR_REL);
544
- const candidate = sessionFlag ?? process.env.UKIT_SESSION_ID;
545
- if (candidate) {
546
- const file = `${safeSegment(candidate)}.json`;
547
- const ledger = await readJsonIfExists(path.join(dir, file));
548
- return ledger ? { ledger, ledgerKey: file } : { ledger: null, ledgerKey: file };
549
- }
550
- const [name] = await listLedgerFiles(dir, 1);
551
- if (!name) return { ledger: null, ledgerKey: null };
552
- const ledger = await readJsonIfExists(path.join(dir, name));
553
- return ledger ? { ledger, ledgerKey: name } : { ledger: null, ledgerKey: name };
554
- }
555
-
556
- function episodeText(ledger, sessionId) {
557
- const write = ledger.writeSucceeded === true ? 'writeOk' : 'writeFail';
558
- const verify = ledger.verificationAttempted !== true
559
- ? 'not-run'
560
- : ledger.verificationSucceeded === true ? 'ok' : 'fail';
561
- const receipts = Array.isArray(ledger.receipts) ? ledger.receipts : [];
562
- const lastReceipt = receipts.length > 0 ? receipts[receipts.length - 1] : null;
563
- const lastCommand = lastReceipt
564
- ? String(lastReceipt?.command ?? '').split('\n')[0].trim() || 'n/a'
565
- : 'n/a';
566
- const text = `Session ${sessionId}: ${write}, verify=${verify}, `
567
- + `${receipts.length} receipts, last command ${lastCommand}`;
568
- return text.slice(0, 300);
569
- }
523
+ // Record shape + dedupe live in core/memory/episodes.js (shared with the
524
+ // self-improve backfill).
570
525
 
571
526
  async function runMemoryEpisode(projectRoot, homeDir, args) {
572
527
  const dryRun = args.includes('--dry-run');
@@ -578,62 +533,31 @@ async function runMemoryEpisode(projectRoot, homeDir, args) {
578
533
  return;
579
534
  }
580
535
 
581
- const { ledger, ledgerKey } = await resolveLedger(projectRoot, sessionFlag);
536
+ const { ledger, ledgerKey } = await resolveLedger(projectRoot, sessionFlag ?? process.env.UKIT_SESSION_ID);
582
537
  if (!ledger) {
583
538
  console.log('[UKit] episode: nothing to record');
584
539
  return;
585
540
  }
586
541
 
587
- const sessionId = ledger.sessionId ?? ledger.sessionKey
588
- ?? (ledgerKey ? ledgerKey.replace(/\.json$/, '') : 'unknown');
589
- const text = episodeText(ledger, sessionId);
590
-
591
542
  if (dryRun) {
592
- console.log(`[UKit] episode (dry-run): ${text}`);
593
- return;
594
- }
595
-
596
- const existing = await loadRecords(projectRoot, { homeDir });
597
- if (existing.some((r) => r.meta?.ledgerKey === ledgerKey)) {
598
- console.log('[UKit] episode: already recorded');
543
+ const sessionId = ledger.sessionId ?? ledger.sessionKey ?? ledgerKey.replace(/\.json$/, '');
544
+ console.log(`[UKit] episode (dry-run): ${episodeText(ledger, sessionId)}`);
599
545
  return;
600
546
  }
601
547
 
602
- const ttlDays = Number(config?.memoryV2?.episodeTtlDays) || 90;
603
- const projectId = (await detectProjectContext(projectRoot, { homeDir })).project.name;
604
- // FR-023: the add goes through mutateMemory with the ledger key as the
605
- // idempotency key — a hook-invoked rerun is a journal 'duplicate' even if
606
- // the meta.ledgerKey fast-path above is bypassed.
607
- const res = await mutateMemory(
608
- { kind: 'project', projectRoot, homeDir },
609
- {
610
- op: 'add',
611
- idempotencyKey: ledgerKey,
612
- payload: {
613
- type: 'episode',
614
- scope: 'session',
615
- text,
616
- provenance: 'exec-ledger',
617
- confidence: 0.6,
618
- createdBy: 'episode-hook',
619
- projectId,
620
- validUntil: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
621
- meta: { sessionId, ledgerKey },
622
- },
623
- },
624
- );
548
+ const res = await writeEpisode(projectRoot, { ledger, ledgerKey, config, homeDir });
625
549
  if (res.status === 'duplicate') {
626
550
  console.log('[UKit] episode: already recorded');
627
551
  return;
628
552
  }
629
553
  if (res.status === 'rejected') {
630
- printGuardRejection(res);
554
+ printGuardRejection(res.result);
631
555
  return;
632
556
  }
633
- if (res.status !== 'ok') {
634
- throw new Error(`episode write failed: ${res.status}${res.reason ? ` — ${res.reason}` : ''}`);
557
+ if (res.status !== 'recorded') {
558
+ throw new Error(`episode write failed: ${res.reason}`);
635
559
  }
636
- console.log(`[UKit] episode: recorded ${res.record.id} for session ${sessionId}`);
560
+ console.log(`[UKit] episode: recorded ${res.id} for session ${res.sessionId}`);
637
561
  }
638
562
 
639
563
  // ---- `ukit memory backup|restore|doctor` — operator lane (SPEC §9, FR-011) ----
@@ -0,0 +1,55 @@
1
+ // `ukit self-improve` — run the automatic collect → learn → apply pass now.
2
+ // Hooks trigger it detached (self-improve-trigger.mjs); this is the manual
3
+ // and the trigger's own entry point. `--if-due` honors the rate-limit stamp
4
+ // (what triggers pass); without it the pass is forced.
5
+
6
+ import { runSelfImprove } from '../../learning/selfImprove.js';
7
+
8
+ const HELP_FLAGS = new Set(['--help', '-h', 'help']);
9
+ const KNOWN_FLAGS = new Set(['--if-due', '--json', '--quiet']);
10
+
11
+ function printUsage() {
12
+ console.log('Usage: ukit self-improve [--if-due] [--json] [--quiet]');
13
+ console.log('');
14
+ console.log('Runs one self-improve pass: episode backfill, diagnostics, auto-tuning,');
15
+ console.log('pattern proposals, telemetry collect. Hooks run it automatically.');
16
+ console.log('');
17
+ console.log(' --if-due Skip when the last pass ran < 10 minutes ago (hook mode)');
18
+ console.log(' --json JSON output');
19
+ console.log(' --quiet No output (hook mode)');
20
+ }
21
+
22
+ function describeStep(stepResult) {
23
+ const { name, status, ...rest } = stepResult;
24
+ const details = Object.entries(rest)
25
+ .filter(([, value]) => value !== undefined && value !== null)
26
+ .map(([key, value]) => `${key}=${Array.isArray(value) ? value.length : value}`)
27
+ .join(' ');
28
+ return ` ${name}: ${status}${details ? ` ${details}` : ''}`;
29
+ }
30
+
31
+ export async function runSelfImproveCommand({ projectRoot, argv = [] }) {
32
+ if (argv.some((flag) => HELP_FLAGS.has(flag))) {
33
+ printUsage();
34
+ return;
35
+ }
36
+ const unknown = argv.filter((flag) => !KNOWN_FLAGS.has(flag));
37
+ if (unknown.length > 0) {
38
+ console.error(`[UKit] Unknown self-improve argument(s): ${unknown.join(', ')}`);
39
+ printUsage();
40
+ process.exitCode = 1;
41
+ return;
42
+ }
43
+ const result = await runSelfImprove(projectRoot, { force: !argv.includes('--if-due') });
44
+ if (argv.includes('--quiet')) return;
45
+ if (argv.includes('--json')) {
46
+ console.log(JSON.stringify(result, null, 2));
47
+ return;
48
+ }
49
+ if (result.status !== 'ran') {
50
+ console.log(`[UKit] self-improve: skipped (${result.reason})`);
51
+ return;
52
+ }
53
+ console.log('[UKit] self-improve: ran');
54
+ for (const stepResult of result.steps) console.log(describeStep(stepResult));
55
+ }
@@ -42,7 +42,7 @@ import { validateSupportBundle } from '../../core/observability/support/import.j
42
42
  import { runEvaluation } from '../../core/observability/evaluation/runner.js';
43
43
 
44
44
  const HELP_FLAGS = new Set(['--help', '-h', 'help']);
45
- const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'import', 'evaluate']);
45
+ const SUBCOMMANDS = new Set(['collect', 'status', 'digest', 'export-support', 'export-run', 'import', 'evaluate']);
46
46
  const JSON_SUBCOMMANDS = new Set(['status', 'digest', 'evaluate']);
47
47
 
48
48
  // The automatic support refresh's canary gate lives in schedule.js via
@@ -62,6 +62,7 @@ function printUsage() {
62
62
  console.log(' status Show stage, segments, counters, support lag, crashes');
63
63
  console.log(' digest Rebuild the trace index; print anomalies + digest markdown');
64
64
  console.log(' export-support Write the sanitized UKit Support bundle now');
65
+ console.log(' export-run <op> Write a sanitized per-run support bundle (V-06)');
65
66
  console.log(' import <path> Validate a received support bundle (zip or directory)');
66
67
  console.log(' evaluate Run the AI evaluator lane (needs observability.evaluator config)');
67
68
  console.log('');
@@ -370,6 +371,45 @@ async function exportSupportCommand({ projectRoot, config }) {
370
371
  process.exitCode = 2;
371
372
  }
372
373
 
374
+ // --- export-run (V-06) -------------------------------------------------------
375
+
376
+ /**
377
+ * `export-run <operationId>` — stage-gated per-operation support bundle.
378
+ * Unlike export-support this is NOT lifted past the stage gate: the
379
+ * bundle lives under decisionRuntime.diagnostics, and 'off' means zero
380
+ * writes by contract (SPEC G7-FR04).
381
+ */
382
+ async function exportRunCommand({ projectRoot, config, operationId }) {
383
+ const { exportSupportBundle } = await import('../../core/agentRuntime/diagnostics.js');
384
+ const runtimeDir = path.join(projectRoot, '.ukit', 'storage', 'agent-runtime');
385
+ let res;
386
+ try {
387
+ res = await exportSupportBundle(runtimeDir, operationId, {
388
+ config,
389
+ projectKey: projectRoot,
390
+ traceRoot: segmentsRoot(projectRoot),
391
+ });
392
+ } catch {
393
+ res = { status: 'degraded', reason: 'bundle_error', bytes: 0 };
394
+ }
395
+ if (res.status === 'written') {
396
+ // Pseudonymous location only — the absolute path is user-private.
397
+ const proj = path.basename(path.dirname(res.dir));
398
+ const run = path.basename(res.dir);
399
+ console.log(
400
+ `export-run: written bytes=${res.bytes ?? 0} ` +
401
+ `dir=${SUPPORT_DIR_NAME}/${proj}/${run}`,
402
+ );
403
+ return;
404
+ }
405
+ console.log(
406
+ `export-run: ${res.status}` +
407
+ (res.reason ? ` reason=${res.reason}` : '') +
408
+ ` bytes=${res.bytes ?? 0}`,
409
+ );
410
+ process.exitCode = 2;
411
+ }
412
+
373
413
  async function importCommand({ bundlePath }) {
374
414
  const res = await validateSupportBundle({ path: bundlePath });
375
415
  if (!res.ok) {
@@ -434,7 +474,7 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
434
474
  const flags = rest.filter((a) => a.startsWith('-'));
435
475
  const allowed = JSON_SUBCOMMANDS.has(sub) ? new Set(['--json']) : new Set();
436
476
  const unknownFlags = flags.filter((f) => !allowed.has(f));
437
- const maxPositional = sub === 'import' ? 1 : 0;
477
+ const maxPositional = (sub === 'import' || sub === 'export-run') ? 1 : 0;
438
478
 
439
479
  if (unknownFlags.length > 0) {
440
480
  console.error(`[UKit] Unknown telemetry flag(s): ${unknownFlags.join(', ')}`);
@@ -442,7 +482,8 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
442
482
  process.exitCode = 1;
443
483
  return;
444
484
  }
445
- if (positional.length > maxPositional || (sub === 'import' && positional.length === 0)) {
485
+ const wantsOperand = sub === 'import' || sub === 'export-run';
486
+ if (positional.length > maxPositional || (wantsOperand && positional.length === 0)) {
446
487
  printUsage();
447
488
  process.exitCode = 1;
448
489
  return;
@@ -455,6 +496,7 @@ export async function runTelemetry({ projectRoot, packageRoot, argv = [] }) {
455
496
  if (sub === 'status') return statusCommand({ projectRoot, config, json });
456
497
  if (sub === 'digest') return digestCommand({ projectRoot, json });
457
498
  if (sub === 'export-support') return exportSupportCommand({ projectRoot, config });
499
+ if (sub === 'export-run') return exportRunCommand({ projectRoot, config, operationId: positional[0] });
458
500
  if (sub === 'evaluate') return evaluateCommand({ projectRoot, config, json });
459
501
  return importCommand({ bundlePath: positional[0] });
460
502
  }
package/src/cli/index.js CHANGED
@@ -12,6 +12,7 @@ import { runTelemetry } from './commands/telemetry.js';
12
12
  import { runFeedback } from './commands/feedback.js';
13
13
  import { runPlaybook } from './commands/playbook.js';
14
14
  import { runDecision } from './commands/decision.js';
15
+ import { runSelfImproveCommand } from './commands/selfImprove.js';
15
16
  const GLOBAL_FLAGS = new Set(['--help', '-h', '--version', '-v']);
16
17
 
17
18
  export async function runCli({ argv, packageRoot, projectRoot, packageVersion }) {
@@ -89,6 +90,11 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
89
90
  return;
90
91
  }
91
92
 
93
+ if (command === 'self-improve') {
94
+ await runSelfImproveCommand({ projectRoot, argv: commandArgv });
95
+ return;
96
+ }
97
+
92
98
  if (command === 'update') {
93
99
  await runUpdate({ packageVersion, argv: commandArgv });
94
100
  return;
@@ -131,6 +137,7 @@ export async function runCli({ argv, packageRoot, projectRoot, packageVersion })
131
137
  console.log(' metrics Telemetry roll-up (route outcomes, failure patterns, memory)');
132
138
  console.log(' telemetry Flight recorder (collect/status/digest/export-support/import/evaluate)');
133
139
  console.log(' feedback Record or list wrong-route feedback labels');
140
+ console.log(' self-improve Run the automatic learn/tune/collect pass now (hooks run it)');
134
141
  console.log(' update Upgrade the global UKit CLI to the latest version');
135
142
  console.log(' version Show UKit version');
136
143
  console.log('');
@@ -27,12 +27,22 @@
27
27
  * recorder via telemetry.js — synchronous, never-throw, stage-gated
28
28
  * inside emit(). `now` and `artifactStore` are injectable for tests.
29
29
  *
30
+ * Host lane adapters (V-02, AGENT_VM_RUNTIME_PLAN item V2) live at the
31
+ * end of this module: HOST_ADAPTERS is the frozen capability registry
32
+ * for the owned-runner host lanes (omp / claude-code / codex),
33
+ * detectHost best-effort resolves the ambient engine session from env
34
+ * markers, and probeHostAdapter evaluates one lane against the current
35
+ * environment + injectable primitives — honest statuses only
36
+ * ('supported' | 'unsupported-probe' | 'unsupported'), never a faked
37
+ * supported.
38
+ *
30
39
  * `artifactStore` protocol (injected): `put(buffer)` →
31
40
  * `Promise<string | { path: string }>`; the returned ref is recorded on
32
41
  * `event.artifactRefs` and in the result `artifactRefs` list.
33
42
  */
34
43
 
35
44
  import crypto from 'node:crypto';
45
+ import { spawn } from 'node:child_process';
36
46
 
37
47
  import {
38
48
  validateSemanticEvent,
@@ -333,3 +343,170 @@ export async function adaptOutput({
333
343
  }
334
344
  return { events: [event], artifactRefs, parseStatus };
335
345
  }
346
+
347
+ // --- V-02: host lane adapters (AGENT_VM_RUNTIME_PLAN item V2) ---------
348
+ // The owned runner (supervisor.js) is primitive-level identical on every
349
+ // engine: Node child_process spawn, detached process-group kill, and
350
+ // kill(pid, 0) liveness — UKit-owned children only; no engine's own
351
+ // process lifecycle is hijacked. The lanes below describe that contract
352
+ // per host honestly: no engine exposes a host-owned durable completion
353
+ // API, so completionApi is UKit's own durable journal on every lane.
354
+ //
355
+ // Status ladder (G-V2 — never fake a completion signal):
356
+ // 'supported' — primitives present AND live host E2E evidence
357
+ // was explicitly declared (opts.liveHostE2E).
358
+ // Absent live evidence a probe never upgrades.
359
+ // 'unsupported-probe' — the lane contract is available at the
360
+ // primitive level but live host E2E has NOT
361
+ // been proven in this environment. The owned
362
+ // runner may launch; capability reports stay
363
+ // honest about the evidence gap.
364
+ // 'unsupported' — a required primitive is absent (win32 process-
365
+ // group kill, no spawnImpl, …) or the host is
366
+ // unknown. Never runnable.
367
+
368
+ export const HOST_NAMES = Object.freeze(['omp', 'claude-code', 'codex']);
369
+
370
+ export const HOST_SUPPORTED = 'supported';
371
+ export const HOST_UNSUPPORTED_PROBE = 'unsupported-probe';
372
+ export const HOST_UNSUPPORTED = 'unsupported';
373
+
374
+ // No engine exposes a host-owned durable completion API today; every
375
+ // lane's durable completion channel is the eventStore journal.
376
+ export const OWNED_COMPLETION_API = 'eventStore-journal';
377
+
378
+ // Frozen per-lane descriptor — static contract only; environment truth
379
+ // comes from probeHostAdapter, never from this table.
380
+ export const HOST_ADAPTERS = Object.freeze({
381
+ 'omp': Object.freeze({
382
+ name: 'omp',
383
+ completionApi: OWNED_COMPLETION_API,
384
+ ownedProcessGroups: true,
385
+ }),
386
+ 'claude-code': Object.freeze({
387
+ name: 'claude-code',
388
+ completionApi: OWNED_COMPLETION_API,
389
+ ownedProcessGroups: true,
390
+ }),
391
+ 'codex': Object.freeze({
392
+ name: 'codex',
393
+ completionApi: OWNED_COMPLETION_API,
394
+ ownedProcessGroups: true,
395
+ }),
396
+ });
397
+
398
+ // Documented session env markers for ambient engine detection. Explicit
399
+ // identity always wins: UKIT_HOST, then UKIT_AGENT_ID, then the marker
400
+ // sets — a hit in >1 marker set is ambiguous and resolves null (never a
401
+ // guessed lane).
402
+ const HOST_ENV_MARKERS = {
403
+ 'omp': ['OMP_HOME', 'OMP_SESSION_ID', 'ORCA_OMP_SOURCE_AGENT_DIR'],
404
+ 'claude-code': ['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT'],
405
+ 'codex': ['CODEX_SANDBOX', 'CODEX_CI', 'ORCA_CODEX_HOME'],
406
+ };
407
+
408
+ /**
409
+ * Best-effort ambient host resolution.
410
+ * @param {object} [env] default process.env
411
+ * @returns {string|null} a HOST_NAMES member, or null when absent/ambiguous.
412
+ */
413
+ export function detectHost(env = process.env) {
414
+ const source = env && typeof env === 'object' ? env : {};
415
+ const explicit = source.UKIT_HOST ?? source.UKIT_AGENT_ID;
416
+ if (typeof explicit === 'string' && HOST_ADAPTERS[explicit]) return explicit;
417
+ const hits = HOST_NAMES.filter(
418
+ (name) => HOST_ENV_MARKERS[name].some((key) => source[key] != null),
419
+ );
420
+ return hits.length === 1 ? hits[0] : null;
421
+ }
422
+
423
+ /**
424
+ * Evaluate one host lane against the current environment.
425
+ *
426
+ * @param {string} name lane name (HOST_NAMES member or arbitrary string)
427
+ * @param {object} [opts]
428
+ * @param {boolean} [opts.liveHostE2E] declare live host E2E evidence —
429
+ * the ONLY path to 'supported'. Never derived from primitive presence.
430
+ * @param {Function} [opts.spawnImpl] required primitive (default Node spawn)
431
+ * @param {Function} [opts.killImpl] required primitive (default process.kill)
432
+ * @param {Function} [opts.probeImpl] required liveness primitive (default sig-0)
433
+ * @param {string} [opts.platform] default process.platform
434
+ * @returns {{name:string|null, supported:string, reason:string|null,
435
+ * completionApi:string|null, primitives:object}}
436
+ */
437
+ export function probeHostAdapter(name, opts = {}) {
438
+ const descriptor = HOST_ADAPTERS[name];
439
+ if (!descriptor) {
440
+ return {
441
+ name: null,
442
+ supported: HOST_UNSUPPORTED,
443
+ reason: 'unknown_host',
444
+ completionApi: null,
445
+ primitives: {},
446
+ };
447
+ }
448
+ const platform = typeof opts.platform === 'string' ? opts.platform : process.platform;
449
+ // `undefined` picks the Node defaults; any other non-function value is a
450
+ // caller-supplied (possibly deliberately absent) primitive — probes must
451
+ // evaluate what was given, never silently fall back.
452
+ const spawnImpl = opts.spawnImpl === undefined
453
+ ? (argv, spawnOpts) => spawn(argv[0], argv.slice(1), spawnOpts)
454
+ : opts.spawnImpl;
455
+ const killImpl = opts.killImpl === undefined
456
+ ? (signal, target) => process.kill(target, signal)
457
+ : opts.killImpl;
458
+ const probeImpl = opts.probeImpl === undefined
459
+ ? (pid) => {
460
+ try { process.kill(pid, 0); return true; } catch { return false; }
461
+ }
462
+ : opts.probeImpl;
463
+ const primitives = {
464
+ spawn: typeof spawnImpl === 'function',
465
+ processGroupKill: platform !== 'win32' && typeof killImpl === 'function',
466
+ livenessProbe: typeof probeImpl === 'function',
467
+ };
468
+ if (platform === 'win32') {
469
+ return {
470
+ name,
471
+ supported: HOST_UNSUPPORTED,
472
+ reason: 'process_group_kill_unavailable',
473
+ completionApi: descriptor.completionApi,
474
+ primitives,
475
+ };
476
+ }
477
+ if (!primitives.spawn || !primitives.livenessProbe || !primitives.processGroupKill) {
478
+ return {
479
+ name,
480
+ supported: HOST_UNSUPPORTED,
481
+ reason: 'host_spawn_unavailable',
482
+ completionApi: descriptor.completionApi,
483
+ primitives,
484
+ };
485
+ }
486
+ if (opts.liveHostE2E === true) {
487
+ return {
488
+ name,
489
+ supported: HOST_SUPPORTED,
490
+ reason: null,
491
+ completionApi: descriptor.completionApi,
492
+ primitives,
493
+ };
494
+ }
495
+ return {
496
+ name,
497
+ supported: HOST_UNSUPPORTED_PROBE,
498
+ reason: 'live_host_e2e_unproven',
499
+ completionApi: descriptor.completionApi,
500
+ primitives,
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Probe every registered lane. Returns `{hostName: probe}` — one entry
506
+ * per HOST_NAMES member, in registry order.
507
+ */
508
+ export function listHostAdapters(opts = {}) {
509
+ const out = {};
510
+ for (const name of HOST_NAMES) out[name] = probeHostAdapter(name, opts);
511
+ return out;
512
+ }
@@ -52,6 +52,17 @@ export const SIDE_EFFECT_CLASSES = Object.freeze([
52
52
  'destructive',
53
53
  ]);
54
54
 
55
+ // ---------------------------------------------------------------------------
56
+ // V-01 — IR versioning (plan-language level, independent of CONTRACT_VERSION).
57
+
58
+ /**
59
+ * Frozen IR versions understood by planCompiler. 'v1' is the original opcode
60
+ * set (RUN, WAIT_EVENT, BRANCH, COMPLETE, ESCALATE); 'v2' adds the wrapper
61
+ * ops PARALLEL and TIMEOUT. A plan opts into v2 via `ir.planVersion: 'v2'`
62
+ * or an enabled caller channel — v1 IR never parses v2 ops by default.
63
+ */
64
+ export const IR_VERSIONS = Object.freeze(['v1', 'v2']);
65
+
55
66
  /** Classes that MAY auto-retry under bounded attempts + backoff (SPEC §5). */
56
67
  const AUTO_RETRYABLE = new Set(['pure', 'read_only', 'idempotent_write']);
57
68
 
@@ -245,3 +256,69 @@ export function validateRetry(sideEffectClass, attempt, policy = {}) {
245
256
 
246
257
  return ok();
247
258
  }
259
+
260
+ // ---------------------------------------------------------------------------
261
+ // V-05 — VM decision-node question block + classify verdict shapes.
262
+
263
+ /** Verdict actions a classifyFn may return (agent-vm-runtime V5). */
264
+ export const CLASSIFY_ACTIONS = Object.freeze(['route', 'escalate']);
265
+
266
+ /**
267
+ * Validate a classifyFn verdict — the bounded decision-node answer shape:
268
+ * `{action:'route', outcome:string}` routes the event into the node's
269
+ * deterministic transition table (which still gates the outcome); any other
270
+ * valid verdict escalates onto the deterministic lane.
271
+ *
272
+ * @param {object} verdict
273
+ * @returns {{ok:true}|{ok:false, code:string}}
274
+ */
275
+ export function validateClassifyVerdict(verdict) {
276
+ if (verdict === null || typeof verdict !== 'object' || Array.isArray(verdict)) {
277
+ return reject('malformed_verdict');
278
+ }
279
+ if (verdict.action === 'escalate') {
280
+ return ok();
281
+ }
282
+ if (verdict.action === 'route' && typeof verdict.outcome === 'string' && verdict.outcome.length > 0) {
283
+ return ok();
284
+ }
285
+ return reject('malformed_verdict');
286
+ }
287
+
288
+ /**
289
+ * Validate a VM node's optional `question` block — the bounded
290
+ * classify/select question the decision plane may answer when the plan IR
291
+ * cannot route an event. Absent is valid (nodes need not ask); a malformed
292
+ * block is rejected wholesale, never partially honored.
293
+ *
294
+ * @param {object} question `{decisionKey?, instruction?, candidates?}` —
295
+ * each declared field must be a non-empty bounded string / string list.
296
+ * @param {object} [bounds] `{maxText?, maxCandidates?}`
297
+ * @returns {{ok:true}|{ok:false, code:string}}
298
+ */
299
+ export function validateNodeQuestion(question, bounds = {}) {
300
+ if (question === undefined) {
301
+ return ok();
302
+ }
303
+ if (question === null || typeof question !== 'object' || Array.isArray(question)) {
304
+ return reject('malformed_question');
305
+ }
306
+ const maxText = Number.isInteger(bounds.maxText) && bounds.maxText > 0 ? bounds.maxText : 240;
307
+ const maxCandidates = Number.isInteger(bounds.maxCandidates) && bounds.maxCandidates > 0 ? bounds.maxCandidates : 8;
308
+ const bounded = (v) => typeof v === 'string' && v.length > 0 && v.length <= maxText;
309
+ if (question.decisionKey !== undefined && !bounded(question.decisionKey)) {
310
+ return reject('malformed_question');
311
+ }
312
+ if (question.instruction !== undefined && !bounded(question.instruction)) {
313
+ return reject('malformed_question');
314
+ }
315
+ if (question.candidates !== undefined) {
316
+ if (!Array.isArray(question.candidates)
317
+ || question.candidates.length === 0
318
+ || question.candidates.length > maxCandidates
319
+ || !question.candidates.every(bounded)) {
320
+ return reject('malformed_question');
321
+ }
322
+ }
323
+ return ok();
324
+ }