@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
@@ -7,6 +7,7 @@
7
7
  * emitSpanRecord(telemetry, ctx, semanticName, payload) → EmitResult
8
8
  * durationMs(startNs, nowNs) → positive ms | null
9
9
  * projectRootFromRuntimeDir(runtimeDir) → projectRoot | null
10
+ * buildTraceExcerpt(records, opts) → {entries, gaps, stats} (V-06)
10
11
  *
11
12
  * Contracts:
12
13
  * - Emission is fire-and-forget: every helper is synchronous, wraps
@@ -31,6 +32,11 @@ import path from 'node:path';
31
32
  import { getRecorder } from '../observability/emit/lifecycle.js';
32
33
  import { RESOURCE_SOURCES } from '../observability/schema/constants.js';
33
34
  import { REASON_CODES } from '../observability/schema/registry.js';
35
+ import { validateSemanticRecord } from '../observability/schema/validate.js';
36
+ import { sanitizeForSupport } from '../observability/privacy/sanitizeForSupport.js';
37
+ import { MAX_SUPPORT_STRING_CHARS } from '../observability/privacy/allowlist.js';
38
+ import { redactString } from '../observability/privacy/redaction.js';
39
+ import { scanText } from '../sensitiveValueScanner.js';
34
40
 
35
41
  /** Monotonic nanoseconds — BigInt where available (process.hrtime). */
