@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +14 -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
@@ -0,0 +1,144 @@
1
+ // MemoryHit provenance API (M05-01/M05-03, SPEC §5 FR-001/FR-002, §8).
2
+ // Pure module: no I/O, deterministic. buildMemoryHit carries provenance
3
+ // (citation, as-of, freshness slot, reason codes) plus backward-compat
4
+ // fields; rankHits applies eligibility-first dual-score ranking.
5
+ // The record's stored certainty hint is NEVER read for ranking —
6
+ // eligibility and the composite score are the only gates.
7
+
8
+ import { eligible } from './policy.js';
9
+ import { scoreRecordTokens } from './recordIndex.js';
10
+ import { LEGACY_SCOPE_MATCH, RECORD_TYPE_WEIGHTS } from './retrieval.js';
11
+
12
+ const SNIPPET_MAX = 160;
13
+ const TYPE_WEIGHT_SCALE = 10; // typeWeight bounded to ≤0.7 bonus
14
+ const RECENCY_HALF_LIFE_MS = 30 * 24 * 60 * 60 * 1000; // bounded decay on created_at
15
+
16
+ function firstCitation(evidence) {
17
+ if (!Array.isArray(evidence)) return null;
18
+ for (const entry of evidence) {
19
+ if (entry != null && typeof entry === 'object'
20
+ && typeof entry.locator === 'string' && entry.locator.length > 0) {
21
+ // Locator is an opaque passthrough — never resolved, never rendered
22
+ // as an instruction.
23
+ return {
24
+ kind: entry.kind ?? null,
25
+ locator: entry.locator,
26
+ observedAt: entry.observed_at ?? null,
27
+ };
28
+ }
29
+ }
30
+ return null;
31
+ }
32
+
33
+ function maxObservedAt(evidence, fallback) {
34
+ let max = null;
35
+ if (Array.isArray(evidence)) {
36
+ for (const entry of evidence) {
37
+ if (entry != null && typeof entry.observed_at === 'number'
38
+ && (max == null || entry.observed_at > max)) {
39
+ max = entry.observed_at;
40
+ }
41
+ }
42
+ }
43
+ return max ?? fallback;
44
+ }
45
+
46
+ // buildMemoryHit(record, {freshness?, score?, reasons?}) → MemoryHit
47
+ // MemoryHit = {id, type, scope, projectId, snippet, citation, observedAt,
48
+ // validFrom, validUntil, revision, freshness, reasons, relevanceScore,
49
+ // summary, source, timestamp}
50
+ export function buildMemoryHit(record, { freshness = null, score = 0, reasons = [] } = {}) {
51
+ const text = String(record?.text ?? '');
52
+ const snippet = text.length > SNIPPET_MAX ? text.slice(0, SNIPPET_MAX) : text;
53
+ const createdAt = record?.created_at ?? 0;
54
+ return {
55
+ id: record?.id ?? null,
56
+ type: record?.type ?? null,
57
+ scope: record?.scope ?? null,
58
+ projectId: record?.project_id ?? null,
59
+ snippet,
60
+ citation: firstCitation(record?.evidence),
61
+ observedAt: maxObservedAt(record?.evidence, createdAt),
62
+ validFrom: record?.valid_from ?? null,
63
+ validUntil: record?.valid_until ?? null,
64
+ revision: record?.revision ?? 1,
65
+ freshness,
66
+ reasons: Array.isArray(reasons) ? [...reasons] : [],
67
+ relevanceScore: score,
68
+ // Backward-compat fields for existing search-result consumers.
69
+ summary: snippet,
70
+ source: record?.type ?? null,
71
+ timestamp: createdAt,
72
+ };
73
+ }
74
+
75
+ // Requested-scope narrowing only — eligibility is owned by policy.js.
76
+ // Delegates to the shared LEGACY_SCOPE_MATCH table from retrieval.js.
77
+ function matchesRequestedScope(record, scope) {
78
+ if (scope === 'all') return true;
79
+ const matcher = LEGACY_SCOPE_MATCH[scope];
80
+ return matcher ? matcher(record) : (record.scope === scope || record.type === scope);
81
+ }
82
+
83
+ function hasLocatorEvidence(record) {
84
+ return Array.isArray(record.evidence)
85
+ && record.evidence.some((entry) => entry != null
86
+ && typeof entry.locator === 'string' && entry.locator.length > 0);
87
+ }
88
+
89
+ // rankHits(records, {queryTokens, eligibleCtx, policy, scope?, limit?, now?})
90
+ // → MemoryHit[]
91
+ // Eligibility first (eligible() + requested-scope narrowing), then composite
92
+ // score = lexical + typeBonus + evidenceBonus + recencyBonus. Dedup by id.
93
+ export function rankHits(records, {
94
+ queryTokens = [],
95
+ eligibleCtx = {},
96
+ policy = {},
97
+ scope = 'all',
98
+ limit = 10,
99
+ now,
100
+ } = {}) {
101
+ if (!Array.isArray(records) || records.length === 0) return [];
102
+ if (!Array.isArray(queryTokens) || queryTokens.length === 0) return [];
103
+ const nowMs = typeof now === 'number' ? now : Date.now();
104
+ const ctx = { ...eligibleCtx, now: nowMs };
105
+
106
+ const seen = new Set();
107
+ const scored = [];
108
+ for (const record of records) {
109
+ if (record == null || seen.has(record.id)) continue;
110
+ seen.add(record.id);
111
+ if (!eligible(record, ctx, policy).ok) continue;
112
+ if (!matchesRequestedScope(record, scope)) continue;
113
+
114
+ const lexical = scoreRecordTokens(record, queryTokens);
115
+ const typeWeight = RECORD_TYPE_WEIGHTS[record.type] ?? 1;
116
+ const typeBonus = typeWeight / TYPE_WEIGHT_SCALE;
117
+ const evidenceBonus = hasLocatorEvidence(record) ? 1 : 0;
118
+ const age = nowMs - (record.created_at ?? 0);
119
+ const recencyBonus = record.created_at > 0 && age >= 0
120
+ ? Math.exp(-Math.LN2 * age / RECENCY_HALF_LIFE_MS)
121
+ : 0;
122
+ const score = lexical + typeBonus + evidenceBonus + recencyBonus;
123
+
124
+ const reasons = [];
125
+ if (lexical > 0) reasons.push('lexical');
126
+ reasons.push(`type:${record.type}`);
127
+ if (evidenceBonus > 0) reasons.push('evidence');
128
+ if (recencyBonus > 0) reasons.push('recency');
129
+
130
+ scored.push({ record, score, reasons });
131
+ }
132
+
133
+ return scored
134
+ .sort((left, right) => (
135
+ right.score - left.score
136
+ || (right.record.created_at ?? 0) - (left.record.created_at ?? 0)
137
+ || String(left.record.text ?? '').localeCompare(String(right.record.text ?? ''))
138
+ ))
139
+ .slice(0, limit)
140
+ .map((entry) => buildMemoryHit(entry.record, {
141
+ score: entry.score,
142
+ reasons: entry.reasons,
143
+ }));
144
+ }
@@ -1,16 +1,19 @@
1
- // Memory v1 → v2 migration — SPEC §4.
1
+ // Memory v1 → v2 migration — SPEC §4 / §5 FR-001..003.
2
2
  // Idempotent: marker v2/migrated-from-v1.json; re-run is a no-op.
