@ngockhoale/ukit 3.0.7 → 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.
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
@@ -8,25 +8,25 @@ import {
8
8
  runProjectHygiene,
9
9
  } from '../../core/memory/store.js';
10
10
  import {
11
- addRecord,
12
11
  getRecord,
13
12
  loadRecords,
14
13
  queryRecords,
15
14
  stats as v2Stats,
16
- updateRecord,
17
15
  } from '../../core/memory/storeV2.js';
18
16
  import {
19
- addUserRecord,
20
17
  getUserRecord,
21
18
  queryUserRecords,
22
- removeUserRecord,
23
- updateUserRecord,
24
19
  userMemoryStats,
25
20
  } from '../../core/memory/userMemory.js';
26
21
  import { runMigration } from '../../core/memory/migrate.js';
27
- import { getContextInjection, search } from '../../core/memory/retrieval.js';
22
+ import { createBackup, restoreBackup, diagnoseStore } from '../../core/memory/storeBackup.js';
23
+ import { mutateMemory } from '../../core/memory/mutateMemory.js';
24
+ import { redactWritePayload } from '../../core/memory/writeGuard.js';
25
+ import { deletePromptCacheEntries } from '../../core/token/index.js';
26
+ import { expand, getContextInjection, search } from '../../core/memory/retrieval.js';
28
27
  import { inspectRuntimeConfig } from '../../core/runtimeConfig.js';
29
28
  import { buildRuntimePaths } from '../../core/runtimePaths.js';
29
+ import { buildUserPaths } from '../../core/userPaths.js';
30
30
  import { pathExists } from '../../core/fileOps.js';
31
31
  import { detectProjectContext } from '../../context/detectProjectContext.js';
32
32
  import { listLedgerFiles, LEDGER_DIR_REL } from '../../diagnostics/ledgerFiles.js';
@@ -94,6 +94,37 @@ async function listAllPendingPatternCandidates(projectRoot, runtimePaths) {
94
94
  }
95
95
  return results;
96
96
  }
97
+ // FR-025: guard rejections surface labels only — never the matched value or
98
+ // record body. Returns true when the result was a handled rejection.
99
+ function printGuardRejection(res) {
100
+ const labels = Array.isArray(res.labels) && res.labels.length > 0
101
+ ? res.labels.join(', ')
102
+ : 'unknown';
103
+ console.log(`[UKit] rejected: ${res.reason ?? 'secret-detected'} (${labels})`);
104
+ }
105
+
106
+ // Shared mutateMemory error mapping for CLI write ops: conflict/not-found/
107
+ // corrupt become thrown errors; rejected prints labels and returns quietly.
108
+ function handleMutationResult(res, { notFoundMessage }) {
109
+ if (res.status === 'ok') return res;
110
+ if (res.status === 'not-found') throw new Error(notFoundMessage);
111
+ if (res.status === 'conflict') {
112
+ throw new Error(`Write conflict (${res.reason ?? 'revision-mismatch'}) — a concurrent writer won; re-run the command.`);
113
+ }
114
+ // M06 rollout flag (SPEC §9): writer stage 'off'/killSwitch surfaces as a
115
+ // typed disabled message, not a failure — the write lane is intentionally
116
+ // gated, exit-code class unchanged.
117
+ if (res.status === 'disabled' && res.reason === 'rollout-stage') {
118
+ console.log('[UKit] memory writer disabled by rollout stage');
119
+ return res;
120
+ }
121
+ if (res.status === 'rejected') {
122
+ printGuardRejection(res);
123
+ return res;
124
+ }
125
+ throw new Error(`Memory write failed: ${res.status}${res.reason ? ` — ${res.reason}` : ''}`);
126
+ }
127
+
97
128
 
98
129
  // ---- `ukit memory v2` ops (SPEC §11) ----
99
130
 
@@ -151,13 +182,29 @@ async function runMemoryV2(projectRoot, args) {
151
182
  throw new Error(`Cannot promote ${record.type} records (only derived_fact or episode).`);
152
183
  }
153
184
  // Explicit approval only — no auto-promotion (SPEC §11,
