@ngockhoale/ukit 3.0.8 → 3.0.9
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 +14 -1
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/scripts/audit/decision-coverage.mjs +29 -2
- package/scripts/bench/data-foundation.mjs +52 -3
- package/scripts/bench/decision-runtime-baseline.mjs +427 -0
- package/scripts/bench/decision-runtime-metrics.mjs +67 -0
- package/scripts/bench/decision-runtime-variant.mjs +626 -0
- package/scripts/bench/memory-ablation.mjs +495 -0
- package/scripts/bench/memory-baseline.mjs +596 -0
- package/scripts/bench/memory-bench.mjs +661 -0
- package/scripts/bench/memory-canary.mjs +321 -0
- package/scripts/bench/memory-corpus.mjs +354 -0
- package/scripts/bench/memory-gate.mjs +389 -0
- package/scripts/bench/memory-metrics.mjs +179 -0
- package/scripts/bench/parallel-agents.mjs +33 -11
- package/scripts/bench/recorder-overhead.mjs +204 -0
- package/scripts/bench/sqlite-spike.mjs +451 -0
- package/scripts/measure-decision-gateway.mjs +306 -0
- package/scripts/perf/audit-perf.mjs +35 -17
- package/src/bug/triageBug.js +4 -3
- package/src/cli/commands/memory.js +357 -63
- package/src/context/detectProjectContext.js +11 -1
- package/src/core/agentRuntime/adapters.js +254 -0
- package/src/core/agentRuntime/artifacts.js +192 -0
- package/src/core/agentRuntime/completionGate.js +176 -0
- package/src/core/agentRuntime/context.js +149 -0
- package/src/core/agentRuntime/contract.js +247 -0
- package/src/core/agentRuntime/diagnostics.js +244 -0
- package/src/core/agentRuntime/evaluation.js +163 -0
- package/src/core/agentRuntime/eventStore.js +404 -0
- package/src/core/agentRuntime/liveness.js +60 -0
- package/src/core/agentRuntime/planCompiler.js +322 -0
- package/src/core/agentRuntime/promotion.js +53 -0
- package/src/core/agentRuntime/qualityComparison.js +112 -0
- package/src/core/agentRuntime/recovery.js +266 -0
- package/src/core/agentRuntime/resourcePolicy.js +78 -0
- package/src/core/agentRuntime/runtimeSupport.js +237 -0
- package/src/core/agentRuntime/supervisor.js +565 -0
- package/src/core/agentRuntime/vmEngine.js +621 -0
- package/src/core/codeintel/analogy.js +3 -2
- package/src/core/experiments/dynamicWorkflow.js +17 -2
- package/src/core/fileOps.js +21 -3
- package/src/core/memory/deltaOverlays.js +75 -30
- package/src/core/memory/learningCandidates.js +93 -48
- package/src/core/memory/memoryFlags.js +83 -0
- package/src/core/memory/memoryFreshness.js +190 -0
- package/src/core/memory/memoryHit.js +144 -0
- package/src/core/memory/migrate.js +69 -189
- package/src/core/memory/migrateMapping.js +232 -0
- package/src/core/memory/mutateMemory.js +323 -0
- package/src/core/memory/policy.js +96 -0
- package/src/core/memory/projectIdentity.js +266 -0
- package/src/core/memory/recordIndex.js +178 -0
- package/src/core/memory/recordStore.js +133 -20
- package/src/core/memory/records.js +144 -6
- package/src/core/memory/retrieval.js +259 -125
- package/src/core/memory/store.js +16 -5
- package/src/core/memory/storeBackup.js +226 -0
- package/src/core/memory/storeV2.js +63 -26
- package/src/core/memory/storeV2Loader.js +30 -12
- package/src/core/memory/userMemory.js +38 -20
- package/src/core/memory/writeClassification.js +161 -0
- package/src/core/memory/writeGuard.js +129 -0
- package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
- package/src/core/observability/analytics/cohorts.js +148 -0
- package/src/core/observability/analytics/storeDigest.js +163 -0
- package/src/core/observability/evaluation/experimentPlan.js +95 -0
- package/src/core/observability/evaluation/findings.js +99 -0
- package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
- package/src/core/observability/evaluation/perturbation.js +273 -0
- package/src/core/observability/evaluation/replay.js +7 -1
- package/src/core/observability/evaluation/scorecard.js +23 -3
- package/src/core/observability/rollout.js +11 -7
- package/src/core/observability/schema/compatibility.js +135 -0
- package/src/core/observability/schema/registry.js +99 -0
- package/src/core/observability/schema/validate.js +7 -0
- package/src/core/observability/support/import.js +53 -9
- package/src/core/observability/support/paths.js +13 -3
- package/src/core/observability/support/projector.js +148 -12
- package/src/core/output/index.js +12 -2
- package/src/core/runtimeConfig.js +83 -0
- package/src/core/runtimePaths.js +3 -0
- package/src/core/sensitiveValueScanner.js +40 -0
- package/src/core/token/index.js +40 -3
- package/src/decision/client.js +37 -13
- package/src/decision/protocol.js +1 -1
- package/src/decision/registry.js +5 -3
- package/src/decision/runtimeDecide.js +242 -0
- package/src/decision/runtimeFilter.js +150 -0
- package/src/decision/runtimeScheduler.js +239 -0
- package/src/index/buildIndex.js +13 -12
- package/src/index/queryIndex.js +35 -14
- package/src/index/relatedTests.js +50 -8
- package/src/index/resolveContext.js +9 -4
- package/src/manifest/selectItems.js +7 -3
- package/src/render/instructionRenderer.js +17 -5
- package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
- package/template_project/.claude/ukit/index/route-task.mjs +121 -19
- package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
- package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
- package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
- package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
|
@@ -10,10 +10,21 @@
|
|
|
10
10
|
* - `records` — already-collected canonical records, or
|
|
11
11
|
* - `root` — canonical segment root; records are pulled through
|
|
12
12
|
* readSegments (TASK-006) and its coverage is surfaced.
|
|
13
|
-
* options: { homeDir?, env?, platform?, config?, caps?, now
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* options: { homeDir?, env?, platform?, config?, caps?, now?,
|
|
14
|
+
* projectKey?, projectRoot? }
|
|
15
|
+
* - `config.observability.stage` — 'off' is the kill switch: the call
|
|
16
|
+
* returns skipped/stage_off AND best-effort invalidates this
|
|
17
|
+
* project's previously-owned material (foreign files survive).
|
|
16
18
|
* - `caps` — { maxBytes, maxAgeMs, maxDigests, maxRecords }.
|
|
19
|
+
* - `projectKey`/`projectRoot`/`snapshot.project_key` — project identity
|
|
20
|
+
* for the per-project subdirectory
|
|
21
|
+
* `proj-<sha256(key).slice(0,12)>`; precedence
|
|
22
|
+
* projectKey > project_key > projectRoot > literal 'unknown'.
|
|
23
|
+
*
|
|
24
|
+
* invalidateSupportView(dir) → { ok, removed } | { ok: false, reason }
|
|
25
|
+
* Removes only projector-owned names (our-format manifest list,
|
|
26
|
+
* isOwnedName allowlist, digest-* convention); never follows
|
|
27
|
+
* symlinks, never touches foreign files.
|
|
17
28
|
*
|
|
18
29
|
* Pipeline: stage gate → Documents resolution → collect → double-gate
|
|
19
30
|
* (validateSemanticRecord + sanitizeForSupport — default-deny, unknown
|
|
@@ -192,7 +203,7 @@ function renderSummary({ generatedAt, lagMs, coverage, caps, traces }) {
|
|
|
192
203
|
return lines.join('\n');
|
|
193
204
|
}
|
|
194
205
|
|
|
195
|
-
function buildManifest({ generatedAt, pseudonym, coverage, caps, files }) {
|
|
206
|
+
function buildManifest({ generatedAt, pseudonym, coverage, caps, files, projectIdSource }) {
|
|
196
207
|
const fileEntries = {};
|
|
197
208
|
for (const [name, content] of Object.entries(files)) {
|
|
198
209
|
if (name === 'manifest.json') continue;
|
|
@@ -207,6 +218,7 @@ function buildManifest({ generatedAt, pseudonym, coverage, caps, files }) {
|
|
|
207
218
|
project_ref_pseudonym: pseudonym,
|
|
208
219
|
coverage,
|
|
209
220
|
caps,
|
|
221
|
+
project_id_source: projectIdSource,
|
|
210
222
|
files: fileEntries,
|
|
211
223
|
};
|
|
212
224
|
}
|
|
@@ -223,6 +235,98 @@ async function collectRecords(snapshot, coverage) {
|
|
|
223
235
|
return [];
|
|
224
236
|
}
|
|
225
237
|
|
|
238
|
+
/**
|
|
239
|
+
* Project-key precedence (TASK-001 / G4-FR05):
|
|
240
|
+
* options.projectKey > snapshot.project_key > options.projectRoot > 'unknown'
|
|
241
|
+
* The literal 'unknown' key is never a repo basename — identity is the key,
|
|
242
|
+
* never the pathname.
|
|
243
|
+
*/
|
|
244
|
+
function projectKeyFor(snapshot, options) {
|
|
245
|
+
if (typeof options.projectKey === 'string' && options.projectKey.length > 0) {
|
|
246
|
+
return { key: options.projectKey, source: 'project_key' };
|
|
247
|
+
}
|
|
248
|
+
if (typeof snapshot.project_key === 'string' && snapshot.project_key.length > 0) {
|
|
249
|
+
return { key: snapshot.project_key, source: 'project_key' };
|
|
250
|
+
}
|
|
251
|
+
if (typeof options.projectRoot === 'string' && options.projectRoot.length > 0) {
|
|
252
|
+
return { key: options.projectRoot, source: 'project_root' };
|
|
253
|
+
}
|
|
254
|
+
return { key: 'unknown', source: 'unknown' };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function projectDirName(key) {
|
|
258
|
+
return `proj-${sha256Hex(key).slice(0, 12)}`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* invalidateSupportView(dir) → { ok: true, removed: string[] }
|
|
263
|
+
* | { ok: false, reason: string }
|
|
264
|
+
*
|
|
265
|
+
* Removes ONLY material this projector owns: names in a manifest that
|
|
266
|
+
* carries our `format`, plus the retention `isOwnedName` allowlist and the
|
|
267
|
+
* `digest-*` convention. Never follows symlinks, never touches foreign
|
|
268
|
+
* files, never removes the directory itself. A missing dir is a clean
|
|
269
|
+
* no-op; a symlinked or non-directory path is a typed refusal.
|
|
270
|
+
*/
|
|
271
|
+
export async function invalidateSupportView(dir) {
|
|
272
|
+
if (typeof dir !== 'string' || dir.length === 0) {
|
|
273
|
+
return { ok: false, reason: 'invalid_dir' };
|
|
274
|
+
}
|
|
275
|
+
let st;
|
|
276
|
+
try {
|
|
277
|
+
st = await fs.promises.lstat(dir);
|
|
278
|
+
} catch (err) {
|
|
279
|
+
if (err && err.code === 'ENOENT') return { ok: true, removed: [] };
|
|
280
|
+
return { ok: false, reason: ioReason(err) };
|
|
281
|
+
}
|
|
282
|
+
if (st.isSymbolicLink() || !st.isDirectory()) {
|
|
283
|
+
return { ok: false, reason: 'unsupported_file_type' };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Manifest-confirmed ownership set (our format only).
|
|
287
|
+
let manifestOwned = null;
|
|
288
|
+
try {
|
|
289
|
+
const prev = JSON.parse(await fs.promises.readFile(path.join(dir, 'manifest.json'), 'utf8'));
|
|
290
|
+
if (isPlainObject(prev) && prev.format === SUPPORT_FORMAT && isPlainObject(prev.files)) {
|
|
291
|
+
manifestOwned = new Set(Object.keys(prev.files));
|
|
292
|
+
manifestOwned.add('manifest.json');
|
|
293
|
+
}
|
|
294
|
+
} catch {
|
|
295
|
+
manifestOwned = null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
let entries;
|
|
299
|
+
try {
|
|
300
|
+
entries = await fs.promises.readdir(dir);
|
|
301
|
+
} catch (err) {
|
|
302
|
+
return { ok: false, reason: ioReason(err) };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const removed = [];
|
|
306
|
+
for (const name of entries) {
|
|
307
|
+
const owned =
|
|
308
|
+
(manifestOwned !== null && manifestOwned.has(name)) ||
|
|
309
|
+
isOwnedName(name) ||
|
|
310
|
+
name.startsWith('digest-');
|
|
311
|
+
if (!owned) continue;
|
|
312
|
+
let target;
|
|
313
|
+
try {
|
|
314
|
+
target = await fs.promises.lstat(path.join(dir, name));
|
|
315
|
+
} catch {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
// regular files only — a symlink or directory at an owned name is not
|
|
319
|
+
// ours to unlink (never traverse, never rm -r).
|
|
320
|
+
if (!target.isFile()) continue;
|
|
321
|
+
try {
|
|
322
|
+
await fs.promises.rm(path.join(dir, name), { force: true });
|
|
323
|
+
removed.push(name);
|
|
324
|
+
} catch {
|
|
325
|
+
// already gone — best effort
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
return { ok: true, removed };
|
|
329
|
+
}
|
|
226
330
|
/**
|
|
227
331
|
* @param {object} snapshot
|
|
228
332
|
* @param {object} [options]
|
|
@@ -230,22 +334,54 @@ async function collectRecords(snapshot, coverage) {
|
|
|
230
334
|
*/
|
|
231
335
|
export async function projectSupport(snapshot = {}, options = {}) {
|
|
232
336
|
const none = { status: 'skipped', bytes: 0 };
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
337
|
+
const snap = isPlainObject(snapshot) ? snapshot : {};
|
|
338
|
+
const { key: projectKey, source: projectIdSource } = projectKeyFor(snap, options);
|
|
339
|
+
const stageOff = resolveStage(options.config) === 'off';
|
|
236
340
|
|
|
237
341
|
const resolved = await resolveSupportDir({
|
|
238
342
|
homeDir: options.homeDir,
|
|
239
343
|
env: options.env,
|
|
240
344
|
platform: options.platform,
|
|
241
345
|
});
|
|
346
|
+
|
|
347
|
+
if (stageOff) {
|
|
348
|
+
// Kill switch: previously-owned support material for this project must
|
|
349
|
+
// not survive the switch. Best-effort — never blocks, never touches
|
|
350
|
+
// foreign files, skips silently when Documents cannot resolve.
|
|
351
|
+
if (resolved.ok) {
|
|
352
|
+
try {
|
|
353
|
+
await invalidateSupportView(path.join(resolved.dir, projectDirName(projectKey)));
|
|
354
|
+
} catch {
|
|
355
|
+
// invalidation is best-effort; the skip result is unchanged
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return { ...none, reason: 'stage_off' };
|
|
359
|
+
}
|
|
360
|
+
|
|
242
361
|
if (!resolved.ok) {
|
|
243
362
|
return { status: 'degraded', reason: resolved.reason, bytes: 0 };
|
|
244
363
|
}
|
|
245
|
-
const dir = resolved.dir;
|
|
246
364
|
|
|
247
365
|
// The support path itself must be a real directory — never a symlink,
|
|
248
366
|
// never a foreign file. Missing is fine; we create it.
|
|
367
|
+
try {
|
|
368
|
+
const st = await fs.promises.lstat(resolved.dir);
|
|
369
|
+
if (st.isSymbolicLink()) {
|
|
370
|
+
return { ...none, reason: 'support_path_symlink' };
|
|
371
|
+
}
|
|
372
|
+
if (!st.isDirectory()) {
|
|
373
|
+
return { ...none, reason: 'support_path_not_directory' };
|
|
374
|
+
}
|
|
375
|
+
} catch (err) {
|
|
376
|
+
if (!err || err.code !== 'ENOENT') {
|
|
377
|
+
return { status: 'degraded', reason: ioReason(err), bytes: 0 };
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// Per-project isolation: this project's material lives in its own
|
|
382
|
+
// proj-<sha256(key).slice(0,12)> subdirectory so alternating projections
|
|
383
|
+
// from sibling projects never share or clobber a manifest.
|
|
384
|
+
const dir = path.join(resolved.dir, projectDirName(projectKey));
|
|
249
385
|
try {
|
|
250
386
|
const st = await fs.promises.lstat(dir);
|
|
251
387
|
if (st.isSymbolicLink()) {
|
|
@@ -277,7 +413,7 @@ export async function projectSupport(snapshot = {}, options = {}) {
|
|
|
277
413
|
};
|
|
278
414
|
|
|
279
415
|
// --- collect + double-gate ---
|
|
280
|
-
const raw = await collectRecords(
|
|
416
|
+
const raw = await collectRecords(snap, coverage);
|
|
281
417
|
coverage.records_in = raw.length;
|
|
282
418
|
|
|
283
419
|
const kept = [];
|
|
@@ -314,8 +450,8 @@ export async function projectSupport(snapshot = {}, options = {}) {
|
|
|
314
450
|
coverage.records_projected = projected.length;
|
|
315
451
|
|
|
316
452
|
const projectRef =
|
|
317
|
-
typeof
|
|
318
|
-
?
|
|
453
|
+
typeof snap.project_ref === 'string' && snap.project_ref.length > 0
|
|
454
|
+
? snap.project_ref
|
|
319
455
|
: (kept.find((r) => typeof r.project_ref === 'string') || {}).project_ref;
|
|
320
456
|
const projectPseudonym = projectRef
|
|
321
457
|
? `proj-${sha256Hex(`${salt}:project:${projectRef}`).slice(0, 16)}`
|
|
@@ -390,7 +526,7 @@ export async function projectSupport(snapshot = {}, options = {}) {
|
|
|
390
526
|
coverage.truncated_records += recordLines.length - keptLines;
|
|
391
527
|
files['records.jsonl'] = keptLines > 0 ? `${recordLines.slice(0, keptLines).join('\n')}\n` : '';
|
|
392
528
|
|
|
393
|
-
const manifest = buildManifest({ generatedAt, pseudonym: projectPseudonym, coverage, caps, files });
|
|
529
|
+
const manifest = buildManifest({ generatedAt, pseudonym: projectPseudonym, coverage, caps, files, projectIdSource });
|
|
394
530
|
files['manifest.json'] = `${JSON.stringify(manifest, null, 2)}\n`;
|
|
395
531
|
|
|
396
532
|
// --- write phase: per-file atomic temp-rename, manifest last ---
|
package/src/core/output/index.js
CHANGED
|
@@ -310,6 +310,9 @@ function buildRawOutputText({ command = '', stdout = '', stderr = '', exitCode =
|
|
|
310
310
|
}
|
|
311
311
|
|
|
312
312
|
function buildRecoveryHintSummary(summary, rawPath, { tokensBefore = 0 } = {}) {
|
|
313
|
+
// TASK-003 fix round 1: a failed tee persist returns rawPath='' — never emit a
|
|
314
|
+
// dangling `- Full output: ` hint that points at nothing.
|
|
315
|
+
if (!rawPath) return null;
|
|
313
316
|
const recoveryLine = `- Full output: ${rawPath}`;
|
|
314
317
|
const summaryLines = String(summary ?? '')
|
|
315
318
|
.split(/\r?\n/)
|
|
@@ -457,8 +460,15 @@ async function persistRawOutput(projectRoot, {
|
|
|
457
460
|
exitCode,
|
|
458
461
|
});
|
|
459
462
|
|
|
460
|
-
|
|
461
|
-
|
|
463
|
+
try {
|
|
464
|
+
await fs.mkdir(teeCacheDir, { recursive: true });
|
|
465
|
+
await fs.writeFile(absolutePath, rawOutputText, 'utf8');
|
|
466
|
+
} catch {
|
|
467
|
+
// TASK-003 / FR-006: an unwritable tee dir (EACCES, ENOSPC, EEXIST on a
|
|
468
|
+
// non-dir, …) must degrade to "no raw file" — never reject the caller's
|
|
469
|
+
// compression result.
|
|
470
|
+
return { rawSaved: false, rawPath: '', rawBytes: 0 };
|
|
471
|
+
}
|
|
462
472
|
// Bounded, sampled tee/ prune (BUG-C21-07): advisory only.
|
|
463
473
|
await maybeSweepTeeCache(teeCacheDir).catch(() => {});
|
|
464
474
|
|
|
@@ -400,6 +400,16 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
400
400
|
episodeTtlDays: 90,
|
|
401
401
|
promotion: { episodeToRuleRequiresApproval: true },
|
|
402
402
|
recall: { maxRecords: 8 },
|
|
403
|
+
// M06 rollout flags (SPEC §5 FR-001): per-plane stage on the shared
|
|
404
|
+
// off→shadow→canary→default ladder. eligibility/writer/index ship
|
|
405
|
+
// 'default' (the w1-2 path is the shipped behavior); decision ships
|
|
406
|
+
// 'off' — plumbing only, no consumer yet. killSwitch is absolute.
|
|
407
|
+
eligibility: { stage: 'default' },
|
|
408
|
+
writer: { stage: 'default' },
|
|
409
|
+
index: { stage: 'default' },
|
|
410
|
+
decision: { stage: 'off' },
|
|
411
|
+
canaryProjects: [],
|
|
412
|
+
killSwitch: false,
|
|
403
413
|
},
|
|
404
414
|
memory: {
|
|
405
415
|
enabled: true,
|
|
@@ -437,6 +447,35 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
|
|
|
437
447
|
continuity: {
|
|
438
448
|
resumableRun: { stage: 'off' },
|
|
439
449
|
},
|
|
450
|
+
// C63 DR-02 + C64 DR-03/04 + C66 DR-06 + C67 DR-07/08 + C69 DR-10
|
|
451
|
+
// decision-first-runtime (SPEC §2 G1-FR07, §2 G2-FR07/08, §2 G4-FR07,
|
|
452
|
+
// §2 G5-FR07, §2 G7-FR04/07). Pinned keys — `decisionRuntime.contract`
|
|
453
|
+
// gates the G1 contract/eventStore prototype; `decisionRuntime.supervisor`
|
|
454
|
+
// gates the G2 owned-process supervisor; `decisionRuntime.scheduler`
|
|
455
|
+
// gates the G3 scheduler; `decisionRuntime.vm` gates the G4 plan-VM
|
|
456
|
+
// engine; `decisionRuntime.adapters`, `decisionRuntime.context`,
|
|
457
|
+
// `decisionRuntime.completion`, and `decisionRuntime.quality` gate the
|
|
458
|
+
// G5 adapters/context/completion/quality slice; `decisionRuntime.promotion`
|
|
459
|
+
// gates the G6 caller-wait promotion profile and
|
|
460
|
+
// `decisionRuntime.resourcePolicy` gates the G6 pressure/coalescing
|
|
461
|
+
// policy; `decisionRuntime.diagnostics` gates the G7 explainable-trace /
|
|
462
|
+
// replay / support-record readers. Absence or malformed stage resolves
|
|
463
|
+
// 'off' → zero files under .ukit/storage/agent-runtime/, zero behavior
|
|
464
|
+
// change.
|
|
465
|
+
decisionRuntime: {
|
|
466
|
+
contract: { stage: 'off' },
|
|
467
|
+
supervisor: { stage: 'off' },
|
|
468
|
+
scheduler: { stage: 'off' },
|
|
469
|
+
vm: { stage: 'off' },
|
|
470
|
+
adapters: { stage: 'off' },
|
|
471
|
+
context: { stage: 'off' },
|
|
472
|
+
completion: { stage: 'off' },
|
|
473
|
+
quality: { stage: 'off' },
|
|
474
|
+
promotion: { stage: 'off' },
|
|
475
|
+
resourcePolicy: { stage: 'off' },
|
|
476
|
+
diagnostics: { stage: 'off' },
|
|
477
|
+
},
|
|
478
|
+
|
|
440
479
|
// C52 M06 experiments (SPEC §5 FR-021–FR-023). Disabled by default —
|
|
441
480
|
// zero calls and zero prompt content when off; no auto-promotion.
|
|
442
481
|
experiments: {
|
|
@@ -731,6 +770,29 @@ export function validateRuntimeConfig(config) {
|
|
|
731
770
|
}
|
|
732
771
|
}
|
|
733
772
|
|
|
773
|
+
// C63 DR-02 + C64 DR-03/04 + C67 DR-07/08 + C68 DR-09 + C69 DR-10
|
|
774
|
+
// decisionRuntime — optional-present; carries one stage key per prototype
|
|
775
|
+
// (pinned: decisionRuntime.contract, decisionRuntime.supervisor,
|
|
776
|
+
// decisionRuntime.scheduler, decisionRuntime.vm, decisionRuntime.adapters,
|
|
777
|
+
// decisionRuntime.context, decisionRuntime.completion,
|
|
778
|
+
// decisionRuntime.quality, decisionRuntime.promotion,
|
|
779
|
+
// decisionRuntime.resourcePolicy, decisionRuntime.diagnostics). Absent →
|
|
780
|
+
// valid; malformed stage → error + resolver falls back to 'off'.
|
|
781
|
+
if (config.decisionRuntime !== undefined) {
|
|
782
|
+
if (!isPlainObject(config.decisionRuntime)) {
|
|
783
|
+
errors.push('decisionRuntime must be an object.');
|
|
784
|
+
} else {
|
|
785
|
+
for (const key of ['contract', 'supervisor', 'scheduler', 'vm', 'adapters', 'context', 'completion', 'quality', 'promotion', 'resourcePolicy', 'diagnostics']) {
|
|
786
|
+
const stageKey = config.decisionRuntime[key];
|
|
787
|
+
if (stageKey === undefined) continue;
|
|
788
|
+
if (!isPlainObject(stageKey)) {
|
|
789
|
+
errors.push(`decisionRuntime.${key} must be an object.`);
|
|
790
|
+
} else {
|
|
791
|
+
pushStageError(errors, stageKey, `decisionRuntime.${key}`);
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
734
796
|
// C52 M06 experiments — optional-present; both experiments are
|
|
735
797
|
// disabled-by-default booleans plus bounded breakers.
|
|
736
798
|
if (config.experiments !== undefined) {
|
|
@@ -922,6 +984,27 @@ export function validateRuntimeConfig(config) {
|
|
|
922
984
|
} else {
|
|
923
985
|
pushPositiveNumberError(errors, memoryV2.recall.maxRecords, 'memoryV2.recall.maxRecords');
|
|
924
986
|
}
|
|
987
|
+
// M06 rollout flags (SPEC §5 FR-001): stage enum per plane, boolean
|
|
988
|
+
// killSwitch, string-array canaryProjects. All optional — absent means
|
|
989
|
+
// 'off'/false/[] at read time; malformed stage → validation error AND
|
|
990
|
+
// resolves 'off' (flags only reduce capability).
|
|
991
|
+
for (const plane of ['eligibility', 'writer', 'index', 'decision']) {
|
|
992
|
+
if (memoryV2[plane] === undefined) continue;
|
|
993
|
+
if (!isPlainObject(memoryV2[plane])) {
|
|
994
|
+
errors.push(`memoryV2.${plane} must be an object.`);
|
|
995
|
+
} else {
|
|
996
|
+
pushStageError(errors, memoryV2[plane], `memoryV2.${plane}`);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
if (memoryV2.killSwitch !== undefined) {
|
|
1000
|
+
pushBooleanError(errors, memoryV2.killSwitch, 'memoryV2.killSwitch');
|
|
1001
|
+
}
|
|
1002
|
+
if (memoryV2.canaryProjects !== undefined) {
|
|
1003
|
+
if (!Array.isArray(memoryV2.canaryProjects)
|
|
1004
|
+
|| memoryV2.canaryProjects.some((entry) => typeof entry !== 'string')) {
|
|
1005
|
+
errors.push('memoryV2.canaryProjects must be an array of strings.');
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
925
1008
|
}
|
|
926
1009
|
|
|
927
1010
|
if (!isPlainObject(config.memory)) {
|
package/src/core/runtimePaths.js
CHANGED
|
@@ -22,6 +22,9 @@ export function buildRuntimePaths(projectRoot) {
|
|
|
22
22
|
outputHistoryPath: path.join(cacheRoot, 'output-history.json'),
|
|
23
23
|
userMemoryPath: path.join(memoryRoot, 'user.json'),
|
|
24
24
|
projectsDir: path.join(memoryRoot, 'projects'),
|
|
25
|
+
// Reserved identity hint slot (SPEC §14): a hint only — the user-level
|
|
26
|
+
// registry ~/.ukit/storage/projects/registry.json is the authority.
|
|
27
|
+
projectIdentityPath: path.join(memoryRoot, 'project.json'),
|
|
25
28
|
sessionsDir: path.join(memoryRoot, 'sessions'),
|
|
26
29
|
};
|
|
27
30
|
}
|
|
@@ -36,7 +36,11 @@ const TOKEN_PATTERNS = [
|
|
|
36
36
|
{ label: 'Google API key', re: /(?<![A-Za-z0-9+/_-])AIza[0-9A-Za-z_-]{20,}(?![A-Za-z0-9+/_-])/g },
|
|
37
37
|
{ label: 'Stripe live key', re: /\b[srp]k_live_[A-Za-z0-9]{20,}/g },
|
|
38
38
|
{ label: 'JWT', re: /\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g },
|
|
39
|
+
// Whole-block pattern must precede the BEGIN-marker pattern so redactText
|
|
40
|
+
// consumes the full PEM span before the marker can match inside it.
|
|
41
|
+
{ label: 'private-key-block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
39
42
|
{ label: 'private key block', re: /-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY-----/g },
|
|
43
|
+
{ label: 'generic-credential', re: /(?:password|passwd|secret|api[_-]?key|access[_-]?token|auth[_-]?token)\s*[:=]\s*['"]?[A-Za-z0-9/+=_.\-]{16,}/gi },
|
|
40
44
|
];
|
|
41
45
|
|
|
42
46
|
const SHA256_HEX_RE = /^[0-9a-f]{64}$/i;
|
|
@@ -86,6 +90,42 @@ export function scanText(text, options = {}) {
|
|
|
86
90
|
return { hasSecret: true, allowed: false, labels: [...labels] };
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
/**
|
|
94
|
+
* Redact high-confidence secret values from text, replacing each matched
|
|
95
|
+
* span with `[REDACTED:<label>]` — the label names the pattern class, never
|
|
96
|
+
* the value. Allowlisted exact values are left intact; gate-off is a clean
|
|
97
|
+
* passthrough. Never throws; non-string input returns `{text, redacted:false,
|
|
98
|
+
* labels:[]}` with `text` echoed back.
|
|
99
|
+
*
|
|
100
|
+
* @param {string} text
|
|
101
|
+
* @param {{ allowlistHashes?: string[], gateEnabled?: boolean }} [options]
|
|
102
|
+
* @returns {{ text: string, redacted: boolean, labels: string[] }}
|
|
103
|
+
*/
|
|
104
|
+
export function redactText(text, options = {}) {
|
|
105
|
+
const { allowlistHashes = [], gateEnabled = true } = options || {};
|
|
106
|
+
const clean = { text, redacted: false, labels: [] };
|
|
107
|
+
if (gateEnabled === false) return clean;
|
|
108
|
+
if (typeof text !== 'string' || text.length === 0) return clean;
|
|
109
|
+
|
|
110
|
+
const allowed = new Set(
|
|
111
|
+
Array.isArray(allowlistHashes)
|
|
112
|
+
? allowlistHashes.filter((h) => typeof h === 'string').map((h) => h.toLowerCase())
|
|
113
|
+
: [],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
const labels = new Set();
|
|
117
|
+
let out = text;
|
|
118
|
+
for (const { label, re } of TOKEN_PATTERNS) {
|
|
119
|
+
const marker = `[REDACTED:${label}]`;
|
|
120
|
+
out = out.replace(re, (match) => {
|
|
121
|
+
if (allowed.has(sha256(match))) return match;
|
|
122
|
+
labels.add(label);
|
|
123
|
+
return marker;
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
return { text: out, redacted: labels.size > 0, labels: [...labels] };
|
|
127
|
+
}
|
|
128
|
+
|
|
89
129
|
/**
|
|
90
130
|
* Gate toggle: enabled unless security.sensitiveDataGate === false
|
|
91
131
|
* (mirrors the hook's explicit-false check on .ukit/storage/config.json).
|
package/src/core/token/index.js
CHANGED
|
@@ -270,9 +270,17 @@ export async function readPromptCacheEntry(
|
|
|
270
270
|
...cache.entries.slice(0, entryIndex),
|
|
271
271
|
...cache.entries.slice(entryIndex + 1),
|
|
272
272
|
].slice(0, maxEntries);
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
273
|
+
try {
|
|
274
|
+
const nextDocument = normalizePromptCacheDocument({ entries }, { maxEntries });
|
|
275
|
+
await writePromptCacheDocument(projectRoot, nextDocument);
|
|
276
|
+
return nextDocument.entries[0] ?? touchedEntry;
|
|
277
|
+
} catch {
|
|
278
|
+
// TASK-006 (SPEC §5 FR-009): the touch-write is a side effect of a READ —
|
|
279
|
+
// a full/unwritable cache (EACCES, ENOSPC, ...) must never reject the
|
|
280
|
+
// read. Degrade to the in-memory touched entry; the persisted hitCount
|
|
281
|
+
// simply lags until a later write succeeds.
|
|
282
|
+
return touchedEntry;
|
|
283
|
+
}
|
|
276
284
|
}
|
|
277
285
|
|
|
278
286
|
export async function writePromptCacheEntry(
|
|
@@ -297,6 +305,35 @@ export async function writePromptCacheEntry(
|
|
|
297
305
|
return nextDocument;
|
|
298
306
|
}
|
|
299
307
|
|
|
308
|
+
/**
|
|
309
|
+
* deletePromptCacheEntries(projectRoot, {selectedIds}) → Promise<{removed:int}>
|
|
310
|
+
* Purge sweep (SPEC §5 FR-018): drops every cache entry whose
|
|
311
|
+
* `metadata.selectedIds` intersects `selectedIds` — cached content embeds
|
|
312
|
+
* record text, so purged records must not linger in the cache. Persists via
|
|
313
|
+
* the same normalize+write path as the other cache writers. Empty or absent
|
|
314
|
+
* cache → {removed:0}; never throws.
|
|
315
|
+
*/
|
|
316
|
+
export async function deletePromptCacheEntries(projectRoot, { selectedIds } = {}) {
|
|
317
|
+
try {
|
|
318
|
+
const ids = new Set(Array.isArray(selectedIds) ? selectedIds : []);
|
|
319
|
+
if (ids.size === 0) return { removed: 0 };
|
|
320
|
+
const cache = await readPromptCacheDocument(projectRoot);
|
|
321
|
+
if (cache.entries.length === 0) return { removed: 0 };
|
|
322
|
+
const kept = cache.entries.filter((entry) => {
|
|
323
|
+
const entryIds = Array.isArray(entry?.metadata?.selectedIds)
|
|
324
|
+
? entry.metadata.selectedIds
|
|
325
|
+
: [];
|
|
326
|
+
return !entryIds.some((id) => ids.has(id));
|
|
327
|
+
});
|
|
328
|
+
const removed = cache.entries.length - kept.length;
|
|
329
|
+
if (removed === 0) return { removed: 0 };
|
|
330
|
+
await writePromptCacheDocument(projectRoot, { entries: kept });
|
|
331
|
+
return { removed };
|
|
332
|
+
} catch {
|
|
333
|
+
return { removed: 0 };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
300
337
|
export function buildPromptCacheStats(rawCache) {
|
|
301
338
|
const cache = normalizePromptCacheDocument(rawCache);
|
|
302
339
|
const totalEntries = cache.entries.length;
|
package/src/decision/client.js
CHANGED
|
@@ -213,21 +213,36 @@ export function createDecisionClient({
|
|
|
213
213
|
|
|
214
214
|
// Language classification covers state + question text; the English
|
|
215
215
|
// checkpoint is reachable only when the whole request proves English.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
const checkpoint = resolveCheckpoint(languageClass, config);
|
|
222
|
-
|
|
223
|
-
// Budget gate — oversize state is never silently truncated.
|
|
216
|
+
// Malformed batch input (cyclic state, non-serializable values, internal
|
|
217
|
+
// budget-gate faults) resolves to a typed 'invalid' outcome instead of
|
|
218
|
+
// throwing — requestBatch never throws (unic-decision.mjs:575-586 parity).
|
|
219
|
+
let serialized;
|
|
220
|
+
let checkpoint;
|
|
224
221
|
try {
|
|
222
|
+
serialized = serializeStatePacket(batch.statePacket ?? {});
|
|
223
|
+
const questionText = questions
|
|
224
|
+
.map(
|
|
225
|
+
(q) =>
|
|
226
|
+
`${q.decisionKey} ${q.instruction} ${(q.candidates ?? []).join(' ')}`,
|
|
227
|
+
)
|
|
228
|
+
.join(' ');
|
|
229
|
+
const languageClass = classifyLanguage(`${serialized} ${questionText}`);
|
|
230
|
+
checkpoint = resolveCheckpoint(languageClass, config);
|
|
231
|
+
|
|
232
|
+
// Budget gate — oversize state is never silently truncated.
|
|
225
233
|
assertStateBudget(serialized, languageClass, maxStateTokens);
|
|
226
234
|
} catch (err) {
|
|
227
235
|
if (err?.code === 'state-too-large') {
|
|
228
|
-
return outcome('invalid', {
|
|
236
|
+
return outcome('invalid', {
|
|
237
|
+
fallbackCode: 'state-too-large',
|
|
238
|
+
checkpoint: checkpoint ?? null,
|
|
239
|
+
});
|
|
229
240
|
}
|
|
230
|
-
|
|
241
|
+
return outcome('invalid', {
|
|
242
|
+
fallbackCode: 'malformed-question',
|
|
243
|
+
checkpoint: checkpoint ?? null,
|
|
244
|
+
detail: err?.message ?? String(err),
|
|
245
|
+
});
|
|
231
246
|
}
|
|
232
247
|
|
|
233
248
|
// Sensitive-value gate before serialization leaves the process.
|
|
@@ -250,9 +265,18 @@ export function createDecisionClient({
|
|
|
250
265
|
});
|
|
251
266
|
}
|
|
252
267
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
268
|
+
let body;
|
|
269
|
+
try {
|
|
270
|
+
body = JSON.stringify(
|
|
271
|
+
encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
|
|
272
|
+
);
|
|
273
|
+
} catch (error) {
|
|
274
|
+
return outcome('invalid', {
|
|
275
|
+
fallbackCode: 'malformed-question',
|
|
276
|
+
checkpoint,
|
|
277
|
+
detail: error?.message ?? String(error),
|
|
278
|
+
});
|
|
279
|
+
}
|
|
256
280
|
const fingerprint = requestFingerprint(body);
|
|
257
281
|
const deadlineMs = Number.isFinite(batch?.deadlineMs)
|
|
258
282
|
? batch.deadlineMs
|
package/src/decision/protocol.js
CHANGED
|
@@ -172,7 +172,7 @@ function validateValue(question, args) {
|
|
|
172
172
|
const value = args?.value;
|
|
173
173
|
if (question.kind === 'choice') {
|
|
174
174
|
if (typeof value !== 'string') return 'missing-value';
|
|
175
|
-
if (!question.candidates.includes(value)) return 'out-of-enum';
|
|
175
|
+
if (!Array.isArray(question.candidates) || !question.candidates.includes(value)) return 'out-of-enum';
|
|
176
176
|
return { value };
|
|
177
177
|
}
|
|
178
178
|
if (question.kind === 'noul') {
|
package/src/decision/registry.js
CHANGED
|
@@ -23,6 +23,7 @@ export const DECISION_FAMILIES = new Set([
|
|
|
23
23
|
'review',
|
|
24
24
|
'resume',
|
|
25
25
|
'learn',
|
|
26
|
+
'runtime',
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
29
|
export const DECISION_KINDS = new Set(['choice', 'noul', 'score']);
|
|
@@ -37,9 +38,10 @@ export const CACHE_SENSITIVITY = new Set([
|
|
|
37
38
|
'verified-native-update',
|
|
38
39
|
'none',
|
|
39
40
|
]);
|
|
40
|
-
|
|
41
41
|
// Stable dotted key ending in an explicit schema version: `family.name.vN`.
|
|
42
|
-
|
|
42
|
+
// Segments allow `[a-z0-9_-]` — registered runtime keys (runtime.stall_action.v1,
|
|
43
|
+
// runtime.wake_needed.v1, runtime.safe_retry.v1) use snake_case segments.
|
|
44
|
+
const DECISION_KEY_PATTERN = /^[a-z][a-z0-9_-]*(\.[a-z0-9_-]+)*\.v[0-9]+$/;
|
|
43
45
|
|
|
44
46
|
const REQUIRED_STRING_FIELDS = [
|
|
45
47
|
'decisionKey',
|
|
@@ -280,7 +282,7 @@ function isPlainObject(value) {
|
|
|
280
282
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
281
283
|
}
|
|
282
284
|
|
|
283
|
-
function validateEntry(entry, index, seen) {
|
|
285
|
+
export function validateEntry(entry, index, seen) {
|
|
284
286
|
const label = isPlainObject(entry) && typeof entry.decisionKey === 'string'
|
|
285
287
|
? entry.decisionKey
|
|
286
288
|
: `entry[${index}]`;
|