3
3
  // Backup-first: legacy memory dir copied to v1-backup-<ts>/ before writing.
4
4
  // Legacy files are never mutated.
5
+ // Record mapping lives in migrateMapping.js (pure, deterministic); this file
6
+ // only collects legacy sources, resolves identities/tombstones, and runs the
7
+ // guarded store merge.
5
8
 
6
- import crypto from 'node:crypto';
7
9
  import fs from 'node:fs/promises';
8
10
  import path from 'node:path';
9
11
  import { buildRuntimePaths } from '../runtimePaths.js';
10
12
  import { readJsonIfExists, writeJson } from '../fileOps.js';
11
13
  import { loadRuntimeConfig } from '../runtimeConfig.js';
12
- import { createRecord } from './records.js';
13
- import { mutateRecordStore } from './recordStore.js';
14
+ import { mapLegacySources } from './migrateMapping.js';
15
+ import { readRecordStore } from './recordStore.js';
16
+ import { lookupRegisteredProjectId } from './projectIdentity.js';
14
17
 
15
18
  const MARKER_NAME = 'migrated-from-v1.json';
16
19
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -62,176 +65,38 @@ async function copyDirRecursive(srcDir, destDir) {
62
65
  }
63
66
  }
64
67
 
65
- function fingerprint(type, text) {
66
- const normalized = String(text).toLowerCase().replace(/\s+/g, ' ').trim();
67
- return crypto.createHash('sha1').update(`v1:${type}:${normalized}`).digest('hex');
68
- }
69
-
70
- // Per-run skip counter — threaded explicitly so concurrent runMigration calls
71
- // can never cross-report each other's skips (a module-level counter did).
72
-
73
- function makeRecord({ type, scope, text, confidence, provenance, projectId = null, validUntil = null, createdAt = null, meta = {}, status = null }, skip) {
74
- try {
75
- const record = createRecord({
76
- type,
77
- scope,
78
- text,
79
- provenance,
80
- sourceFingerprint: fingerprint(type, text),
81
- confidence,
82
- createdBy: 'migration',
83
- projectId,
84
- validUntil,
85
- meta,
86
- });
87
- if (createdAt != null && Number.isFinite(createdAt)) record.created_at = createdAt;
88
- if (status) record.status = status;
89
- return record;
90
- } catch {
91
- skip.count += 1;
92
- return null;
93
- }
94
- }
95
-
96
- function migrateUserMemory(user, records, skip) {
97
- if (!user || typeof user !== 'object') return;
98
- for (const rule of Array.isArray(user.rules) ? user.rules : []) {
99
- if (typeof rule !== 'string' || !rule) continue;
100
- const rec = makeRecord({
101
- type: 'project_rule', scope: 'user', text: rule,
102
- confidence: 1.0, provenance: 'legacy:user-rules',
103
- createdAt: user.updatedAt,
104
- }, skip);
105
- if (rec) records.push(rec);
106
- }
107
- const prefs = user.preferences && typeof user.preferences === 'object' ? user.preferences : {};
108
- for (const [key, value] of Object.entries(prefs)) {
109
- const text = `${key} = ${typeof value === 'string' ? value : JSON.stringify(value)}`;
110
- const rec = makeRecord({
111
- type: 'derived_fact', scope: 'user', text,
112
- confidence: 1.0, provenance: 'legacy:user-preferences',
113
- createdAt: user.updatedAt,
114
- }, skip);
115
- if (rec) records.push(rec);
116
- }
117
- }
118
-
119
- function migrateProjectMemory(project, projectId, records, skip) {
120
- if (!project || typeof project !== 'object') return;
121
- const createdAt = project.updatedAt;
122
-
123
- for (const text of Array.isArray(project.conventions) ? project.conventions : []) {
124
- if (typeof text !== 'string' || !text) continue;
125
- const rec = makeRecord({
126
- type: 'project_rule', scope: 'repo', text,
127
- confidence: 0.9, provenance: 'legacy:conventions',
128
- projectId, createdAt,
129
- }, skip);
130
- if (rec) records.push(rec);
131
- }
132
-
133
- for (const text of Array.isArray(project.activeRules) ? project.activeRules : []) {
134
- if (typeof text !== 'string' || !text) continue;
135
- const rec = makeRecord({
136
- type: 'project_rule', scope: 'repo', text,
137
- confidence: 1.0, provenance: 'legacy:activeRules',
138
- projectId, createdAt,
139
- }, skip);
140
- if (rec) records.push(rec);
141
- }
142
-
143
- for (const decision of Array.isArray(project.decisions) ? project.decisions : []) {
144
- if (!decision || typeof decision !== 'object') continue;
145
- const text = decision.why ? `${decision.what} — ${decision.why}` : String(decision.what ?? '');
146
- if (!text) continue;
147
- const rec = makeRecord({
148
- type: 'derived_fact', scope: 'repo', text,
149
- confidence: 0.9, provenance: 'legacy:decisions',
150
- projectId, createdAt: decision.when ?? createdAt,
151
- }, skip);
152
- if (rec) records.push(rec);
153
- }
154
-
155
- const profileEntries = [
156
- ...(typeof project.architecture === 'string' && project.architecture ? [project.architecture] : []),
157
- ...(Array.isArray(project.techStack) ? project.techStack.filter((t) => typeof t === 'string' && t) : []),
158
- ];
159
- for (const text of profileEntries) {
160
- const rec = makeRecord({
161
- type: 'derived_fact', scope: 'repo', text,
162
- confidence: 0.8, provenance: 'legacy:project-profile',
163
- projectId, createdAt,
164
- }, skip);
165
- if (rec) records.push(rec);
166
- }
167
-
168
- for (const pc of Array.isArray(project.patternCandidates) ? project.patternCandidates : []) {
169
- if (!pc || typeof pc !== 'object' || typeof pc.text !== 'string' || !pc.text) continue;
170
- const legacyStatus = pc.status ?? 'pending';
171
- const meta = { ...pc, legacyStatus };
172
- const base = {
173
- text: pc.text,
174
- scope: 'task', confidence: 0.4, provenance: 'legacy:pattern-candidate',
175
- projectId, createdAt: pc.detectedAt ?? createdAt, meta,
176
- };
177
- let rec = null;
178
- if (legacyStatus === 'approved') {
179
- rec = makeRecord({ ...base, type: 'project_rule', scope: 'repo', confidence: 0.9 }, skip);
180
- } else if (legacyStatus === 'rejected') {
181
- rec = makeRecord({ ...base, type: 'derived_fact', status: 'archived' }, skip);
182
- } else {
183
- rec = makeRecord({ ...base, type: 'derived_fact' }, skip);
184
- }
185
- if (rec) records.push(rec);
186
- }
187
- }
188
-
189
- function sessionText(session) {
190
- return [
191
- session.taskDescription,
192
- session.outcome ? `outcome: ${session.outcome}` : null,
193
- ...(Array.isArray(session.keyActions) ? session.keyActions : []),
194
- ].filter(Boolean).join(' — ');
195
- }
196
-
197
- function migrateSession(session, episodeTtlMs, records, skip) {
198
- if (!session || typeof session !== 'object') return;
199
- const text = sessionText(session);
200
- if (!text) return;
201
- const createdAt = session.startedAt ?? session.endedAt ?? null;
202
- const validUntil = createdAt != null ? createdAt + episodeTtlMs : null;
203
- const rec = makeRecord({
204
- type: 'episode', scope: 'session', text,
205
- confidence: 0.7, provenance: 'legacy:session',
206
- projectId: typeof session.projectId === 'string' ? session.projectId : null,
207
- createdAt, validUntil,
208
- meta: { legacyId: session.id ?? null },
209
- }, skip);
210
- if (rec) records.push(rec);
211
- }
212
-
68
+ // Source shape consumed by mapLegacySources:
69
+ // { user, userFile, projects:[{id,file,content}],
70
+ // sessions:[{file,session}], corruptFiles, corrupt:[fileName] }
213
71
  async function collectLegacySources(paths) {
214
- const sources = { user: null, projects: [], sessions: [], corruptFiles: 0 };
72
+ const sources = { user: null, userFile: null, projects: [], sessions: [], corruptFiles: 0, corrupt: [] };
215
73
 
216
74
  const user = await readJsonTolerant(paths.userMemoryPath);
217
- if (user.corrupt) sources.corruptFiles += 1;
218
- if (user.value) sources.user = user.value;
75
+ if (user.corrupt) {
76
+ sources.corruptFiles += 1;
77
+ sources.corrupt.push(path.basename(paths.userMemoryPath));
78
+ }
79
+ if (user.value) {
80
+ sources.user = user.value;
81
+ sources.userFile = path.basename(paths.userMemoryPath);
82
+ }
219
83
 
220
84
  for (const name of await listJsonFiles(paths.projectsDir)) {
221
85
  const filePath = path.join(paths.projectsDir, name);
222
86
  const { value: content, corrupt } = await readJsonTolerant(filePath);
223
87
  if (corrupt) {
224
88
  sources.corruptFiles += 1;
89
+ sources.corrupt.push(name);
225
90
  continue;
226
91
  }
227
92
  if (!content) continue;
228
93
  if (name.endsWith('.archive.json')) {
229
94
  for (const s of Array.isArray(content.sessions) ? content.sessions : []) {
230
- sources.sessions.push(s);
95
+ sources.sessions.push({ file: name, session: s });
231
96
  }
232
97
  continue;
233
98
  }
234
- sources.projects.push({ id: content.id ?? name.replace(/\.json$/, ''), content });
99
+ sources.projects.push({ id: content.id ?? name.replace(/\.json$/, ''), file: name, content });
235
100
  }
236
101
 
237
102
  for (const name of await listJsonFiles(paths.sessionsDir)) {
@@ -239,13 +104,14 @@ async function collectLegacySources(paths) {
239
104
  const { value: content, corrupt } = await readJsonTolerant(filePath);
240
105
  if (corrupt) {
241
106
  sources.corruptFiles += 1;
107
+ sources.corrupt.push(name);
242
108
  continue;
243
109
  }
244
110
  if (!content) continue;
245
111
  if (name.endsWith('.archive.json') && Array.isArray(content.sessions)) {
246
- sources.sessions.push(...content.sessions);
112
+ for (const s of content.sessions) sources.sessions.push({ file: name, session: s });
247
113
  } else {
248
- sources.sessions.push(content);
114
+ sources.sessions.push({ file: name, session: content });
249
115
  }
250
116
  }
251
117
 
@@ -257,18 +123,12 @@ function hasLegacyContent(sources) {
257
123
  || sources.sessions.length > 0 || sources.corruptFiles > 0;
258
124
  }
259
125
 
260
- async function buildRecords(projectRoot, paths, sources, skip) {
126
+ async function episodeTtlMsFor(projectRoot) {
261
127
  const config = await loadRuntimeConfig(projectRoot);
262
128
  const ttlDays = Number.isFinite(config?.memoryV2?.episodeTtlDays) && config.memoryV2.episodeTtlDays > 0
263
129
  ? config.memoryV2.episodeTtlDays
264
130
  : 90;
265
- const episodeTtlMs = ttlDays * DAY_MS;
266
-
267
- const records = [];
268
- if (sources.user) migrateUserMemory(sources.user, records, skip);
269
- for (const { id, content } of sources.projects) migrateProjectMemory(content, id, records, skip);
270
- for (const session of sources.sessions) migrateSession(session, episodeTtlMs, records, skip);
271
- return records;
131
+ return ttlDays * DAY_MS;
272
132
  }
273
133
 
274
134
  /**
@@ -285,25 +145,41 @@ export async function needsMigration(projectRoot) {
285
145
 
286
146
  /**
287
147
  * runMigration(projectRoot, { dryRun? } = {})
288
- * → { migrated, skipped, markerPath, backupDir }
289
- * No-op when the marker exists or no legacy files exist. dryRun reports
290
- * would-be counts without writing records/marker/backup.
148
+ * → { migrated, skipped, plan, markerPath, backupDir }
149
+ * No-op when the marker exists or no legacy files exist. dryRun returns the
150
+ * full per-record plan ({id, source, action, reason}) and writes nothing.
291
151
  */
292
152
  export async function runMigration(projectRoot, { dryRun = false } = {}) {
293
153
  const paths = buildRuntimePaths(projectRoot);
294
154
  const markerPath = path.join(paths.memoryV2Dir, MARKER_NAME);
295
- const empty = { migrated: 0, skipped: 0, markerPath, backupDir: null };
155
+ const empty = { migrated: 0, skipped: 0, plan: [], markerPath, backupDir: null };
296
156
 
297
157
  if (await pathExists(markerPath)) return empty;
298
158
 
299
159
  const sources = await collectLegacySources(paths);
300
160
  if (!hasLegacyContent(sources)) return empty;
301
161
 
302
- const skip = { count: 0 };
303
- const records = await buildRecords(projectRoot, paths, sources, skip);
304
- const skipped = skip.count + sources.corruptFiles;
162
+ // Tombstones + alias resolution feed the pure mapper — tombstone
163
+ // precedence is planned here and enforced again inside mutateMemory.
164
+ // Registry lookups are async, so legacy ids are pre-resolved and handed
165
+ // to the mapper as a synchronous closure.
166
+ const prior = await readRecordStore(paths.memoryV2RecordsPath);
167
+ const resolvedIds = new Map();
168
+ for (const p of sources.projects) {
169
+ if (p.id && !resolvedIds.has(p.id)) {
170
+ resolvedIds.set(p.id, await lookupRegisteredProjectId(p.id));
171
+ }
172
+ }
173
+ const { records, plan, skipped } = mapLegacySources(sources, {
174
+ projectId: null,
175
+ now: Date.now(),
176
+ episodeTtlMs: await episodeTtlMsFor(projectRoot),
177
+ tombstones: prior.tombstones,
178
+ resolveProjectId: (legacyId) => resolvedIds.get(legacyId) ?? null,
179
+ });
180
+
305
181
  if (dryRun) {
306
- return { migrated: records.length, skipped, markerPath, backupDir: null };
182
+ return { migrated: records.length, skipped, plan, markerPath, backupDir: null };
307
183
  }
308
184
 
309
185
  // Backup-first: copy legacy memory dir (excluding v2 + prior backups) before writing.
@@ -321,20 +197,24 @@ export async function runMigration(projectRoot, { dryRun = false } = {}) {
321
197
  }
322
198
  }
323
199
 
324
- // Merge into the existing store under the shared lock — never clobber.
325
- // records.json can already hold records when the marker is absent (a crash
326
- // between saveRecords and the marker write, or a manual re-run); overwriting
327
- // it would silently destroy post-migration data. source_fingerprint dedupes
328
- // re-migrated records so the merge stays idempotent.
329
- await mutateRecordStore(paths.memoryV2RecordsPath, (existing) => {
330
- const fingerprints = new Set(
331
- existing.map((r) => r.source_fingerprint).filter(Boolean),
332
- );
333
- const additions = records.filter(
334
- (r) => !r.source_fingerprint || !fingerprints.has(r.source_fingerprint),
200
+ // Merge into the existing store through mutateMemory's restore op — the
201
+ // single guarded write entry. Tombstone precedence lives there, so a
202
+ // purged record can never resurrect via re-migration; source_fingerprint
203
+ // + deterministic-id dedupe keeps the merge idempotent. A non-ok status
204
+ // (corrupt/disabled/rejected) is fail-closed: surface it as an error
205
+ // rather than writing the marker over a store that never received the
206
+ // records.
207
+ const { mutateMemory } = await import('./mutateMemory.js');
208
+ const restore = await mutateMemory(
209
+ { kind: 'project', projectRoot },
210
+ { op: 'restore', payload: { records } },
211
+ );
212
+ if (restore.status !== 'ok') {
213
+ throw new Error(
214
+ `runMigration: restore rejected — ${restore.status}${restore.reason ? `:${restore.reason}` : ''}`,
335
215
  );
336
- return { result: additions.length, records: [...existing, ...additions] };
337
- });
216
+ }
217
+ const migrated = restore.added ?? 0;
338
218
 
339
219
  const counts = {
340
220
  total: records.length,
@@ -347,5 +227,5 @@ export async function runMigration(projectRoot, { dryRun = false } = {}) {
347
227
  backupDir,
348
228
  });
349
229
 
350
- return { migrated: records.length, skipped, markerPath, backupDir };
230
+ return { migrated, skipped, plan, markerPath, backupDir };
351
231
  }