@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.
- package/CHANGELOG.md +27 -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/commands/telemetry.js +45 -3
- package/src/cli/index.js +7 -0
- package/src/core/agentRuntime/adapters.js +177 -0
- package/src/core/agentRuntime/contract.js +77 -0
- package/src/core/agentRuntime/diagnostics.js +343 -42
- package/src/core/agentRuntime/eventStore.js +139 -0
- package/src/core/agentRuntime/planCompiler.js +45 -6
- package/src/core/agentRuntime/planLibrary.js +43 -7
- package/src/core/agentRuntime/plans/bugfix-loop.json +1 -0
- package/src/core/agentRuntime/plans/flag-promotion.json +138 -0
- package/src/core/agentRuntime/plans/handoff-review-batch.json +1 -0
- package/src/core/agentRuntime/plans/release-check.json +2 -1
- package/src/core/agentRuntime/promotion.js +147 -9
- package/src/core/agentRuntime/runtimeSupport.js +18 -0
- package/src/core/agentRuntime/supervisor.js +44 -2
- package/src/core/agentRuntime/telemetry.js +123 -0
- package/src/core/agentRuntime/vmEngine.js +51 -10
- 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 +71 -40
- package/src/decision/client.js +4 -5
- package/src/decision/runtimeDecide.js +183 -5
- 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/index/unic-decision.mjs +1 -2
- package/template_project/.claude/ukit/runtime/self-improve-trigger.mjs +98 -0
- 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 (
|
|
4
|
-
*
|
|
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
|
-
*
|
|
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({
|
|
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
|
|
756
|
+
if (typeof classify !== 'function') {
|
|
720
757
|
return escalateUnclassified(inst, event);
|
|
721
758
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
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
|
|
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
|
+
}
|
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');
|
|
@@ -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: '
|
|
292
|
-
rigor: { stage: '
|
|
293
|
-
fastPath: { stage: '
|
|
294
|
-
escalation: { stage: '
|
|
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.
|
|
405
|
-
// 'default'
|
|
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: '
|
|
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).
|
|
430
|
-
//
|
|
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: '
|
|
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
|
|
439
|
-
// '
|
|
440
|
-
//
|
|
441
|
-
candidates: { stage: '
|
|
442
|
-
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' },
|
|
443
462
|
},
|
|
444
|
-
// C52 M04.1 compact resumable state (SPEC §5 FR-012–FR-015)
|
|
445
|
-
//
|
|
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: '
|
|
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.
|
|
463
|
-
// '
|
|
464
|
-
//
|
|
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: '
|
|
467
|
-
supervisor: { stage: '
|
|
468
|
-
scheduler: { stage: '
|
|
469
|
-
vm: { stage: '
|
|
470
|
-
adapters: { stage: '
|
|
471
|
-
context: { stage: '
|
|
472
|
-
completion: { stage: '
|
|
473
|
-
quality: { stage: '
|
|
474
|
-
promotion: { stage: '
|
|
475
|
-
resourcePolicy: { stage: '
|
|
476
|
-
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' },
|
|
477
497
|
},
|
|
478
498
|
|
|
479
|
-
// C52 M06 experiments (SPEC §5 FR-021–FR-023).
|
|
480
|
-
//
|
|
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:
|
|
483
|
-
dynamicWorkflow: { enabled:
|
|
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);
|