@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 +17 -0
- package/package.json +1 -1
- package/src/cli/commands/memory.js +11 -87
- package/src/cli/commands/selfImprove.js +55 -0
- package/src/cli/index.js +7 -0
- package/src/core/memory/episodes.js +168 -0
- package/src/core/metadata.js +21 -0
- package/src/core/runInstallPipeline.js +6 -1
- package/src/core/runtimeConfig.js +59 -40
- package/src/learning/selfImprove.js +205 -0
- package/src/learning/tunedOverlay.js +112 -0
- package/template_project/.claude/hooks/session-episode.sh +35 -15
- package/template_project/.claude/ukit/index/route-task.mjs +13 -0
- package/template_project/.claude/ukit/runtime/self-improve-trigger.mjs +98 -0
- package/template_project/ukit/storage/config.json +68 -17
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
|
@@ -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 {
|
|
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
|
-
//
|
|
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
|
-
|
|
593
|
-
|
|
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
|
|
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 !== '
|
|
634
|
-
throw new Error(`episode write failed: ${res.
|
|
557
|
+
if (res.status !== 'recorded') {
|
|
558
|
+
throw new Error(`episode write failed: ${res.reason}`);
|
|
635
559
|
}
|
|
636
|
-
console.log(`[UKit] episode: recorded ${res.
|
|
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
|
+
}
|
package/src/core/metadata.js
CHANGED
|
@@ -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: '
|
|
304
|
-
rigor: { stage: '
|
|
305
|
-
fastPath: { stage: '
|
|
306
|
-
escalation: { stage: '
|
|
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.
|
|
417
|
-
// 'default'
|
|
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: '
|
|
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).
|
|
442
|
-
//
|
|
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: '
|
|
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
|
|
451
|
-
// '
|
|
452
|
-
//
|
|
453
|
-
candidates: { stage: '
|
|
454
|
-
overlays: { stage: '
|
|
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)
|
|
457
|
-
//
|
|
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: '
|
|
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.
|
|
475
|
-
// '
|
|
476
|
-
//
|
|
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: '
|
|
479
|
-
supervisor: { stage: '
|
|
480
|
-
scheduler: { stage: '
|
|
481
|
-
vm: { stage: '
|
|
482
|
-
adapters: { stage: '
|
|
483
|
-
context: { stage: '
|
|
484
|
-
completion: { stage: '
|
|
485
|
-
quality: { stage: '
|
|
486
|
-
promotion: { stage: '
|
|
487
|
-
resourcePolicy: { stage: '
|
|
488
|
-
diagnostics: { stage: '
|
|
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).
|
|
492
|
-
//
|
|
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:
|
|
495
|
-
dynamicWorkflow: { enabled:
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
}
|
|
133
|
-
|
|
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": "
|
|
88
|
+
"stage": "default"
|
|
89
89
|
},
|
|
90
90
|
"rigor": {
|
|
91
|
-
"stage": "
|
|
91
|
+
"stage": "default"
|
|
92
92
|
},
|
|
93
93
|
"fastPath": {
|
|
94
|
-
"stage": "
|
|
94
|
+
"stage": "default"
|
|
95
95
|
},
|
|
96
96
|
"escalation": {
|
|
97
|
-
"stage": "
|
|
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": "
|
|
362
|
+
"applyMode": "auto"
|
|
363
|
+
},
|
|
364
|
+
"selfImprove": {
|
|
365
|
+
"enabled": true
|
|
350
366
|
},
|
|
351
367
|
"candidates": {
|
|
352
|
-
"stage": "
|
|
368
|
+
"stage": "default",
|
|
353
369
|
"minOccurrences": 3
|
|
354
370
|
},
|
|
355
371
|
"overlays": {
|
|
356
|
-
"stage": "
|
|
372
|
+
"stage": "default"
|
|
357
373
|
}
|
|
358
374
|
},
|
|
359
375
|
"continuity": {
|
|
360
376
|
"resumableRun": {
|
|
361
|
-
"stage": "
|
|
377
|
+
"stage": "default"
|
|
362
378
|
}
|
|
363
379
|
},
|
|
364
380
|
"experiments": {
|
|
365
381
|
"deliberation": {
|
|
366
|
-
"enabled":
|
|
382
|
+
"enabled": true
|
|
367
383
|
},
|
|
368
384
|
"dynamicWorkflow": {
|
|
369
|
-
"enabled":
|
|
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":
|
|
604
|
-
"y_nghia": "
|
|
605
|
-
"khi_nao_bat": "Ch\u1ec9
|
|
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": "
|
|
610
|
-
"y_nghia": "
|
|
611
|
-
"khuyen_nghi": "Gi\u1eef
|
|
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.",
|