36
42
  export function nowNs() {
@@ -55,6 +61,10 @@ function isNonEmptyString(value) {
55
61
  return typeof value === 'string' && value.length > 0;
56
62
  }
57
63
 
64
+ function isPlainObject(value) {
65
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
66
+ }
67
+
58
68
  /**
59
69
  * agent_id source (SPEC §14): UKIT_AGENT_ID env first, config.agent.id
60
70
  * second, absent otherwise. The field is omitted entirely — never ''.
@@ -202,3 +212,116 @@ export function typedResource(resource) {
202
212
  if (source && source !== 'UNKNOWN') return { resource: { value, source }, complete: true };
203
213
  return { resource: { value, source: 'ESTIMATED' }, complete: true };
204
214
  }
215
+
216
+ // --- V-06 trace excerpt for support bundles --------------------------------
217
+ // `buildTraceExcerpt` is the read-side counterpart of emitSpanRecord: it
218
+ // turns recorder-fed SemanticRecords into bounded, sanitized trace lines
219
+ // for a support bundle. Every record passes validateSemanticRecord AND
220
+ // the independent sanitizeForSupport second gate; survivors keep only
221
+ // allowlisted envelope/payload fields, strings pass redactString, and
222
+ // caller-supplied pseudonyms replace raw ids. Non-conforming records are
223
+ // reported as typed gap entries — never silently smuggled, never thrown.
224
+
225
+ const TRACE_ID_FIELDS = Object.freeze([
226
+ 'record_id', 'trace_id', 'span_id', 'parent_span_id',
227
+ 'execution_id', 'session_id', 'project_ref', 'boot_id', 'writer_id',
228
+ ]);
229
+
230
+ const TRACE_ENVELOPE_KEEP = new Set([
231
+ 'record_type', 'semantic_name', 'schema_version', 'record_id', 'trace_id',
232
+ 'span_id', 'parent_span_id', 'execution_id', 'session_id', 'project_ref',
233
+ 'boot_id', 'writer_id', 'sequence', 'wall_time_utc', 'monotonic_ns',
234
+ 'importance', 'privacy_class', 'redaction_version', 'sampling',
235
+ ]);
236
+
237
+ /**
238
+ * Build a sanitized trace excerpt from SemanticRecords. Never throws.
239
+ *
240
+ * Every record passes the FULL two-gate chain — validateSemanticRecord
241
+ * then the independent sanitizeForSupport default-deny gate — so a
242
+ * survivor's payload is already allowlist-clean (codes, counts, timings
243
+ * only). The excerpt keeps only the envelope fields named in
244
+ * TRACE_ENVELOPE_KEEP, re-passes strings through redactString, and
245
+ * substitutes caller-supplied pseudonyms for raw ids. Rejects land as
246
+ * typed gap entries — never silently smuggled, never thrown.
247
+ *
248
+ * @param {unknown} records array of records (non-array → 'trace_unavailable')
249
+ * @param {object} [opts]
250
+ * @param {number} [opts.maxRecords=256] entry cap (gaps share the budget)
251
+ * @param {(value:string)=>{hasSecret:boolean}} [opts.scanner] defaults scanText
252
+ * @param {(value:string,kind:string)=>string} [opts.pseudonymize] id rewrite
253
+ * @returns {{ entries: Array, gaps: Array, stats: {in:number, kept:number,
254
+ * rejected:number, truncated:number} }}
255
+ */
256
+
257
+ export function buildTraceExcerpt(records, opts = {}) {
258
+ const maxRecords = Number.isInteger(opts.maxRecords) ? Math.max(0, opts.maxRecords) : 256;
259
+ const scanner = typeof opts.scanner === 'function' ? opts.scanner : scanText;
260
+ const pseudonymize = typeof opts.pseudonymize === 'function' ? opts.pseudonymize : null;
261
+ const local = { scanner, maxChars: MAX_SUPPORT_STRING_CHARS };
262
+
263
+ const entries = [];
264
+ const gaps = [];
265
+ const stats = { in: 0, kept: 0, rejected: 0, truncated: 0 };
266
+
267
+ if (!Array.isArray(records)) {
268
+ gaps.push({ index: 0, code: 'trace_unavailable' });
269
+ return { entries, gaps, stats };
270
+ }
271
+
272
+ for (const record of records) {
273
+ stats.in += 1;
274
+ if (entries.length + gaps.length >= maxRecords) {
275
+ stats.truncated += 1;
276
+ continue;
277
+ }
278
+ const index = stats.in - 1;
279
+ if (!isPlainObject(record)) {
280
+ stats.rejected += 1;
281
+ gaps.push({ index, code: 'record_not_object' });
282
+ continue;
283
+ }
284
+ const valid = validateSemanticRecord(record);
285
+ if (!valid.ok) {
286
+ stats.rejected += 1;
287
+ gaps.push({ index, code: 'schema_rejected' });
288
+ continue;
289
+ }
290
+ const gate = sanitizeForSupport(record, { scanner });
291
+ if (!gate.ok) {
292
+ stats.rejected += 1;
293
+ gaps.push({ index, code: 'support_gate_rejected' });
294
+ continue;
295
+ }
296
+
297
+ const out = {};
298
+ for (const key of Object.keys(gate.record)) {
299
+ if (!TRACE_ENVELOPE_KEEP.has(key)) continue;
300
+ const value = gate.record[key];
301
+ if (key === 'sampling') {
302
+ const s = isPlainObject(value) ? value : {};
303
+ out.sampling = {
304
+ policy_version: typeof s.policy_version === 'string'
305
+ ? redactString(s.policy_version, local) : null,
306
+ rate: typeof s.rate === 'number' && Number.isFinite(s.rate) ? s.rate : null,
307
+ };
308
+ continue;
309
+ }
310
+ if (TRACE_ID_FIELDS.includes(key) && typeof value === 'string') {
311
+ out[key] = pseudonymize ? pseudonymize(value, key) : redactString(value, local);
312
+ continue;
313
+ }
314
+ if (typeof value === 'string') {
315
+ out[key] = redactString(value, local);
316
+ continue;
317
+ }
318
+ out[key] = value;
319
+ }
320
+ // The gate already allowlist-pruned the payload — copy it verbatim.
321
+ out.payload = gate.record.payload;
322
+
323
+ entries.push(out);
324
+ stats.kept += 1;
325
+ }
326
+ return { entries, gaps, stats };
327
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * agentRuntime/vmEngine.js — decision-first-runtime G4 (DR-06), VM vertical
3
- * slice, omp-scoped, off-rollout (gated by host-side `decisionRuntime.vm`
4
- * flag; this module reads no config itself).
3
+ * slice, omp-scoped, off-rollout (`decisionRuntime.vm` gates the V-05
4
+ * decision-plane classifyFn default below; deterministic escalation stays
5
+ * the default lane regardless of the flag).
5
6
  *
6
7
  * Executes a compiled ValidatedPlan (SPEC §4 frozen shape — literal nodes
7
8
  * Map, entryNodes, topological order) purely against G1 durable
@@ -39,7 +40,11 @@ import {
39
40
  validateTransition,
40
41
  validateSemanticEvent,
41
42
  validateRetry,
43
+ validateClassifyVerdict,
42
44
  } from './contract.js';
45
+ import { resolveDecisionRuntimeStage } from '../runtimeConfig.js';
46
+ import { createDecisionClient } from '../../decision/client.js';
47
+ import { createVmClassifyFn } from '../../decision/runtimeDecide.js';
43
48
  import {
44
49
  registerContinuation,
45
50
  consumeContinuation,
@@ -88,18 +93,48 @@ async function writeFileAtomic(target, data) {
88
93
  await fs.rename(tmp, target);
89
94
  }
90
95
 
96
+
91
97
  /**
92
98
  * Migration contract (decision plane): `classifyFn` is invoked only when the
93
99
  * IR itself cannot route an event (see deliver()). It may only consult the
94
100
  * decision plane (unic-decision / createDecisionClient registry keys) for a
95
101
  * bounded route/escalate answer — never a general LLM call.
96
102
  *
97
- * @param {{dir:string, now?:()=>Date, classifyFn?:Function, startFn?:Function, hooks?:object}} opts
103
+ * V-05 default: when no `classifyFn` is injected AND
104
+ * `decisionRuntime.vm.stage` resolves non-'off', the engine installs the
105
+ * bounded runtimeDecide adapter (runtime.node_route.v1 /
106
+ * runtime.node_classify.v1 through `decisionClient` — or the default
107
+ * createDecisionClient when none is injected). Stage 'off' (absent or
108
+ * malformed) leaves `classifyFn` unset → zero decision calls, byte-identical
109
+ * behaviour to the deterministic escalation lane.
110
+ *
111
+ * @param {{dir:string, now?:()=>Date, classifyFn?:Function, startFn?:Function,
112
+ * hooks?:object, config?:object, decisionClient?:object,
113
+ * decider?:object}} opts
98
114
  */
99
- export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
115
+ export function createVmEngine({
116
+ dir,
117
+ now,
118
+ classifyFn,
119
+ startFn,
120
+ hooks,
121
+ config,
122
+ decisionClient,
123
+ decider,
124
+ } = {}) {
100
125
  if (typeof dir !== 'string' || dir === '') {
101
126
  throw new VmEngineError('malformed_opts', 'dir required');
102
127
  }
128
+ // The injected classifyFn always wins; the decision-plane adapter is a
129
+ // stage-gated default for embedders that opt in via runtime config.
130
+ const classify =
131
+ classifyFn
132
+ ?? (resolveDecisionRuntimeStage(config ?? null, 'vm') === 'off'
133
+ ? undefined
134
+ : createVmClassifyFn({
135
+ decider,
136
+ client: decisionClient ?? createDecisionClient({ config }),
137
+ }));
103
138
  const clock = typeof now === 'function' ? now : () => new Date();
104
139
  const fire = typeof startFn === 'function' ? startFn : async () => {};
105
140
  const instances = new Map(); // planInstanceId -> instance
@@ -712,20 +747,26 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
712
747
 
713
748
  // classify only when the IR itself cannot route the event (G4-FR05);
714
749
  // classifyFn may consult the decision plane for bounded route/escalate
715
- // answers only — never a general LLM call (see createVmEngine opts).
750
+ // answers only — never a general LLM call (see createVmEngine opts). A
751
+ // throwing classifyFn or a malformed verdict is an unclassifiable event,
752
+ // never a hard failure (V-05 never-throws contract).
716
753
  let outcome = event.safePayload?.outcome;
717
754
  let targetMatched = matched;
718
755
  if (!targetMatched) {
719
- if (typeof classifyFn !== 'function') {
756
+ if (typeof classify !== 'function') {
720
757
  return escalateUnclassified(inst, event);
721
758
  }
722
- const verdict = await classifyFn(event, { planInstanceId: pi, nodeId, node });
723
- if (verdict?.action === 'escalate' || verdict == null) {
724
- return escalateUnclassified(inst, event);
759
+ let verdict = null;
760
+ try {
761
+ verdict = await classify(event, { planInstanceId: pi, nodeId, node });
762
+ } catch {
763
+ verdict = null;
725
764
  }
726
- if (verdict?.action === 'route' && typeof verdict.outcome === 'string') {
765
+ if (validateClassifyVerdict(verdict).ok && verdict.action === 'route') {
727
766
  outcome = verdict.outcome;
728
767
  targetMatched = node != null && inst.nodes[nodeId]?.state === 'running';
768
+ } else {
769
+ return escalateUnclassified(inst, event);
729
770
  }
730
771
  }
731
772
  if (!targetMatched) return escalateUnclassified(inst, event);
@@ -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');
@@ -204,6 +205,18 @@ export function resolveConfigStage(config = null, path) {
204
205
  return VALID_ROUTE_STAGES.has(node) ? node : 'off';
205
206
  }
206
207
 
208
+ // decisionRuntime family stage resolver (V-03): resolves
209
+ // `decisionRuntime.<key>.stage` for one pinned family flag ('vm',
210
+ // 'supervisor', 'scheduler', 'contract', 'adapters', 'context',
211
+ // 'completion', 'quality', 'promotion', 'resourcePolicy',
212
+ // 'diagnostics'). Same semantics as resolveConfigStage: absent config,
213
+ // absent family, non-string key, or malformed stage all resolve 'off'
214
+ // — a bad config can never promote a decisionRuntime slice.
215
+ export function resolveDecisionRuntimeStage(config = null, key = 'vm') {
216
+ if (typeof key !== 'string' || key.trim() === '') return 'off';
217
+ return resolveConfigStage(config, `decisionRuntime.${key}.stage`);
218
+ }
219
+
207
220
  // Pushes a stage-enum error when `node.stage` is present but not reserved.
208
221
  // Absent stage is valid (absence = 'off'); a non-object node is reported by
209
222
  // the caller's own "must be an object" check.
@@ -287,11 +300,14 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
287
300
  },
288
301
  // M01.1 stage keys (docs/pstack/MIGRATION_ROLLBACK named-keys table). Every stage
289
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.
290
306
  routing: {
291
- routeSchema: { stage: 'off' },
292
- rigor: { stage: 'off' },
293
- fastPath: { stage: 'off' },
294
- escalation: { stage: 'off' },
307
+ routeSchema: { stage: 'default' },
308
+ rigor: { stage: 'default' },
309
+ fastPath: { stage: 'default' },
310
+ escalation: { stage: 'default' },
295
311
  },
296
312
  // C52 M07: `unic-decision` is the owner's local non-LLM Lava/JEV model in
297
313
  // UNIC Provider. Its OpenAI-compatible API is transport only, not proof of
@@ -401,13 +417,12 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
401
417
  promotion: { episodeToRuleRequiresApproval: true },
402
418
  recall: { maxRecords: 8 },
403
419
  // M06 rollout flags (SPEC §5 FR-001): per-plane stage on the shared
404
- // off→shadow→canary→default ladder. eligibility/writer/index ship
405
- // 'default' (the w1-2 path is the shipped behavior); decision ships
406
- // '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.
407
422
  eligibility: { stage: 'default' },
408
423
  writer: { stage: 'default' },
409
424
  index: { stage: 'default' },
410
- decision: { stage: 'off' },
425
+ decision: { stage: 'default' },
411
426
  canaryProjects: [],
412
427
  killSwitch: false,
413
428
  },
@@ -426,26 +441,29 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
426
441
  maxRetries: 1,
427
442
  confidenceThreshold: 50,
428
443
  },
429
- // Phase-4 learning loop (SPEC §8a). Advisory only — applyMode 'manual'
430
- // 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.
431
448
  learning: {
432
449
  feedback: { enabled: true, maxEvents: 200 },
433
450
  proposals: { minCount: 3, minSessions: 2 },
434
451
  episodes: { autoWrite: true },
435
- 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 },
436
455
  // C52 M04.2 stage keys (SPEC §5 FR-016–FR-018). candidates: repeated
437
456
  // corrections/suppressions/escalations promote to a LearningCandidate
438
- // only after minOccurrences + cross-session evidence; report-only at
439
- // 'shadow'. overlays: delta overlays on policy fields; base-version
440
- // mismatch → conflict, never silent apply. Promotion stays manual.
441
- candidates: { stage: 'shadow', minOccurrences: 3 },
442
- 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' },
443
462
  },
444
- // C52 M04.1 compact resumable state (SPEC §5 FR-012–FR-015). Stage 'off'
445
- // → no resumable-run records written or consumed; resume falls back to
446
- // 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.
447
465
  continuity: {
448
- resumableRun: { stage: 'off' },
466
+ resumableRun: { stage: 'default' },
449
467
  },
450
468
  // C63 DR-02 + C64 DR-03/04 + C66 DR-06 + C67 DR-07/08 + C69 DR-10
451
469
  // decision-first-runtime (SPEC §2 G1-FR07, §2 G2-FR07/08, §2 G4-FR07,
@@ -459,29 +477,35 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
459
477
  // gates the G6 caller-wait promotion profile and
460
478
  // `decisionRuntime.resourcePolicy` gates the G6 pressure/coalescing
461
479
  // policy; `decisionRuntime.diagnostics` gates the G7 explainable-trace /
462
- // replay / support-record readers. Absence or malformed stage resolves
463
- // 'off' → zero files under .ukit/storage/agent-runtime/, zero behavior
464
- // 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).
465
485
  decisionRuntime: {
466
- contract: { stage: 'off' },
467
- supervisor: { stage: 'off' },
468
- scheduler: { stage: 'off' },
469
- vm: { stage: 'off' },
470
- adapters: { stage: 'off' },
471
- context: { stage: 'off' },
472
- completion: { stage: 'off' },
473
- quality: { stage: 'off' },
474
- promotion: { stage: 'off' },
475
- resourcePolicy: { stage: 'off' },
476
- 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' },
477
497
  },
478
498
 
479
- // C52 M06 experiments (SPEC §5 FR-021–FR-023). Disabled by default —
480
- // 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.
481
502
  experiments: {
482
- deliberation: { enabled: false },
483
- dynamicWorkflow: { enabled: false, maxAttemptsPerFailure: 2, noProgressCap: 3 },
503
+ deliberation: { enabled: true },
504
+ dynamicWorkflow: { enabled: true, maxAttemptsPerFailure: 2, noProgressCap: 3 },
484
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' },
485
509
  safePatch: {
486
510
  enabled: true,
487
511
  strictSharedRisk: true,
@@ -1079,12 +1103,19 @@ export function validateRuntimeConfig(config) {
1079
1103
  errors.push('learning.tuning must be an object.');
1080
1104
  } else {
1081
1105
  pushBooleanError(errors, learning.tuning.enabled, 'learning.tuning.enabled');
1082
- const VALID_APPLY_MODES = new Set(['manual', 'off']);
1106
+ const VALID_APPLY_MODES = new Set(['auto', 'manual', 'off']);
1083
1107
  if (!VALID_APPLY_MODES.has(learning.tuning.applyMode)) {
1084
1108
  errors.push(`learning.tuning.applyMode must be one of: ${[...VALID_APPLY_MODES].join(', ')}.`);
1085
1109
  }
1086
1110
  }
1087
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
+ }
1088
1119
  // C52 M04.2 stage keys — same optional-present contract as routing.*.
1089
1120
  if (learning.candidates !== undefined) {
1090
1121
  if (!isPlainObject(learning.candidates)) {
@@ -1179,7 +1210,7 @@ export async function inspectRuntimeConfig(projectRoot, { homeDir } = {}) {
1179
1210
 
1180
1211
  const user = await readUserConfig(homeDir);
1181
1212
  const userLayer = user.parseError ? null : stripProjectManagedKeys(user.rawConfig);
1182
- const mergedRaw = mergeObjects(userLayer ?? {}, rawConfig ?? {});
1213
+ const mergedRaw = await mergeTunedOverlay(projectRoot, mergeObjects(userLayer ?? {}, rawConfig ?? {}));
1183
1214
 
1184
1215
  const config = normalizeLegacyOpencodeEntries(buildDefaultRuntimeConfig(mergedRaw));
1185
1216
  const validation = validateRuntimeConfig(config);