@ngockhoale/ukit 3.0.8 → 3.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/manifests/documentation.yaml +11 -0
  3. package/package.json +1 -1
  4. package/scripts/audit/decision-coverage.mjs +29 -2
  5. package/scripts/bench/data-foundation.mjs +52 -3
  6. package/scripts/bench/decision-runtime-baseline.mjs +427 -0
  7. package/scripts/bench/decision-runtime-metrics.mjs +67 -0
  8. package/scripts/bench/decision-runtime-variant.mjs +626 -0
  9. package/scripts/bench/memory-ablation.mjs +495 -0
  10. package/scripts/bench/memory-baseline.mjs +596 -0
  11. package/scripts/bench/memory-bench.mjs +661 -0
  12. package/scripts/bench/memory-canary.mjs +321 -0
  13. package/scripts/bench/memory-corpus.mjs +354 -0
  14. package/scripts/bench/memory-gate.mjs +389 -0
  15. package/scripts/bench/memory-metrics.mjs +179 -0
  16. package/scripts/bench/parallel-agents.mjs +33 -11
  17. package/scripts/bench/recorder-overhead.mjs +204 -0
  18. package/scripts/bench/sqlite-spike.mjs +451 -0
  19. package/scripts/measure-decision-gateway.mjs +306 -0
  20. package/scripts/perf/audit-perf.mjs +35 -17
  21. package/src/bug/triageBug.js +4 -3
  22. package/src/cli/commands/memory.js +357 -63
  23. package/src/context/detectProjectContext.js +11 -1
  24. package/src/core/agentRuntime/adapters.js +254 -0
  25. package/src/core/agentRuntime/artifacts.js +192 -0
  26. package/src/core/agentRuntime/completionGate.js +176 -0
  27. package/src/core/agentRuntime/context.js +149 -0
  28. package/src/core/agentRuntime/contract.js +247 -0
  29. package/src/core/agentRuntime/diagnostics.js +244 -0
  30. package/src/core/agentRuntime/evaluation.js +163 -0
  31. package/src/core/agentRuntime/eventStore.js +404 -0
  32. package/src/core/agentRuntime/liveness.js +60 -0
  33. package/src/core/agentRuntime/planCompiler.js +322 -0
  34. package/src/core/agentRuntime/promotion.js +53 -0
  35. package/src/core/agentRuntime/qualityComparison.js +112 -0
  36. package/src/core/agentRuntime/recovery.js +266 -0
  37. package/src/core/agentRuntime/resourcePolicy.js +78 -0
  38. package/src/core/agentRuntime/runtimeSupport.js +237 -0
  39. package/src/core/agentRuntime/supervisor.js +565 -0
  40. package/src/core/agentRuntime/vmEngine.js +621 -0
  41. package/src/core/codeintel/analogy.js +3 -2
  42. package/src/core/experiments/dynamicWorkflow.js +17 -2
  43. package/src/core/fileOps.js +21 -3
  44. package/src/core/memory/deltaOverlays.js +75 -30
  45. package/src/core/memory/learningCandidates.js +93 -48
  46. package/src/core/memory/memoryFlags.js +83 -0
  47. package/src/core/memory/memoryFreshness.js +190 -0
  48. package/src/core/memory/memoryHit.js +144 -0
  49. package/src/core/memory/migrate.js +69 -189
  50. package/src/core/memory/migrateMapping.js +232 -0
  51. package/src/core/memory/mutateMemory.js +323 -0
  52. package/src/core/memory/policy.js +96 -0
  53. package/src/core/memory/projectIdentity.js +266 -0
  54. package/src/core/memory/recordIndex.js +178 -0
  55. package/src/core/memory/recordStore.js +133 -20
  56. package/src/core/memory/records.js +144 -6
  57. package/src/core/memory/retrieval.js +259 -125
  58. package/src/core/memory/store.js +16 -5
  59. package/src/core/memory/storeBackup.js +226 -0
  60. package/src/core/memory/storeV2.js +63 -26
  61. package/src/core/memory/storeV2Loader.js +30 -12
  62. package/src/core/memory/userMemory.js +38 -20
  63. package/src/core/memory/writeClassification.js +161 -0
  64. package/src/core/memory/writeGuard.js +129 -0
  65. package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
  66. package/src/core/observability/analytics/cohorts.js +148 -0
  67. package/src/core/observability/analytics/storeDigest.js +163 -0
  68. package/src/core/observability/evaluation/experimentPlan.js +95 -0
  69. package/src/core/observability/evaluation/findings.js +99 -0
  70. package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
  71. package/src/core/observability/evaluation/perturbation.js +273 -0
  72. package/src/core/observability/evaluation/replay.js +7 -1
  73. package/src/core/observability/evaluation/scorecard.js +23 -3
  74. package/src/core/observability/rollout.js +11 -7
  75. package/src/core/observability/schema/compatibility.js +135 -0
  76. package/src/core/observability/schema/registry.js +99 -0
  77. package/src/core/observability/schema/validate.js +7 -0
  78. package/src/core/observability/support/import.js +53 -9
  79. package/src/core/observability/support/paths.js +13 -3
  80. package/src/core/observability/support/projector.js +148 -12
  81. package/src/core/output/index.js +12 -2
  82. package/src/core/runtimeConfig.js +83 -0
  83. package/src/core/runtimePaths.js +3 -0
  84. package/src/core/sensitiveValueScanner.js +40 -0
  85. package/src/core/token/index.js +40 -3
  86. package/src/decision/client.js +37 -13
  87. package/src/decision/protocol.js +1 -1
  88. package/src/decision/registry.js +5 -3
  89. package/src/decision/runtimeDecide.js +242 -0
  90. package/src/decision/runtimeFilter.js +150 -0
  91. package/src/decision/runtimeScheduler.js +239 -0
  92. package/src/index/buildIndex.js +13 -12
  93. package/src/index/queryIndex.js +35 -14
  94. package/src/index/relatedTests.js +50 -8
  95. package/src/index/resolveContext.js +9 -4
  96. package/src/manifest/selectItems.js +7 -3
  97. package/src/render/instructionRenderer.js +17 -5
  98. package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
  99. package/template_project/.claude/ukit/index/route-task.mjs +121 -19
  100. package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
  101. package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
  102. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
  103. package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
  104. package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
  105. 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
