@ngockhoale/ukit 3.2.0 → 3.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,23 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.3.1 - 2026-09-27
6
+
7
+ - **Republish of 3.3.0, content-identical.** npm staged 3.3.0 then returned E409 "previously staged version" on retry — same staged-limbo pattern as 3.0.11→3.0.12. Version bump only; parity verified below.
8
+
9
+ ## 3.3.0 - 2026-09-27
10
+
11
+ **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.
12
+
13
+ - **`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.
14
+ - **`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`.
15
+ - **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.
16
+ - **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`.
17
+ - **`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.
18
+ - 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.
19
+
20
+
21
+
5
22
  ## 3.2.0 - 2026-09-26
6
23
 
7
24
  **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`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.2.0",
3
+ "version": "3.3.1",
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
+ }
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('');
@@ -0,0 +1,168 @@
1
+ // episodes.js — session episode records from exec-ledgers (SPEC §7b).
2
+ //
3
+ // One episode per exec-ledger file, keyed (and deduped) by the ledger file
4
+ // name. Shared by `ukit memory episode` (single session) and the self-improve
5
+ // cycle (backfill every idle ledger), so the record shape has one owner.
6
+ //
7
+ // Engine-agnostic by construction: the self-improve backfill walks the ledger
8
+ // directory instead of trusting a stop event's session id — omp's
9
+ // `session_stop` id and the session key its tool events write under can
10
+ // differ, and Codex has no stop event at all.
11
+
12
+ import fs from 'node:fs/promises';
13
+ import path from 'node:path';
14
+
15
+ import { loadRecords } from './storeV2.js';
16
+ import { mutateMemory } from './mutateMemory.js';
17
+ import { detectProjectContext } from '../../context/detectProjectContext.js';
18
+ import { listLedgerFiles, LEDGER_DIR_REL } from '../../diagnostics/ledgerFiles.js';
19
+
20
+ const DAY_MS = 24 * 60 * 60 * 1000;
21
+
22
+ // Mirrors execution-ledger.mjs safeSegment — src/ cannot import the runtime
23
+ // module, so the segment rule is duplicated deliberately.
24
+ export function safeSegment(value) {
25
+ return String(value || 'default')
26
+ .trim()
27
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
28
+ .slice(0, 96) || 'default';
29
+ }
30
+
31
+ async function readJsonIfExists(filePath) {
32
+ try {
33
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ // Resolution order: explicit session id → most-recent ledger.
40
+ export async function resolveLedger(projectRoot, sessionId) {
41
+ const dir = path.join(projectRoot, LEDGER_DIR_REL);
42
+ if (sessionId) {
43
+ const file = `${safeSegment(sessionId)}.json`;
44
+ const ledger = await readJsonIfExists(path.join(dir, file));
45
+ return { ledger, ledgerKey: file };
46
+ }
47
+ const [name] = await listLedgerFiles(dir, 1);
48
+ if (!name) return { ledger: null, ledgerKey: null };
49
+ return { ledger: await readJsonIfExists(path.join(dir, name)), ledgerKey: name };
50
+ }
51
+
52
+ export function episodeText(ledger, sessionId) {
53
+ const write = ledger.writeSucceeded === true ? 'writeOk' : 'writeFail';
54
+ const verify = ledger.verificationAttempted !== true
55
+ ? 'not-run'
56
+ : ledger.verificationSucceeded === true ? 'ok' : 'fail';
57
+ const receipts = Array.isArray(ledger.receipts) ? ledger.receipts : [];
58
+ const lastReceipt = receipts.length > 0 ? receipts[receipts.length - 1] : null;
59
+ const lastCommand = lastReceipt
60
+ ? String(lastReceipt?.command ?? '').split('\n')[0].trim() || 'n/a'
61
+ : 'n/a';
62
+ const text = `Session ${sessionId}: ${write}, verify=${verify}, `
63
+ + `${receipts.length} receipts, last command ${lastCommand}`;
64
+ return text.slice(0, 300);
65
+ }
66
+
67
+ function ledgerSessionId(ledger, ledgerKey) {
68
+ return ledger.sessionId ?? ledger.sessionKey
69
+ ?? (ledgerKey ? ledgerKey.replace(/\.json$/, '') : 'unknown');
70
+ }
71
+
72
+ // Writes one episode for `ledger`. Returns
73
+ // { status: 'recorded', id, sessionId } | { status: 'duplicate' }
74
+ // | { status: 'rejected', result } | { status: 'failed', reason }.
75
+ // `existingKeys` (Set of ledgerKeys already recorded) skips the store read
76
+ // when the caller batches.
77
+ export async function writeEpisode(projectRoot, {
78
+ ledger,
79
+ ledgerKey,
80
+ config,
81
+ homeDir,
82
+ projectId,
83
+ existingKeys,
84
+ } = {}) {
85
+ const sessionId = ledgerSessionId(ledger, ledgerKey);
86
+ const keys = existingKeys ?? new Set(
87
+ (await loadRecords(projectRoot, { homeDir })).map((r) => r.meta?.ledgerKey).filter(Boolean),
88
+ );
89
+ if (keys.has(ledgerKey)) return { status: 'duplicate' };
90
+
91
+ const ttlDays = Number(config?.memoryV2?.episodeTtlDays) || 90;
92
+ const resolvedProjectId = projectId
93
+ ?? (await detectProjectContext(projectRoot, { homeDir })).project.name;
94
+ // FR-023: the ledger key is the idempotency key — a rerun is a journal
95
+ // 'duplicate' even when the meta.ledgerKey fast path above is bypassed.
96
+ const res = await mutateMemory(
97
+ { kind: 'project', projectRoot, homeDir },
98
+ {
99
+ op: 'add',
100
+ idempotencyKey: ledgerKey,
101
+ payload: {
102
+ type: 'episode',
103
+ scope: 'session',
104
+ text: episodeText(ledger, sessionId),
105
+ provenance: 'exec-ledger',
106
+ confidence: 0.6,
107
+ createdBy: 'episode-hook',
108
+ projectId: resolvedProjectId,
109
+ validUntil: Date.now() + ttlDays * DAY_MS,
110
+ meta: { sessionId, ledgerKey },
111
+ },
112
+ },
113
+ );
114
+ if (res.status === 'duplicate') return { status: 'duplicate' };
115
+ if (res.status === 'rejected') return { status: 'rejected', result: res };
116
+ if (res.status !== 'ok') {
117
+ return { status: 'failed', reason: `${res.status}${res.reason ? ` — ${res.reason}` : ''}` };
118
+ }
119
+ keys.add(ledgerKey);
120
+ return { status: 'recorded', id: res.record.id, sessionId };
121
+ }
122
+
123
+ // Backfills an episode for every ledger that has been idle for `idleMs`
124
+ // (so a live session is never summarized mid-flight) and has none yet.
125
+ // Bounded by `limit` newest ledgers; never throws.
126
+ export async function backfillEpisodes(projectRoot, {
127
+ config,
128
+ homeDir,
129
+ idleMs = 10 * 60 * 1000,
130
+ limit = 50,
131
+ now = Date.now(),
132
+ } = {}) {
133
+ const summary = { scanned: 0, recorded: 0, skipped: 0, failed: 0 };
134
+ try {
135
+ if (config?.memoryV2?.enabled === false || config?.learning?.episodes?.autoWrite === false) {
136
+ return { ...summary, reason: 'disabled' };
137
+ }
138
+ const dir = path.join(projectRoot, LEDGER_DIR_REL);
139
+ const names = await listLedgerFiles(dir, limit);
140
+ if (names.length === 0) return summary;
141
+ const existingKeys = new Set(
142
+ (await loadRecords(projectRoot, { homeDir })).map((r) => r.meta?.ledgerKey).filter(Boolean),
143
+ );
144
+ const projectId = (await detectProjectContext(projectRoot, { homeDir })).project.name;
145
+ for (const name of names) {
146
+ summary.scanned += 1;
147
+ if (existingKeys.has(name)) { summary.skipped += 1; continue; }
148
+ let stat;
149
+ try { stat = await fs.stat(path.join(dir, name)); } catch { summary.skipped += 1; continue; }
150
+ if (now - stat.mtimeMs < idleMs) { summary.skipped += 1; continue; }
151
+ const ledger = await readJsonIfExists(path.join(dir, name));
152
+ const receipts = Array.isArray(ledger?.receipts) ? ledger.receipts.length : 0;
153
+ if (!ledger || (receipts === 0 && ledger.writeSucceeded !== true)) {
154
+ summary.skipped += 1;
155
+ continue;
156
+ }
157
+ const res = await writeEpisode(projectRoot, {
158
+ ledger, ledgerKey: name, config, homeDir, projectId, existingKeys,
159
+ });
160
+ if (res.status === 'recorded') summary.recorded += 1;
161
+ else if (res.status === 'duplicate') summary.skipped += 1;
162
+ else summary.failed += 1;
163
+ }
164
+ } catch (error) {
165
+ return { ...summary, reason: error?.message ?? String(error) };
166
+ }
167
+ return summary;
168
+ }
@@ -114,6 +114,27 @@ export async function writeInstallMetadata({
114
114
  }
115
115
  }
116
116
 
117
+ export const CLI_LOCATOR_REL = path.join('.ukit', 'storage', 'cli.json');
118
+
119
+ // `.ukit/storage/cli.json` — absolute node + CLI entry of the install that
120
+ // last refreshed this project. Read by .claude/ukit/runtime/ukit-cli.mjs so
121
+ // hook-side CLI spawns work without `ukit` on the host's PATH. Advisory:
122
+ // a write failure only degrades hooks back to the PATH lookup.
123
+ export async function writeCliLocator({ projectRoot, packageRoot, packageVersion }) {
124
+ if (!projectRoot || !packageRoot) return false;
125
+ try {
126
+ await writeJson(path.join(projectRoot, CLI_LOCATOR_REL), {
127
+ node: process.execPath,
128
+ bin: path.join(path.resolve(packageRoot), 'bin', 'ukit'),
129
+ version: packageVersion ?? null,
130
+ writtenAt: new Date().toISOString(),
131
+ });
132
+ return true;
133
+ } catch {
134
+ return false;
135
+ }
136
+ }
137
+
117
138
  export async function removeTrackedPathsFromMetadata({
118
139
  installMetaPath,
119
140
  projectRoot,
@@ -10,7 +10,7 @@ import { buildInstallPlan } from './buildPlan.js';
10
10
  import { diffInstallPlan } from './diffPlan.js';
11
11
  import { applyDiffResults, pruneOldBackups } from './applyPlan.js';
12
12
  import { summarizeDiff, toDiffRows } from './report.js';
13
- import { writeInstallMetadata } from './metadata.js';
13
+ import { writeInstallMetadata, writeCliLocator } from './metadata.js';
14
14
  import { cleanupLegacyPaths, migrateLegacyRuntimeRoot } from './migrateLegacy.js';
15
15
  import { ensureGitignore } from './ensureGitignore.js';
16
16
  import { repairBrokenHooks } from './repairBrokenHooks.js';
@@ -477,6 +477,11 @@ export async function runInstallPipeline({
477
477
  retainedManagedRelativePaths,
478
478
  });
479
479
 
480
+ // 3.3.0: hooks spawn the CLI (episodes, self-improve) from a GUI-launched
481
+ // host whose PATH often lacks nvm/volta shims — `ukit` on PATH silently
482
+ // failed. Record the absolute node + bin path of THIS install instead.
483
+ await writeCliLocator({ projectRoot: pathConfig.projectRoot, packageRoot: pathConfig.packageRoot, packageVersion });
484
+
480
485
  const { userLayer, userRows } = await runUserLayerPass({
481
486
  pathConfig,
482
487
  homeDir,
@@ -5,6 +5,7 @@ import { buildRuntimePaths } from './runtimePaths.js';
5
5
  import { buildUserPaths } from './userPaths.js';
6
6
  import { loadShippedCompactBudget } from './compact/contextBudget.js';
7
7
  import { buildConfigContracts } from './executionContracts.js';
8
+ import { mergeTunedOverlay } from '../learning/tunedOverlay.js';
8
9
 
9
10
  const require = createRequire(import.meta.url);
10
11
  const { version: PACKAGE_VERSION } = require('../../package.json');
@@ -299,11 +300,14 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
299
300
  },
300
301
  // M01.1 stage keys (docs/pstack/MIGRATION_ROLLBACK named-keys table). Every stage
301
302
  // key treats absence as "off"; stages promote off -> shadow -> canary -> default.
303
+ // 3.3.0 zero-config: every routing stage ships 'default'. escalation at
304
+ // 'default' enforces verification evidence at Stop for high-risk routes —
305
+ // self-healing (run the verification), never a hard deadlock.
302
306
  routing: {
303
- routeSchema: { stage: 'off' },
304
- rigor: { stage: 'off' },
305
- fastPath: { stage: 'off' },
306
- escalation: { stage: 'off' },
307
+ routeSchema: { stage: 'default' },
308
+ rigor: { stage: 'default' },
309
+ fastPath: { stage: 'default' },
310
+ escalation: { stage: 'default' },
307
311
  },
308
312
  // C52 M07: `unic-decision` is the owner's local non-LLM Lava/JEV model in
309
313
  // UNIC Provider. Its OpenAI-compatible API is transport only, not proof of
@@ -413,13 +417,12 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
413
417
  promotion: { episodeToRuleRequiresApproval: true },
414
418
  recall: { maxRecords: 8 },
415
419
  // M06 rollout flags (SPEC §5 FR-001): per-plane stage on the shared
416
- // off→shadow→canary→default ladder. eligibility/writer/index ship
417
- // 'default' (the w1-2 path is the shipped behavior); decision ships
418
- // 'off' — plumbing only, no consumer yet. killSwitch is absolute.
420
+ // off→shadow→canary→default ladder. 3.3.0 zero-config: every plane
421
+ // ships 'default'. killSwitch is absolute.
419
422
  eligibility: { stage: 'default' },
420
423
  writer: { stage: 'default' },
421
424
  index: { stage: 'default' },
422
- decision: { stage: 'off' },
425
+ decision: { stage: 'default' },
423
426
  canaryProjects: [],
424
427
  killSwitch: false,
425
428
  },
@@ -438,26 +441,29 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
438
441
  maxRetries: 1,
439
442
  confidenceThreshold: 50,
440
443
  },
441
- // Phase-4 learning loop (SPEC §8a). Advisory only — applyMode 'manual'
442
- // means suggestions are computed + persisted, never auto-applied.
444
+ // Phase-4 learning loop (SPEC §8a). 3.3.0 zero-config: applyMode 'auto'
445
+ // lets the self-improve cycle apply clamped one-step tuning through the
446
+ // .ukit/storage/learning/tuned.json overlay (src/learning/tunedOverlay.js);
447
+ // 'manual' = suggestions only, 'off' = nothing computed.
443
448
  learning: {
444
449
  feedback: { enabled: true, maxEvents: 200 },
445
450
  proposals: { minCount: 3, minSessions: 2 },
446
451
  episodes: { autoWrite: true },
447
- tuning: { enabled: true, applyMode: 'manual' },
452
+ tuning: { enabled: true, applyMode: 'auto' },
453
+ // 3.3.0: automatic collect → learn → apply pass (src/learning/selfImprove.js).
454
+ selfImprove: { enabled: true },
448
455
  // C52 M04.2 stage keys (SPEC §5 FR-016–FR-018). candidates: repeated
449
456
  // corrections/suppressions/escalations promote to a LearningCandidate
450
- // only after minOccurrences + cross-session evidence; report-only at
451
- // 'shadow'. overlays: delta overlays on policy fields; base-version
452
- // mismatch → conflict, never silent apply. Promotion stays manual.
453
- candidates: { stage: 'shadow', minOccurrences: 3 },
454
- overlays: { stage: 'off' },
457
+ // only after minOccurrences + cross-session evidence (status
458
+ // 'proposed'; rule promotion still needs approval). overlays: only
459
+ // approved + enabled overlays apply; base-version mismatch → conflict.
460
+ candidates: { stage: 'default', minOccurrences: 3 },
461
+ overlays: { stage: 'default' },
455
462
  },
456
- // C52 M04.1 compact resumable state (SPEC §5 FR-012–FR-015). Stage 'off'
457
- // → no resumable-run records written or consumed; resume falls back to
458
- // the existing reinject path.
463
+ // C52 M04.1 compact resumable state (SPEC §5 FR-012–FR-015): a bounded
464
+ // resumable-run record per route so a compacted session resumes mid-task.
459
465
  continuity: {
460
- resumableRun: { stage: 'off' },
466
+ resumableRun: { stage: 'default' },
461
467
  },
462
468
  // C63 DR-02 + C64 DR-03/04 + C66 DR-06 + C67 DR-07/08 + C69 DR-10
463
469
  // decision-first-runtime (SPEC §2 G1-FR07, §2 G2-FR07/08, §2 G4-FR07,
@@ -471,29 +477,35 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
471
477
  // gates the G6 caller-wait promotion profile and
472
478
  // `decisionRuntime.resourcePolicy` gates the G6 pressure/coalescing
473
479
  // policy; `decisionRuntime.diagnostics` gates the G7 explainable-trace /
474
- // replay / support-record readers. Absence or malformed stage resolves
475
- // 'off' → zero files under .ukit/storage/agent-runtime/, zero behavior
476
- // change.
480
+ // replay / support-record readers. 3.3.0 zero-config: every family ships
481
+ // 'shadow' — evidence is recorded while the deterministic owners stay
482
+ // authoritative; promotion.js promotes a family only once its frozen
483
+ // PROMOTION_CRITERIA hold on sampled runs. diagnostics ships 'default'
484
+ // (sanitized support records, explicit CLI act).
477
485
  decisionRuntime: {
478
- contract: { stage: 'off' },
479
- supervisor: { stage: 'off' },
480
- scheduler: { stage: 'off' },
481
- vm: { stage: 'off' },
482
- adapters: { stage: 'off' },
483
- context: { stage: 'off' },
484
- completion: { stage: 'off' },
485
- quality: { stage: 'off' },
486
- promotion: { stage: 'off' },
487
- resourcePolicy: { stage: 'off' },
488
- diagnostics: { stage: 'off' },
486
+ contract: { stage: 'shadow' },
487
+ supervisor: { stage: 'shadow' },
488
+ scheduler: { stage: 'shadow' },
489
+ vm: { stage: 'shadow' },
490
+ adapters: { stage: 'shadow' },
491
+ context: { stage: 'shadow' },
492
+ completion: { stage: 'shadow' },
493
+ quality: { stage: 'shadow' },
494
+ promotion: { stage: 'shadow' },
495
+ resourcePolicy: { stage: 'shadow' },
496
+ diagnostics: { stage: 'default' },
489
497
  },
490
498
 
491
- // C52 M06 experiments (SPEC §5 FR-021–FR-023). Disabled by default —
492
- // zero calls and zero prompt content when off; no auto-promotion.
499
+ // C52 M06 experiments (SPEC §5 FR-021–FR-023). 3.3.0 zero-config: enabled;
500
+ // each experiment still runs only where a caller invokes it and never
501
+ // auto-promotes its own result.
493
502
  experiments: {
494
- deliberation: { enabled: false },
495
- dynamicWorkflow: { enabled: false, maxAttemptsPerFailure: 2, noProgressCap: 3 },
503
+ deliberation: { enabled: true },
504
+ dynamicWorkflow: { enabled: true, maxAttemptsPerFailure: 2, noProgressCap: 3 },
496
505
  },
506
+ // Flight recorder (docs/OBSERVABILITY.md): 'default' activates recorder,
507
+ // analytics, support projection and evaluation. 'off' is the kill switch.
508
+ observability: { stage: 'default' },
497
509
  safePatch: {
498
510
  enabled: true,
499
511
  strictSharedRisk: true,
@@ -1091,12 +1103,19 @@ export function validateRuntimeConfig(config) {
1091
1103
  errors.push('learning.tuning must be an object.');
1092
1104
  } else {
1093
1105
  pushBooleanError(errors, learning.tuning.enabled, 'learning.tuning.enabled');
1094
- const VALID_APPLY_MODES = new Set(['manual', 'off']);
1106
+ const VALID_APPLY_MODES = new Set(['auto', 'manual', 'off']);
1095
1107
  if (!VALID_APPLY_MODES.has(learning.tuning.applyMode)) {
1096
1108
  errors.push(`learning.tuning.applyMode must be one of: ${[...VALID_APPLY_MODES].join(', ')}.`);
1097
1109
  }
1098
1110
  }
1099
1111
  }
1112
+ if (learning.selfImprove !== undefined) {
1113
+ if (!isPlainObject(learning.selfImprove)) {
1114
+ errors.push('learning.selfImprove must be an object.');
1115
+ } else {
1116
+ pushBooleanError(errors, learning.selfImprove.enabled, 'learning.selfImprove.enabled');
1117
+ }
1118
+ }
1100
1119
  // C52 M04.2 stage keys — same optional-present contract as routing.*.
1101
1120
  if (learning.candidates !== undefined) {
1102
1121
  if (!isPlainObject(learning.candidates)) {
@@ -1191,7 +1210,7 @@ export async function inspectRuntimeConfig(projectRoot, { homeDir } = {}) {
1191
1210
 
1192
1211
  const user = await readUserConfig(homeDir);
1193
1212
  const userLayer = user.parseError ? null : stripProjectManagedKeys(user.rawConfig);
1194
- const mergedRaw = mergeObjects(userLayer ?? {}, rawConfig ?? {});
1213
+ const mergedRaw = await mergeTunedOverlay(projectRoot, mergeObjects(userLayer ?? {}, rawConfig ?? {}));
1195
1214
 
1196
1215
  const config = normalizeLegacyOpencodeEntries(buildDefaultRuntimeConfig(mergedRaw));
1197
1216
  const validation = validateRuntimeConfig(config);
@@ -0,0 +1,205 @@
1
+ // selfImprove.js — the automatic collect → learn → apply cycle (3.3.0).
2
+ //
3
+ // One bounded pass that turns what hooks already record into memory,
4
+ // diagnostics, tuning, and the flight recorder — no human command needed.
5
+ // Triggered detached by `.claude/ukit/runtime/self-improve-trigger.mjs` from
6
+ // Claude SessionEnd, omp session_stop, and route-task.mjs (the Codex helper
7
+ // path), and runnable by hand via `ukit self-improve`.
8
+ //
9
+ // Steps (each isolated: one failing step never skips the others):
10
+ // 1. episodes — backfill an episode record for every idle exec-ledger
11
+ // 2. diagnostics — failure patterns, feedback events, skill accuracy
12
+ // 3. tuning — compute suggestions; applyMode 'auto' applies them as
13
+ // clamped one-step changes in learning/tuned.json, with a
14
+ // per-key cooldown so one noisy window cannot ratchet a
15
+ // value across its whole range
16
+ // 4. proposals — repeated failure patterns become PENDING memory
17
+ // candidates (rules still need `ukit memory approve`)
18
+ // 5. telemetry — ingest stored hook/ledger telemetry, flush, refresh the
19
+ // support view
20
+ //
21
+ // Guards: `.ukit/storage/learning/self-improve.json` stamp rate-limits the
22
+ // pass (minIntervalMs, default 10 min) and a file lock keeps two triggers from
23
+ // overlapping. Never throws.
24
+
25
+ import fs from 'node:fs/promises';
26
+ import os from 'node:os';
27
+ import path from 'node:path';
28
+
29
+ import { inspectRuntimeConfig } from '../core/runtimeConfig.js';
30
+ import { withFileLock } from '../core/fileOps.js';
31
+ import { detectProjectContext } from '../context/detectProjectContext.js';
32
+ import { backfillEpisodes } from '../core/memory/episodes.js';
33
+ import { mineFailurePatterns } from '../diagnostics/failurePatterns.js';
34
+ import { collectFeedbackEvents } from '../diagnostics/feedbackEvents.js';
35
+ import { collectSkillAccuracy } from '../diagnostics/skillAccuracy.js';
36
+ import { computeTuningSuggestions } from './tuning.js';
37
+ import { proposeFromPatterns } from './patternProposals.js';
38
+ import {
39
+ TUNABLE_KEYS,
40
+ clampTunable,
41
+ readTunedDocument,
42
+ resolveApplyMode,
43
+ writeTunedDocument,
44
+ } from './tunedOverlay.js';
45
+
46
+ export const STAMP_REL = path.join('.ukit', 'storage', 'learning', 'self-improve.json');
47
+ export const DEFAULT_MIN_INTERVAL_MS = 10 * 60 * 1000;
48
+ // A tuned key moves at most one step per cooldown window.
49
+ export const TUNING_COOLDOWN_MS = 24 * 60 * 60 * 1000;
50
+ const TELEMETRY_DEADLINE_MS = 30_000;
51
+ const FLUSH_DEADLINE_MS = 10_000;
52
+
53
+ async function readJson(filePath) {
54
+ try {
55
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
56
+ } catch {
57
+ return null;
58
+ }
59
+ }
60
+
61
+ async function writeJsonAtomic(filePath, value) {
62
+ const tmp = `${filePath}.tmp-${process.pid}`;
63
+ try {
64
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
65
+ await fs.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`);
66
+ await fs.rename(tmp, filePath);
67
+ } catch {
68
+ await fs.rm(tmp, { force: true }).catch(() => {});
69
+ }
70
+ }
71
+
72
+ async function step(name, fn) {
73
+ try {
74
+ return { name, ...(await fn()) };
75
+ } catch (error) {
76
+ return { name, status: 'failed', reason: error?.message ?? String(error) };
77
+ }
78
+ }
79
+
80
+ // Applies tuning suggestions to the overlay. Pure over its inputs apart from
81
+ // the overlay write, so it is testable without the rest of the cycle.
82
+ export async function applyTuning(projectRoot, suggestions, { now = Date.now() } = {}) {
83
+ const doc = (await readTunedDocument(projectRoot))
84
+ ?? { overrides: {}, lastAppliedAt: {}, history: [] };
85
+ const applied = [];
86
+ for (const suggestion of Array.isArray(suggestions) ? suggestions : []) {
87
+ const key = suggestion?.target;
88
+ if (!TUNABLE_KEYS[key]) continue;
89
+ const last = Number(doc.lastAppliedAt[key]);
90
+ if (Number.isFinite(last) && now - last < TUNING_COOLDOWN_MS) continue;
91
+ const value = clampTunable(key, suggestion.suggested);
92
+ if (value === null || doc.overrides[key] === value) continue;
93
+ const from = doc.overrides[key] ?? suggestion.current ?? null;
94
+ doc.overrides[key] = value;
95
+ doc.lastAppliedAt[key] = now;
96
+ doc.history.push({
97
+ at: new Date(now).toISOString(),
98
+ key,
99
+ from,
100
+ to: value,
101
+ reason: suggestion?.evidence?.reason ?? null,
102
+ });
103
+ applied.push({ key, from, to: value });
104
+ }
105
+ if (applied.length > 0) {
106
+ doc.history = doc.history.slice(-50);
107
+ await writeTunedDocument(projectRoot, doc);
108
+ }
109
+ return applied;
110
+ }
111
+
112
+ async function collectTelemetry(projectRoot, config) {
113
+ const { resolveStage } = await import('../core/observability/emit/config.js');
114
+ if (resolveStage(config) === 'off') return { status: 'skipped', reason: 'stage_off' };
115
+ const { getRecorder, segmentsRoot } = await import('../core/observability/emit/lifecycle.js');
116
+ const { ingestStoredTelemetry } = await import('../core/observability/adapters/ingest.js');
117
+ const { maybeRefreshSupport } = await import('../core/observability/support/schedule.js');
118
+ await fs.mkdir(segmentsRoot(projectRoot), { recursive: true });
119
+ const recorder = getRecorder({ projectRoot, config });
120
+ const ingest = await ingestStoredTelemetry({ projectRoot, recorder, deadlineMs: TELEMETRY_DEADLINE_MS });
121
+ const flush = await recorder.flush({ deadlineMs: FLUSH_DEADLINE_MS });
122
+ const support = await maybeRefreshSupport({ projectRoot, config, recorder });
123
+ const emitted = Object.values(ingest?.coverage ?? {})
124
+ .reduce((sum, cov) => sum + (Number(cov?.emitted) || 0), 0);
125
+ return {
126
+ status: ingest?.ok === true ? ingest.status : 'failed',
127
+ emitted,
128
+ written: flush?.written ?? 0,
129
+ support: support?.status ?? 'unknown',
130
+ };
131
+ }
132
+
133
+ /**
134
+ * Run one self-improve pass.
135
+ * @param {string} projectRoot
136
+ * @param {{ force?: boolean, homeDir?: string, now?: number, minIntervalMs?: number }} [opts]
137
+ * @returns {Promise<{status: 'ran'|'skipped', reason?: string, steps?: object[]}>}
138
+ */
139
+ export async function runSelfImprove(projectRoot, {
140
+ force = false,
141
+ homeDir = os.homedir(),
142
+ now = Date.now(),
143
+ minIntervalMs = DEFAULT_MIN_INTERVAL_MS,
144
+ } = {}) {
145
+ const root = path.resolve(projectRoot);
146
+ const stampPath = path.join(root, STAMP_REL);
147
+ try {
148
+ const { config, rawConfig } = await inspectRuntimeConfig(root, { homeDir });
149
+ if (config?.learning?.selfImprove?.enabled === false) {
150
+ return { status: 'skipped', reason: 'disabled' };
151
+ }
152
+ const stamp = await readJson(stampPath);
153
+ const lastRunAt = Date.parse(stamp?.lastRunAt ?? '');
154
+ if (!force && Number.isFinite(lastRunAt) && now - lastRunAt < minIntervalMs) {
155
+ return { status: 'skipped', reason: 'min_interval' };
156
+ }
157
+
158
+ const outcome = await withFileLock(stampPath, async () => {
159
+ const steps = [];
160
+ steps.push(await step('episodes', async () => {
161
+ const res = await backfillEpisodes(root, { config, homeDir, now });
162
+ return { status: res.reason ? 'skipped' : 'ok', ...res };
163
+ }));
164
+ steps.push(await step('diagnostics', async () => {
165
+ const patterns = await mineFailurePatterns(root);
166
+ const feedback = await collectFeedbackEvents(root);
167
+ const skills = await collectSkillAccuracy(root);
168
+ return {
169
+ status: 'ok',
170
+ patterns: Array.isArray(patterns?.patterns) ? patterns.patterns.length : 0,
171
+ feedbackEvents: Array.isArray(feedback?.events) ? feedback.events.length : 0,
172
+ skills: Object.keys(skills?.skills ?? {}).length,
173
+ };
174
+ }));
175
+ steps.push(await step('tuning', async () => {
176
+ const result = await computeTuningSuggestions(root);
177
+ const mode = resolveApplyMode(rawConfig ?? {});
178
+ const applied = mode === 'auto' ? await applyTuning(root, result.suggestions, { now }) : [];
179
+ return { status: 'ok', mode, suggestions: result.suggestions.length, applied };
180
+ }));
181
+ steps.push(await step('proposals', async () => {
182
+ const projectId = (await detectProjectContext(root, { homeDir })).project.name;
183
+ const minCount = Number(config?.learning?.proposals?.minCount) || undefined;
184
+ const res = await proposeFromPatterns(root, projectId, { minCount, dryRun: false });
185
+ return {
186
+ status: res.error ? 'failed' : 'ok',
187
+ proposed: res.proposed.filter((p) => p.status === 'proposed').length,
188
+ reason: res.error,
189
+ };
190
+ }));
191
+ steps.push(await step('telemetry', () => collectTelemetry(root, config)));
192
+
193
+ await writeJsonAtomic(stampPath, {
194
+ lastRunAt: new Date(now).toISOString(),
195
+ steps,
196
+ });
197
+ return steps;
198
+ }, { maxWaitMs: 500 });
199
+
200
+ if (outcome === undefined) return { status: 'skipped', reason: 'locked' };
201
+ return { status: 'ran', steps: outcome };
202
+ } catch (error) {
203
+ return { status: 'skipped', reason: error?.message ?? String(error) };
204
+ }
205
+ }
@@ -0,0 +1,112 @@
1
+ // tunedOverlay.js — auto-applied tuning overlay (learning.tuning.applyMode 'auto').
2
+ //
3
+ // The self-improve cycle writes clamped one-step tuning changes to
4
+ // `.ukit/storage/learning/tuned.json`; runtime config readers merge the
5
+ // overlay over the project config. The file lives outside the install-managed
6
+ // `config.json`, so a reinstall never erases learned values, and deleting the
7
+ // file (or setting applyMode 'manual'|'off') is a complete rollback.
8
+ //
9
+ // Contract:
10
+ // * Only TUNABLE_KEYS are honored; every value is clamped to its bounds on
11
+ // read, so a hand-edited or corrupt overlay can never push a key outside
12
+ // the range the tuner itself is allowed to reach.
13
+ // * Never throws: missing/corrupt overlay → no overrides.
14
+ // * Merge point: inspectRuntimeConfig (src/core/runtimeConfig.js) applies
15
+ // the overlay after the user+project merge, before defaults/validation.
16
+
17
+ import fs from 'node:fs/promises';
18
+ import path from 'node:path';
19
+
20
+ export const TUNED_REL = path.join('.ukit', 'storage', 'learning', 'tuned.json');
21
+ export const TUNED_VERSION = 1;
22
+
23
+ export const TUNABLE_KEYS = Object.freeze({
24
+ 'codeIntel.retriever.weights.exact': { min: 0.1, max: 2.0 },
25
+ 'codeIntel.retriever.weights.symbol': { min: 0.1, max: 2.0 },
26
+ 'codeIntel.retriever.weights.bm25': { min: 0.1, max: 2.0 },
27
+ 'codeIntel.retriever.weights.semantic': { min: 0.1, max: 2.0 },
28
+ 'codeIntel.retriever.weights.vector': { min: 0.1, max: 2.0 },
29
+ 'orchestration.escalation.debugLoopThreshold': { min: 1, max: 5, integer: true },
30
+ });
31
+
32
+ function isPlainObject(value) {
33
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
34
+ }
35
+
36
+ export function clampTunable(key, value) {
37
+ const spec = TUNABLE_KEYS[key];
38
+ if (!spec || typeof value !== 'number' || !Number.isFinite(value)) return null;
39
+ const clamped = Math.min(spec.max, Math.max(spec.min, value));
40
+ return spec.integer ? Math.round(clamped) : Math.round(clamped * 100) / 100;
41
+ }
42
+
43
+ export function sanitizeOverrides(raw) {
44
+ const out = {};
45
+ if (!isPlainObject(raw)) return out;
46
+ for (const [key, value] of Object.entries(raw)) {
47
+ const clamped = clampTunable(key, value);
48
+ if (clamped !== null) out[key] = clamped;
49
+ }
50
+ return out;
51
+ }
52
+
53
+ export async function readTunedDocument(projectRoot) {
54
+ try {
55
+ const doc = JSON.parse(await fs.readFile(path.join(projectRoot, TUNED_REL), 'utf8'));
56
+ if (!isPlainObject(doc)) return null;
57
+ return {
58
+ version: TUNED_VERSION,
59
+ overrides: sanitizeOverrides(doc.overrides),
60
+ lastAppliedAt: isPlainObject(doc.lastAppliedAt) ? doc.lastAppliedAt : {},
61
+ history: Array.isArray(doc.history) ? doc.history.slice(-50) : [],
62
+ };
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ export async function writeTunedDocument(projectRoot, doc) {
69
+ const filePath = path.join(projectRoot, TUNED_REL);
70
+ const tmp = `${filePath}.tmp-${process.pid}`;
71
+ try {
72
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
73
+ await fs.writeFile(tmp, `${JSON.stringify({ ...doc, version: TUNED_VERSION }, null, 2)}\n`);
74
+ await fs.rename(tmp, filePath);
75
+ return true;
76
+ } catch {
77
+ await fs.rm(tmp, { force: true }).catch(() => {});
78
+ return false;
79
+ }
80
+ }
81
+
82
+ // Resolves the effective apply mode from a raw (pre-default) config object:
83
+ // absent → 'auto' (the shipped default).
84
+ export function resolveApplyMode(rawConfig) {
85
+ const tuning = rawConfig?.learning?.tuning;
86
+ if (tuning?.enabled === false) return 'off';
87
+ const mode = tuning?.applyMode;
88
+ return mode === 'manual' || mode === 'off' || mode === 'auto' ? mode : 'auto';
89
+ }
90
+
91
+ // Returns a copy of `config` with `overrides` set at their dotted paths.
92
+ export function applyOverrides(config, overrides) {
93
+ if (!isPlainObject(overrides) || Object.keys(overrides).length === 0) return config;
94
+ const next = structuredClone(isPlainObject(config) ? config : {});
95
+ for (const [key, value] of Object.entries(overrides)) {
96
+ const parts = key.split('.');
97
+ let node = next;
98
+ for (const part of parts.slice(0, -1)) {
99
+ if (!isPlainObject(node[part])) node[part] = {};
100
+ node = node[part];
101
+ }
102
+ node[parts[parts.length - 1]] = value;
103
+ }
104
+ return next;
105
+ }
106
+
107
+ // Merge helper for config readers: overlay applies only in 'auto' mode.
108
+ export async function mergeTunedOverlay(projectRoot, rawConfig) {
109
+ if (resolveApplyMode(rawConfig) !== 'auto') return rawConfig;
110
+ const doc = await readTunedDocument(projectRoot);
111
+ return doc ? applyOverrides(rawConfig, doc.overrides) : rawConfig;
112
+ }
@@ -112,25 +112,45 @@ try {
112
112
  const config = readJson(configPath) || {};
113
113
  if (config?.learning?.episodes?.autoWrite !== true) process.exit(0);
114
114
 
115
- const probe = spawnSync("ukit", ["--version"], {
116
- shell: true, stdio: "ignore", timeout: 2000,
117
- });
118
- if (probe.error || probe.status === null || probe.status === undefined) process.exit(0);
119
-
120
115
  const sessionId = typeof payload.session_id === "string" && payload.session_id.trim()
121
116
  ? payload.session_id.trim()
122
117
  : null;
123
118
 
124
- spawnSync("ukit", ["memory", "episode"], {
125
- shell: true,
126
- stdio: "ignore",
127
- timeout: 4000,
128
- cwd: projectRoot,
129
- env: sessionId
130
- ? { ...process.env, UKIT_SESSION_ID: sessionId }
131
- : process.env,
132
- });
133
- ' "$UKIT_INPUT_FILE" "$CONFIG_FILE" "$PROJECT_ROOT" 2>/dev/null || true
119
+ (async () => {
120
+ // 3.3.0: prefer the absolute node + CLI recorded by `ukit install`
121
+ // (.ukit/storage/cli.json, validated by resolveCliLocator) — GUI-launched
122
+ // hosts often lack nvm/volta on PATH, which made the bare `ukit` spawn fail
123
+ // silently. PATH lookup stays the fallback for pre-locator installs.
124
+ let trigger = null;
125
+ try {
126
+ trigger = await import(require("url").pathToFileURL(process.argv[4]).href);
127
+ } catch {}
128
+ const located = trigger?.resolveCliLocator?.(projectRoot) ?? null;
129
+ const cli = located
130
+ ? { cmd: located.node, pre: [located.bin], shell: false }
131
+ : { cmd: "ukit", pre: [], shell: true };
132
+
133
+ const probe = spawnSync(cli.cmd, [...cli.pre, "--version"], {
134
+ shell: cli.shell, stdio: "ignore", timeout: 2000,
135
+ });
136
+ if (probe.error || probe.status === null || probe.status === undefined) return;
137
+
138
+ spawnSync(cli.cmd, [...cli.pre, "memory", "episode"], {
139
+ shell: cli.shell,
140
+ stdio: "ignore",
141
+ timeout: 4000,
142
+ cwd: projectRoot,
143
+ env: sessionId
144
+ ? { ...process.env, UKIT_SESSION_ID: sessionId }
145
+ : process.env,
146
+ });
147
+
148
+ // Detached self-improve pass (episode backfill for sessions whose stop id
149
+ // did not match their ledger, diagnostics, auto-tuning, telemetry
150
+ // collect). Returns in ms; rate-limited inside the trigger.
151
+ trigger?.triggerSelfImprove?.({ projectRoot });
152
+ })().catch(() => {}).finally(() => process.exit(0));
153
+ ' "$UKIT_INPUT_FILE" "$CONFIG_FILE" "$PROJECT_ROOT" "$SCRIPT_DIR/../ukit/runtime/self-improve-trigger.mjs" 2>/dev/null || true
134
154
 
135
155
  rm -f "$UKIT_INPUT_FILE" 2>/dev/null || true
136
156
  exit 0
@@ -1722,6 +1722,19 @@ async function main() {
1722
1722
  }
1723
1723
 
1724
1724
  printRouteState(sharedState);
1725
+
1726
+ // 3.3.0: Codex has no hook lifecycle — route-task is the one helper it runs
1727
+ // per task, so it doubles as the self-improve trigger there. Fire-and-forget:
1728
+ // detached child, rate-limited inside the trigger, never throws. Opt out
1729
+ // with UKIT_SELF_IMPROVE=0 (tests/CI) or learning.selfImprove.enabled:false.
1730
+ if (process.env.UKIT_SELF_IMPROVE !== '0') {
1731
+ try {
1732
+ const { triggerSelfImprove } = await import('../runtime/self-improve-trigger.mjs');
1733
+ triggerSelfImprove({ projectRoot: rootDir });
1734
+ } catch {
1735
+ // background work only — a missing/broken trigger never affects routing
1736
+ }
1737
+ }
1725
1738
  }
1726
1739
 
1727
1740
  async function ensureFreshIndex({ rootDir, logPrefix, signal = null, deadlineMs = null, discoverySnapshot = null } = {}) {
@@ -0,0 +1,98 @@
1
+ // self-improve-trigger.mjs — fire-and-forget launcher for `ukit self-improve`.
2
+ //
3
+ // Called from every engine's natural lifecycle point:
4
+ // * Claude Code — SessionEnd (session-episode.sh)
5
+ // * omp — session_stop (same script via ukit-bridge.js)
6
+ // * Codex — route-task.mjs, the helperCommand Codex runs per task
7
+ // (Codex has no hook lifecycle)
8
+ //
9
+ // Contract:
10
+ // * NEVER blocks the caller: the CLI runs detached (own process group,
11
+ // stdio ignored, unref'd); this module returns in a few ms.
12
+ // * NEVER throws; every failure is a silent no-op — self-improvement is
13
+ // background work and must not add noise to a user's session.
14
+ // * Cheap gates first: config opt-out (learning.selfImprove.enabled:false),
15
+ // the pass's own rate-limit stamp, and the CLI locator. The locator
16
+ // (.ukit/storage/cli.json, written by `ukit install`) holds absolute
17
+ // node + bin paths, so GUI-launched hosts without nvm on PATH still work;
18
+ // without it there is no trusted CLI to launch and the trigger no-ops.
19
+ //
20
+ // CLI: node self-improve-trigger.mjs [projectRoot]
21
+
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { spawn } from 'node:child_process';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ export const MIN_INTERVAL_MS = 10 * 60 * 1000;
28
+
29
+ function readJson(filePath) {
30
+ try {
31
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ // The locator is a project file: never trust it blindly. `bin` must be the
38
+ // `bin/ukit` of a directory whose package.json names @ngockhoale/ukit, and
39
+ // `node` must be a node executable — a committed/forged cli.json cannot turn
40
+ // the trigger into an arbitrary-binary launcher.
41
+ export function resolveCliLocator(projectRoot) {
42
+ const locator = readJson(path.join(projectRoot, '.ukit', 'storage', 'cli.json'));
43
+ if (!locator || typeof locator.node !== 'string' || typeof locator.bin !== 'string') return null;
44
+ if (!/^node(\.exe)?$/i.test(path.basename(locator.node))) return null;
45
+ if (path.basename(locator.bin) !== 'ukit' || path.basename(path.dirname(locator.bin)) !== 'bin') return null;
46
+ const pkg = readJson(path.join(path.dirname(locator.bin), '..', 'package.json'));
47
+ if (pkg?.name !== '@ngockhoale/ukit') return null;
48
+ if (!fs.existsSync(locator.node) || !fs.existsSync(locator.bin)) return null;
49
+ return { node: locator.node, bin: locator.bin };
50
+ }
51
+
52
+ export function selfImproveDue(projectRoot, now = Date.now()) {
53
+ const config = readJson(path.join(projectRoot, '.ukit', 'storage', 'config.json'));
54
+ if (config?.learning?.selfImprove?.enabled === false) return false;
55
+ const stamp = readJson(path.join(projectRoot, '.ukit', 'storage', 'learning', 'self-improve.json'));
56
+ const lastRunAt = Date.parse(stamp?.lastRunAt ?? '');
57
+ return !(Number.isFinite(lastRunAt) && now - lastRunAt < MIN_INTERVAL_MS);
58
+ }
59
+
60
+ // Returns true when a pass was launched.
61
+ export function triggerSelfImprove({ projectRoot, now = Date.now(), spawnImpl = spawn } = {}) {
62
+ try {
63
+ if (typeof projectRoot !== 'string' || projectRoot.length === 0) return false;
64
+ const root = path.resolve(projectRoot);
65
+ if (!selfImproveDue(root, now)) return false;
66
+ const cli = resolveCliLocator(root);
67
+ if (!cli) return false;
68
+ const env = { ...process.env };
69
+ // Hook self-deadlines must not leak into a background pass.
70
+ delete env.UKIT_HOOK_DEADLINE_MS;
71
+ const child = spawnImpl(cli.node, [cli.bin, 'self-improve', '--if-due', '--quiet'], {
72
+ cwd: root,
73
+ env,
74
+ detached: true,
75
+ stdio: 'ignore',
76
+ windowsHide: true,
77
+ });
78
+ child.on?.('error', () => {});
79
+ child.unref?.();
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function isMainModule() {
87
+ try {
88
+ const self = fs.realpathSync(fileURLToPath(import.meta.url));
89
+ const invoked = process.argv[1] ? fs.realpathSync(process.argv[1]) : '';
90
+ return self === invoked;
91
+ } catch {
92
+ return false;
93
+ }
94
+ }
95
+
96
+ if (isMainModule()) {
97
+ triggerSelfImprove({ projectRoot: process.argv[2] || process.env.CLAUDE_PROJECT_DIR || process.cwd() });
98
+ }
@@ -85,16 +85,16 @@
85
85
  },
86
86
  "routing": {
87
87
  "routeSchema": {
88
- "stage": "off"
88
+ "stage": "default"
89
89
  },
90
90
  "rigor": {
91
- "stage": "shadow"
91
+ "stage": "default"
92
92
  },
93
93
  "fastPath": {
94
- "stage": "shadow"
94
+ "stage": "default"
95
95
  },
96
96
  "escalation": {
97
- "stage": "shadow"
97
+ "stage": "default"
98
98
  }
99
99
  },
100
100
  "decisionPlane": {
@@ -316,7 +316,20 @@
316
316
  },
317
317
  "recall": {
318
318
  "maxRecords": 8
319
- }
319
+ },
320
+ "eligibility": {
321
+ "stage": "default"
322
+ },
323
+ "writer": {
324
+ "stage": "default"
325
+ },
326
+ "index": {
327
+ "stage": "default"
328
+ },
329
+ "decision": {
330
+ "stage": "default"
331
+ },
332
+ "killSwitch": false
320
333
  },
321
334
  "memory": {
322
335
  "enabled": true,
@@ -346,31 +359,69 @@
346
359
  },
347
360
  "tuning": {
348
361
  "enabled": true,
349
- "applyMode": "manual"
362
+ "applyMode": "auto"
363
+ },
364
+ "selfImprove": {
365
+ "enabled": true
350
366
  },
351
367
  "candidates": {
352
- "stage": "shadow",
368
+ "stage": "default",
353
369
  "minOccurrences": 3
354
370
  },
355
371
  "overlays": {
356
- "stage": "off"
372
+ "stage": "default"
357
373
  }
358
374
  },
359
375
  "continuity": {
360
376
  "resumableRun": {
361
- "stage": "off"
377
+ "stage": "default"
362
378
  }
363
379
  },
364
380
  "experiments": {
365
381
  "deliberation": {
366
- "enabled": false
382
+ "enabled": true
367
383
  },
368
384
  "dynamicWorkflow": {
369
- "enabled": false,
385
+ "enabled": true,
370
386
  "maxAttemptsPerFailure": 2,
371
387
  "noProgressCap": 3
372
388
  }
373
389
  },
390
+ "decisionRuntime": {
391
+ "contract": {
392
+ "stage": "shadow"
393
+ },
394
+ "supervisor": {
395
+ "stage": "shadow"
396
+ },
397
+ "scheduler": {
398
+ "stage": "shadow"
399
+ },
400
+ "vm": {
401
+ "stage": "shadow"
402
+ },
403
+ "adapters": {
404
+ "stage": "shadow"
405
+ },
406
+ "context": {
407
+ "stage": "shadow"
408
+ },
409
+ "completion": {
410
+ "stage": "shadow"
411
+ },
412
+ "quality": {
413
+ "stage": "shadow"
414
+ },
415
+ "promotion": {
416
+ "stage": "shadow"
417
+ },
418
+ "resourcePolicy": {
419
+ "stage": "shadow"
420
+ },
421
+ "diagnostics": {
422
+ "stage": "default"
423
+ }
424
+ },
374
425
  "safePatch": {
375
426
  "enabled": true,
376
427
  "strictSharedRisk": true,
@@ -600,15 +651,15 @@
600
651
  },
601
652
  "tu_ghi_episode_ket_thuc_session": {
602
653
  "field": "learning.episodes.autoWrite",
603
- "mac_dinh": false,
604
- "y_nghia": "N\u1ebfu true, hook SessionEnd t\u1ef1 ghi episode v\u00e0o memory v2 khi k\u1ebft th\u00fac session (c\u1ea7n `ukit` c\u00f3 tr\u00ean PATH). M\u1eb7c \u0111\u1ecbnh t\u1eaft \u0111\u1ec3 tr\u00e1nh ghi memory khi user ch\u01b0a mu\u1ed1n.",
605
- "khi_nao_bat": "Ch\u1ec9 b\u1eadt khi anh mu\u1ed1n UKit t\u1ef1 l\u01b0u episode m\u1ed7i session; d\u1eef li\u1ec7u v\u1eabn n\u1eb1m local trong .ukit/storage."
654
+ "mac_dinh": true,
655
+ "y_nghia": "B\u1eadt m\u1eb7c \u0111\u1ecbnh: UKit t\u1ef1 ghi episode v\u00e0o memory v2 cho m\u1ed7i session \u0111\u00e3 k\u1ebft th\u00fac (hook SessionEnd/session_stop + chu k\u1ef3 self-improve qu\u00e9t exec-ledger, ch\u1ea1y \u0111\u01b0\u1ee3c c\u1ea3 tr\u00ean Codex). D\u1eef li\u1ec7u n\u1eb1m local trong .ukit/storage.",
656
+ "khi_nao_bat": "Ch\u1ec9 t\u1eaft (false) khi kh\u00f4ng mu\u1ed1n UKit l\u01b0u episode."
606
657
  },
607
658
  "tat_goi_y_tuning": {
608
659
  "field": "learning.tuning.applyMode",
609
- "mac_dinh": "manual",
610
- "y_nghia": "manual = UKit ch\u1ec9 t\u00ednh v\u00e0 l\u01b0u g\u1ee3i \u00fd tuning v\u00e0o .ukit/storage/learning/suggestions.json, KH\u00d4NG bao gi\u1edd t\u1ef1 s\u1eeda weights/threshold. off = t\u1eaft lu\u00f4n vi\u1ec7c t\u00ednh g\u1ee3i \u00fd.",
611
- "khuyen_nghi": "Gi\u1eef manual. Kh\u00f4ng c\u00f3 ch\u1ebf \u0111\u1ed9 auto-apply \u2014 m\u1ecdi thay \u0111\u1ed5i weights/threshold \u0111\u1ec1u do ng\u01b0\u1eddi d\u00f9ng t\u1ef1 s\u1eeda."
660
+ "mac_dinh": "auto",
661
+ "y_nghia": "auto (m\u1eb7c \u0111\u1ecbnh) = chu k\u1ef3 self-improve t\u1ef1 \u00e1p d\u1ee5ng thay \u0111\u1ed5i tuning t\u1eebng b\u01b0\u1edbc nh\u1ecf, c\u00f3 gi\u1edbi h\u1ea1n (retriever weights, escalation.debugLoopThreshold) v\u00e0o .ukit/storage/learning/tuned.json. manual = ch\u1ec9 ghi g\u1ee3i \u00fd v\u00e0o suggestions.json. off = kh\u00f4ng t\u00ednh.",
662
+ "khuyen_nghi": "Gi\u1eef auto. Rollback: xo\u00e1 .ukit/storage/learning/tuned.json ho\u1eb7c \u0111\u1eb7t manual."
612
663
  }
613
664
  },
614
665
  "version": "Phi\u00ean b\u1ea3n config runtime \u0111i k\u00e8m package UKit.",