154
- // memoryV2.promotion.episodeToRuleRequiresApproval).
155
- const updated = await updateRecord(projectRoot, id, {
156
- type: 'project_rule',
157
- scope: 'repo',
158
- provenance: `${record.provenance ?? 'manual'};promoted-from:${record.id}`,
185
+ // memoryV2.promotion.episodeToRuleRequiresApproval). The write goes
186
+ // through mutateMemory with expectedRevision so a concurrent writer
187
+ // surfaces as a conflict instead of a silent clobber (FR-023).
188
+ const res = await mutateMemory(
189
+ { kind: 'project', projectRoot },
190
+ {
191
+ op: 'update',
192
+ payload: {
193
+ id,
194
+ patch: {
195
+ type: 'project_rule',
196
+ scope: 'repo',
197
+ provenance: `${record.provenance ?? 'manual'};promoted-from:${record.id}`,
198
+ },
199
+ },
200
+ expectedRevision: record.revision,
201
+ },
202
+ );
203
+ const handled = handleMutationResult(res, {
204
+ notFoundMessage: `Memory v2 record not found: ${id}`,
159
205
  });
160
- console.log(`[UKit] Promoted ${updated.id} → project_rule.`);
206
+ if (handled.status !== 'ok') return;
207
+ console.log(`[UKit] Promoted ${handled.record.id} → project_rule.`);
161
208
  return;
162
209
  }
163
210
 
@@ -166,10 +213,22 @@ async function runMemoryV2(projectRoot, args) {
166
213
  if (!id) {
167
214
  throw new Error('Missing record id. Usage: ukit memory v2 stale <id>');
168
215
  }
169
- const updated = await updateRecord(projectRoot, id, { status: 'stale' });
170
- if (!updated) {
216
+ const record = await getRecord(projectRoot, id);
217
+ if (!record) {
171
218
  throw new Error(`Memory v2 record not found: ${id}`);
172
219
  }
220
+ const res = await mutateMemory(
221
+ { kind: 'project', projectRoot },
222
+ {
223
+ op: 'update',
224
+ payload: { id, patch: { status: 'stale' } },
225
+ expectedRevision: record.revision,
226
+ },
227
+ );
228
+ const handled = handleMutationResult(res, {
229
+ notFoundMessage: `Memory v2 record not found: ${id}`,
230
+ });
231
+ if (handled.status !== 'ok') return;
173
232
  console.log(`[UKit] Marked ${id} as stale.`);
174
233
  return;
175
234
  }
@@ -178,9 +237,19 @@ async function runMemoryV2(projectRoot, args) {
178
237
  const dryRun = rest.includes('--dry-run');
179
238
  const result = await runMigration(projectRoot, { dryRun });
180
239
  if (dryRun) {
181
- console.log(`[UKit] Dry-run: would migrate ${result.migrated} record(s), skip ${result.skipped}.`);
240
+ // Per-record plan: counts per action + ids only — never record bodies.
241
+ const counts = {};
242
+ for (const entry of result.plan) {
243
+ counts[entry.action] = (counts[entry.action] ?? 0) + 1;
244
+ }
245
+ const countText = Object.entries(counts).map(([a, n]) => `${a}=${n}`).join(' ') || 'none';
246
+ console.log(`[UKit] migrate dry-run: ${countText} (migrated=${result.migrated} skipped=${result.skipped})`);
247
+ for (const entry of result.plan) {
248
+ console.log(`[UKit] ${entry.action} ${entry.id} (${entry.source}) — ${entry.reason}`);
249
+ }
182
250
  return;
183
251
  }
252
+
184
253
  if (result.migrated === 0) {
185
254
  console.log('[UKit] Nothing to migrate (marker present or no legacy memory).');
186
255
  return;
@@ -191,17 +260,16 @@ async function runMemoryV2(projectRoot, args) {
191
260
  console.log(`[UKit] v2 store: ${stats.total} record(s) — ${JSON.stringify(stats.byType)}`);
192
261
  return;
193
262
  }
194
-
195
263
  throw new Error(`Unknown memory v2 op: ${op}. Expected list|show|promote|stale|migrate.`);
196
264
  }
197
265
 
198
266
  // ---- `ukit memory --user` — user-level store at ~/.ukit (FR-011) ----
199
267
  // Same v2 record document, no v1→v2 migration (the user layer starts at v2).
200
- // Ops: list|add|get|update|forget|stats; an optional `v2` token is accepted so
268
+ // Ops: list|add|get|update|forget|purge|stats; an optional `v2` token is accepted so
201
269
  // `memory --user v2 add` and `memory --user add` are equivalent. Any other
202
270
  // subcommand under --user is a misuse error — v1 lanes stay project-level.
203
271
 
204
- const USER_MEMORY_OPS = new Set(['list', 'add', 'get', 'update', 'forget', 'stats']);
272
+ const USER_MEMORY_OPS = new Set(['list', 'add', 'get', 'update', 'forget', 'purge', 'stats']);
205
273
 
206
274
  async function runUserMemory(homeDir, args) {
207
275
  const rest = (args[0] ?? '').toLowerCase() === 'v2' ? args.slice(1) : args;
@@ -210,7 +278,7 @@ async function runUserMemory(homeDir, args) {
210
278
 
211
279
  if (!USER_MEMORY_OPS.has(op)) {
212
280
  throw new Error(
213
- `--user only supports v2 ops (list|add|get|update|forget|stats); got "${op}".`,
281
+ `--user only supports v2 ops (list|add|get|update|forget|purge|stats); got "${op}".`,
214
282
  );
215
283
  }
216
284
 
@@ -242,15 +310,32 @@ async function runUserMemory(homeDir, args) {
242
310
  if (!text) {
243
311
  throw new Error('Missing record text. Usage: ukit memory --user add [--type <type>] [--scope <scope>] <text>');
244
312
  }
245
- const record = await addUserRecord({
246
- type: typeFlag.value ?? 'derived_fact',
247
- scope: scopeFlag.value ?? 'user',
248
- text,
249
- provenance: provenanceFlag.value ?? 'cli',
250
- createdBy: 'cli',
251
- }, { homeDir });
252
- console.log(`[UKit] Added user record ${record.id}.`);
253
- return;
313
+ const res = await mutateMemory(
314
+ { kind: 'user', homeDir },
315
+ {
316
+ op: 'add',
317
+ payload: {
318
+ type: typeFlag.value ?? 'derived_fact',
319
+ scope: scopeFlag.value ?? 'user',
320
+ text,
321
+ provenance: provenanceFlag.value ?? 'cli',
322
+ createdBy: 'cli',
323
+ },
324
+ },
325
+ );
326
+ if (res.status === 'ok') {
327
+ console.log(`[UKit] Added user record ${res.record.id}.`);
328
+ return;
329
+ }
330
+ if (res.status === 'duplicate') {
331
+ console.log(`[UKit] User record already exists${res.record ? ` (${res.record.id})` : ''}.`);
332
+ return;
333
+ }
334
+ if (res.status === 'rejected') {
335
+ printGuardRejection(res);
336
+ return;
337
+ }
338
+ throw new Error(`User memory add failed: ${res.status}${res.reason ? ` — ${res.reason}` : ''}`);
254
339
  }
255
340
 
256
341
  if (op === 'get') {
@@ -283,11 +368,15 @@ async function runUserMemory(homeDir, args) {
283
368
  if (Object.keys(patch).length === 0) {
284
369
  throw new Error('Nothing to update. Pass --status/--type/--scope or new text.');
285
370
  }
286
- const updated = await updateUserRecord(id, patch, { homeDir });
287
- if (!updated) {
288
- throw new Error(`User memory record not found: ${id}`);
289
- }
290
- console.log(`[UKit] Updated user record ${updated.id}.`);
371
+ const res = await mutateMemory(
372
+ { kind: 'user', homeDir },
373
+ { op: 'update', payload: { id, patch } },
374
+ );
375
+ const handled = handleMutationResult(res, {
376
+ notFoundMessage: `User memory record not found: ${id}`,
377
+ });
378
+ if (handled.status !== 'ok') return;
379
+ console.log(`[UKit] Updated user record ${handled.record.id}.`);
291
380
  return;
292
381
  }
293
382
 
@@ -296,14 +385,40 @@ async function runUserMemory(homeDir, args) {
296
385
  if (!id) {
297
386
  throw new Error('Missing record id. Usage: ukit memory --user forget <id>');
298
387
  }
299
- const removed = await removeUserRecord(id, { homeDir });
300
- if (!removed) {
301
- throw new Error(`User memory record not found: ${id}`);
302
- }
388
+ // forget = archive semantics (SPEC G5) — the record stays in the store
389
+ // with status:'archived' so tombstone/review invariants hold.
390
+ const res = await mutateMemory(
391
+ { kind: 'user', homeDir },
392
+ { op: 'forget', payload: { id } },
393
+ );
394
+ const handled = handleMutationResult(res, {
395
+ notFoundMessage: `User memory record not found: ${id}`,
396
+ });
397
+ if (handled.status !== 'ok') return;
303
398
  console.log(`[UKit] Forgot user record ${id}.`);
304
399
  return;
305
400
  }
306
401
 
402
+ if (op === 'purge') {
403
+ const confirm = opArgs.includes('--confirm');
404
+ const id = opArgs.filter((a) => a !== '--confirm').join(' ').trim();
405
+ if (!id) {
406
+ throw new Error('Missing record id. Usage: ukit memory --user purge <id> --confirm');
407
+ }
408
+ if (!confirm) {
409
+ throw new Error(`Refusing to purge user record ${id} — re-run with --confirm.`);
410
+ }
411
+ const res = await mutateMemory(
412
+ { kind: 'user', homeDir },
413
+ { op: 'purge', payload: { id }, confirmScope: 'user' },
414
+ );
415
+ const handled = handleMutationResult(res, {
416
+ notFoundMessage: `User memory record not found: ${id}`,
417
+ });
418
+ if (handled.status !== 'ok') return;
419
+ console.log(`[UKit] Purged ${id} (tombstone recorded).`);
420
+ return;
421
+ }
307
422
  // op === 'stats'
308
423
  const stats = await userMemoryStats({ homeDir });
309
424
  console.log(`[UKit] User memory: total=${stats.total} byType=${JSON.stringify(stats.byType)} byStatus=${JSON.stringify(stats.byStatus)} invalidSkipped=${stats.invalidSkipped}`);
@@ -482,28 +597,116 @@ async function runMemoryEpisode(projectRoot, args) {
482
597
 
483
598
  const ttlDays = Number(config?.memoryV2?.episodeTtlDays) || 90;
484
599
  const projectId = (await detectProjectContext(projectRoot)).project.name;
485
- const record = await addRecord(projectRoot, {
486
- type: 'episode',
487
- scope: 'session',
488
- text,
489
- provenance: 'exec-ledger',
490
- confidence: 0.6,
491
- createdBy: 'episode-hook',
492
- projectId,
493
- validUntil: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
494
- meta: { sessionId, ledgerKey },
495
- });
496
- console.log(`[UKit] episode: recorded ${record.id} for session ${sessionId}`);
600
+ // FR-023: the add goes through mutateMemory with the ledger key as the
601
+ // idempotency key — a hook-invoked rerun is a journal 'duplicate' even if
602
+ // the meta.ledgerKey fast-path above is bypassed.
603
+ const res = await mutateMemory(
604
+ { kind: 'project', projectRoot },
605
+ {
606
+ op: 'add',
607
+ idempotencyKey: ledgerKey,
608
+ payload: {
609
+ type: 'episode',
610
+ scope: 'session',
611
+ text,
612
+ provenance: 'exec-ledger',
613
+ confidence: 0.6,
614
+ createdBy: 'episode-hook',
615
+ projectId,
616
+ validUntil: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
617
+ meta: { sessionId, ledgerKey },
618
+ },
619
+ },
620
+ );
621
+ if (res.status === 'duplicate') {
622
+ console.log('[UKit] episode: already recorded');
623
+ return;
624
+ }
625
+ if (res.status === 'rejected') {
626
+ printGuardRejection(res);
627
+ return;
628
+ }
629
+ if (res.status !== 'ok') {
630
+ throw new Error(`episode write failed: ${res.status}${res.reason ? ` — ${res.reason}` : ''}`);
631
+ }
632
+ console.log(`[UKit] episode: recorded ${res.record.id} for session ${sessionId}`);
633
+ }
634
+
635
+ // ---- `ukit memory backup|restore|doctor` — operator lane (SPEC §9, FR-011) ----
636
+ // Output is counts/ids/paths only — never record bodies. `doctor` is strictly
637
+ // read-only; `--dry-run` flags write nothing. `--user` targets the user store
638
+ // at ~/.ukit instead of the project store (dispatch happens before the generic
639
+ // --user lane so these subcommands keep their own flag semantics).
640
+
641
+ async function runMemoryStoreOps(subcommand, { projectRoot, homeDir, isUser, rest }) {
642
+ if (subcommand === 'backup') {
643
+ const outFlag = extractFlag(rest, '--out');
644
+ const { backupDir, manifest } = await createBackup({
645
+ projectRoot: isUser ? null : projectRoot,
646
+ homeDir: isUser ? homeDir : undefined,
647
+ outDir: outFlag.value ?? undefined,
648
+ includeUser: isUser,
649
+ });
650
+ console.log(`[UKit] Backup written: ${backupDir}`);
651
+ for (const store of manifest.stores) {
652
+ console.log(
653
+ `[UKit] ${store.kind}: state=${store.state} records=${store.recordCount} tombstones=${store.tombstoneCount} file=${store.file ?? 'none'}`,
654
+ );
655
+ }
656
+ return;
657
+ }
658
+
659
+ if (subcommand === 'restore') {
660
+ const dryRun = rest.includes('--dry-run');
661
+ const positional = rest.filter((a) => a !== '--dry-run');
662
+ const backupDir = positional.join(' ').trim();
663
+ if (!backupDir) {
664
+ throw new Error('Missing backup dir. Usage: ukit memory restore <backupDir> [--dry-run] [--user]');
665
+ }
666
+ const result = await restoreBackup(backupDir, {
667
+ projectRoot: isUser ? null : projectRoot,
668
+ homeDir,
669
+ dryRun,
670
+ });
671
+ for (const entry of result.plan) {
672
+ const detail = entry.reason ?? (entry.records != null ? `records=${entry.records}` : `added=${entry.added ?? 0}`);
673
+ console.log(`[UKit] ${entry.kind}: ${entry.action} ${detail}`);
674
+ }
675
+ console.log(
676
+ dryRun
677
+ ? `[UKit] restore dry-run: ${result.plan.length} plan entr(ies), restored=0 skipped=${result.skipped} — nothing written.`
678
+ : `[UKit] Restored ${result.restored} record(s), skipped ${result.skipped}.`,
679
+ );
680
+ return;
681
+ }
682
+
683
+ if (subcommand === 'doctor') {
684
+ const { stores } = await diagnoseStore({
685
+ projectRoot: isUser ? null : projectRoot,
686
+ homeDir: isUser ? homeDir : undefined,
687
+ });
688
+ for (const store of stores) {
689
+ console.log(
690
+ `[UKit] ${store.kind}: state=${store.state} generation=${store.generation} records=${store.recordCount} tombstones=${store.tombstoneCount} quarantine=${store.quarantine.length}`,
691
+ );
692
+ if (store.state !== 'ok') {
693
+ const paths = isUser ? buildUserPaths({ homeDir }) : buildRuntimePaths(projectRoot);
694
+ const quarantineDir = path.join(path.dirname(paths.memoryV2RecordsPath), 'quarantine');
695
+ console.log(`[UKit] quarantine-dir: ${quarantineDir}`);
696
+ for (const q of store.quarantine) {
697
+ console.log(`[UKit] quarantined: ${q}`);
698
+ }
699
+ if (store.hint) console.log(`[UKit] hint: ${store.hint}`);
700
+ }
701
+ }
702
+ return;
703
+ }
497
704
  }
498
705
 
499
706
  export async function runMemory({ projectRoot, homeDir = os.homedir(), argv = [] }) {
500
707
  const userFlagIndex = argv.indexOf('--user');
501
708
  const isUser = userFlagIndex >= 0;
502
709
  const runtimePaths = buildRuntimePaths(projectRoot);
503
- // --user operates on ~/.ukit and does not require the project runtime.
504
- if (!isUser && !(await pathExists(runtimePaths.runtimeRoot))) {
505
- throw new Error('Shared UKit runtime not found. Run `ukit install` first.');
506
- }
507
710
 
508
711
  const argvRest = isUser
509
712
  ? [...argv.slice(0, userFlagIndex), ...argv.slice(userFlagIndex + 1)]
@@ -516,6 +719,23 @@ export async function runMemory({ projectRoot, homeDir = os.homedir(), argv = []
516
719
  return;
517
720
  }
518
721
 
722
+ // Operator lane runs without the runtime gate: doctor must report
723
+ // state=missing on a project with no store yet, and backup/restore are
724
+ // exactly the tools used before/without a full install.
725
+ if (subcommand === 'backup' || subcommand === 'restore' || subcommand === 'doctor') {
726
+ await runMemoryStoreOps(subcommand, { projectRoot, homeDir, isUser, rest });
727
+ return;
728
+ }
729
+
730
+ // --user operates on ~/.ukit and does not require the project runtime.
731
+ if (!isUser && !(await pathExists(runtimePaths.runtimeRoot))) {
732
+ throw new Error('Shared UKit runtime not found. Run `ukit install` first.');
733
+ }
734
+
735
+
736
+
737
+
738
+
519
739
  if (isUser) {
520
740
  await runUserMemory(homeDir, argvRest);
521
741
  return;
@@ -659,15 +879,57 @@ export async function runMemory({ projectRoot, homeDir = os.homedir(), argv = []
659
879
  throw new Error('Missing query. Usage: ukit memory search <query>');
660
880
  }
661
881
 
662
- const results = await search(query, 'all', 10, { projectRoot });
882
+ const projectContext = await detectProjectContext(projectRoot, { homeDir });
883
+ const results = await search(query, 'all', 10, {
884
+ projectRoot,
885
+ // Stable registry id (M01-05) — never the display name. Null/ambiguous
886
+ // is still passed through: policy denies repo-bound records rather than
887
+ // silently widening to cross-repo results.
888
+ projectId: projectContext.project.id ?? null,
889
+ projectAliases: projectContext.project.aliases ?? [],
890
+ });
663
891
  if (results.length === 0) {
664
892
  console.log('[UKit] No memory matches found.');
665
893
  return;
666
894
  }
667
895
 
668
896
  for (const result of results) {
669
- console.log(`${result.id} — ${result.summary}`);
897
+ // FR-007: `<id> — <snippet> [<freshness≠fresh>] (<kind>:<locator>)` —
898
+ // tag only when the resolved state is not fresh; citation only when the
899
+ // hit carries one. Ids/locators/labels only — never meta or bodies.
900
+ const snippet = result.snippet ?? result.summary;
901
+ const tag = result.freshness && result.freshness.state !== 'fresh'
902
+ ? ` [${result.freshness.state}]`
903
+ : '';
904
+ const cite = result.citation
905
+ ? ` (${result.citation.kind}:${result.citation.locator})`
906
+ : '';
907
+ console.log(`${result.id} — ${snippet}${tag}${cite}`);
908
+ }
909
+ return;
910
+ }
911
+
912
+ if (subcommand === 'expand') {
913
+ const id = rest.join(' ').trim();
914
+ if (!id) {
915
+ throw new Error('Missing memory id. Usage: ukit memory expand <id>');
916
+ }
917
+
918
+ const projectContext = await detectProjectContext(projectRoot, { homeDir });
919
+ const result = await expand(id, {
920
+ projectRoot,
921
+ projectId: projectContext.project.id ?? null,
922
+ projectAliases: projectContext.project.aliases ?? [],
923
+ });
924
+ // FR-007: the hit block carries citation/freshness/reasons — provenance
925
+ // surface only, never record meta. Legacy lane has no hit; its
926
+ // fullContent is the pre-existing expansion surface.
927
+ if (result.hit) {
928
+ console.log(JSON.stringify(result.hit, null, 2));
929
+ } else {
930
+ console.log(result.fullContent);
670
931
  }
932
+ console.log(`relatedItems: ${(result.relatedItems ?? []).join(', ') || 'none'}`);
671
933
  return;
672
934
  }
673
935
 
@@ -687,8 +949,9 @@ export async function runMemory({ projectRoot, homeDir = os.homedir(), argv = []
687
949
  throw new Error('Memory recall is disabled in .ukit/storage/config.json.');
688
950
  }
689
951
 
690
- const projectContext = await detectProjectContext(projectRoot);
691
- const injection = await getContextInjection(query, projectContext.project.name, {
952
+ const projectContext = await detectProjectContext(projectRoot, { homeDir });
953
+ const injection = await getContextInjection(query, projectContext.project.id ?? null, {
954
+ projectAliases: projectContext.project.aliases ?? [],
692
955
  projectRoot,
693
956
  maxTokens: runtimeConfig.memory.maxInjectionTokens,
694
957
  promptCache: Boolean(runtimeConfig.tokenPipeline?.promptCache),
@@ -729,25 +992,53 @@ export async function runMemory({ projectRoot, homeDir = os.homedir(), argv = []
729
992
  return;
730
993
  }
731
994
 
995
+ if (subcommand === 'purge') {
996
+ const memoryId = rest.join(' ').trim();
997
+ if (!memoryId) {
998
+ throw new Error('Missing memory id. Usage: ukit memory purge <id>');
999
+ }
1000
+ // FR-023/§9: purge removes the record, writes a tombstone, then sweeps
1001
+ // prompt-cache entries that embedded the purged record's text.
1002
+ const res = await mutateMemory(
1003
+ { kind: 'project', projectRoot },
1004
+ { op: 'purge', payload: { id: memoryId } },
1005
+ );
1006
+ const handled = handleMutationResult(res, {
1007
+ notFoundMessage: `Memory record not found: ${memoryId}`,
1008
+ });
1009
+ if (handled.status !== 'ok') return;
1010
+ await deletePromptCacheEntries(projectRoot, { selectedIds: [memoryId] });
1011
+ console.log(`[UKit] Purged ${memoryId} (tombstone recorded).`);
1012
+ return;
1013
+ }
1014
+
732
1015
  if (subcommand === 'export') {
733
1016
  const data = await exportMemory(projectRoot);
734
- console.log(JSON.stringify(data, null, 2));
1017
+ // FR-023/025: defense-in-depth redaction at the CLI boundary too —
1018
+ // store.js exportMemory already redacts, this catches any future lane.
1019
+ const { config } = await inspectRuntimeConfig(projectRoot);
1020
+ const { payload: safe } = redactWritePayload({ meta: data }, { config });
1021
+ console.log(JSON.stringify(safe.meta, null, 2));
735
1022
  return;
736
1023
  }
737
1024
 
1025
+
1026
+
738
1027
  throw new Error(`Unknown memory subcommand: ${subcommand}. Run "ukit memory help".`);
739
1028
  }
740
1029
 
741
1030
  export function printMemoryHelp() {
742
1031
  console.log('UKit Memory Commands');
743
- console.log('Usage: ukit memory <list|search|recall|forget|export|propose|approve|reject|learn|promote|episode|hygiene> [args]');
1032
+ console.log('Usage: ukit memory <list|search|expand|recall|forget|purge|export|propose|approve|reject|learn|promote|episode|hygiene|backup|restore|doctor> [args]');
744
1033
  console.log('');
745
1034
  console.log('Subcommands:');
746
1035
  console.log(' list List memory items in shared .ukit/storage/memory');
747
1036
  console.log(' list --pending List pending pattern candidates awaiting approval');
748
- console.log(' search <query> Search memory summaries and content');
1037
+ console.log(' search <query> Search memory summaries and content (prints freshness tag + citation)');
1038
+ console.log(' expand <id> Print the MemoryHit block (citation/freshness/reasons) + related ids');
749
1039
  console.log(' recall <task> Print a compact previous-context block for the current task');
750
- console.log(' forget <id> Remove one memory item by id');
1040
+ console.log(' forget <id> Archive one memory item by id');
1041
+ console.log(' purge <id> Remove a v2 record + tombstone it, and sweep prompt-cache entries');
751
1042
  console.log(' export Print all memory as JSON');
752
1043
  console.log(' propose "<text>" [--category <name>] [--project <id>] Propose a project convention for human approval');
753
1044
  console.log(' approve <id> [--project <id>] Approve a pending pattern candidate into project conventions');
@@ -756,6 +1047,9 @@ export function printMemoryHelp() {
756
1047
  console.log(' promote [--dry-run] Render approved rules/procedures into the ## ukit-learned block in docs/MEMORY.md');
757
1048
  console.log(' episode [--dry-run] [--session <id>] Write a session episode record from the exec-ledger');
758
1049
  console.log(' hygiene [--project <id>] Run decision-conflict resolution + session archiving now');
759
- console.log(' v2 <list|show|promote|stale|migrate> Operate on the project v2 record store');
760
- console.log(' --user [v2] <list|add|get|update|forget|stats> Operate on the user-level store at ~/.ukit/storage/memory/v2/records.json');
1050
+ console.log(' backup [--out <dir>] [--user] Snapshot v2 store(s) + sha256 manifest into a backup dir');
1051
+ console.log(' restore <dir> [--dry-run] [--user] Restore store(s) from a backup dir (plan only with --dry-run)');
1052
+ console.log(' doctor [--user] Diagnose v2 store state + counts + quarantine (read-only)');
1053
+ console.log(' v2 <list|show|promote|stale|migrate> Operate on the project v2 record store (migrate supports --dry-run plan output)');
1054
+ console.log(' --user [v2] <list|add|get|update|forget|purge|stats> Operate on the user-level store at ~/.ukit/storage/memory/v2/records.json (purge requires --confirm)');
761
1055
  }
@@ -1,6 +1,7 @@
1
1
  import path from 'node:path';
2
2
  import { readJsonIfExists } from '../core/fileOps.js';
3
3
  import { detectPackageManager } from '../core/packageManager.js';
4
+ import { resolveProjectIdentity } from '../core/memory/projectIdentity.js';
4
5
 
5
6
  function inferProjectName(projectRoot, packageJson) {
6
7
  if (typeof packageJson?.name === 'string' && packageJson.name.trim() !== '') {
@@ -10,16 +11,25 @@ function inferProjectName(projectRoot, packageJson) {
10
11
  return path.basename(projectRoot);
11
12
  }
12
13
 
13
- export async function detectProjectContext(projectRoot) {
14
+ export async function detectProjectContext(projectRoot, { homeDir } = {}) {
14
15
  const packageJsonPath = path.join(projectRoot, 'package.json');
15
16
  // A corrupt package.json must degrade to basename naming, not crash the
16
17
  // status/memory CLIs with a raw SyntaxError.
17
18
  const packageJson = await readJsonIfExists(packageJsonPath).catch(() => null);
18
19
 
20
+ // Identity resolution must never break context detection: any resolver
21
+ // failure degrades to `id: null` (SPEC §10 — deny-by-default downstream).
22
+ const identity = await resolveProjectIdentity(projectRoot, { homeDir })
23
+ .catch(() => null);
24
+
19
25
  return {
20
26
  project: {
21
27
  root: projectRoot,
22
28
  name: inferProjectName(projectRoot, packageJson),
29
+ id: identity?.projectId ?? null,
30
+ // Registry-attested display names — lets the read path match migrated
31
+ // v1 records whose project_id is still the legacy name (M01-05).
32
+ aliases: Array.isArray(identity?.aliases) ? identity.aliases : [],
23
33
  },
24
34
  runtime: {
25
35
  packageManager: await detectPackageManager(projectRoot, packageJson),