- * - `config.observability.stage` — 'off' is the kill switch
15
- * (skipped/stage_off, nothing written).
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
- if (resolveStage(options.config) === 'off') {
234
- return { ...none, reason: 'stage_off' };
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(isPlainObject(snapshot) ? snapshot : {}, coverage);
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 snapshot.project_ref === 'string' && snapshot.project_ref.length > 0
318
- ? snapshot.project_ref
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 ---
@@ -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
- await fs.mkdir(teeCacheDir, { recursive: true });
461
- await fs.writeFile(absolutePath, rawOutputText, 'utf8');
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)) {
@@ -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).
@@ -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
- const nextDocument = normalizePromptCacheDocument({ entries }, { maxEntries });
274
- await writePromptCacheDocument(projectRoot, nextDocument);
275
- return nextDocument.entries[0] ?? touchedEntry;
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;
@@ -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
- const serialized = serializeStatePacket(batch.statePacket ?? {});
217
- const questionText = questions
218
- .map((q) => `${q.decisionKey} ${q.instruction} ${(q.candidates ?? []).join(' ')}`)
219
- .join(' ');
220
- const languageClass = classifyLanguage(`${serialized} ${questionText}`);
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', { fallbackCode: 'state-too-large', checkpoint });
236
+ return outcome('invalid', {
237
+ fallbackCode: 'state-too-large',
238
+ checkpoint: checkpoint ?? null,
239
+ });
229
240
  }
230
- throw err;
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
- const body = JSON.stringify(
254
- encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
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
@@ -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') {
@@ -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
- const DECISION_KEY_PATTERN = /^[a-z][a-z0-9-]*(\.[a-z0-9-]+)*\.v[0-9]+$/;
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}]`;