@ngockhoale/ukit 2.6.7 → 2.6.8

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 (44) hide show
  1. package/manifests/documentation.yaml +77 -6
  2. package/manifests/hostCapabilities.yaml +49 -0
  3. package/manifests/instructionRules.yaml +7 -0
  4. package/package.json +1 -1
  5. package/scripts/bench/goldTasks.json +38 -0
  6. package/scripts/bench/runGold.mjs +220 -0
  7. package/scripts/release/verify-release.mjs +6 -0
  8. package/src/cli/commands/code.js +182 -0
  9. package/src/cli/commands/doctor.js +35 -3
  10. package/src/cli/commands/indexTools.js +102 -1
  11. package/src/cli/commands/memory.js +137 -0
  12. package/src/cli/index.js +7 -0
  13. package/src/core/codeintel/compiler.js +316 -0
  14. package/src/core/codeintel/diagnostics.js +114 -0
  15. package/src/core/codeintel/freshness.js +295 -0
  16. package/src/core/codeintel/impact.js +251 -0
  17. package/src/core/codeintel/invalidation.js +150 -0
  18. package/src/core/codeintel/manifest.js +176 -0
  19. package/src/core/codeintel/packet.js +146 -0
  20. package/src/core/codeintel/providers.js +201 -0
  21. package/src/core/codeintel/retriever.js +372 -0
  22. package/src/core/codeintel/router.js +149 -0
  23. package/src/core/codeintel/semanticProvider.js +235 -0
  24. package/src/core/docContracts.js +723 -0
  25. package/src/core/memory/migrate.js +324 -0
  26. package/src/core/memory/records.js +172 -0
  27. package/src/core/memory/retrieval.js +161 -11
  28. package/src/core/memory/store.js +398 -0
  29. package/src/core/memory/storeV2.js +171 -0
  30. package/src/core/memory/storeV2Loader.js +22 -0
  31. package/src/core/runtimeConfig.js +125 -0
  32. package/src/core/runtimePaths.js +3 -0
  33. package/src/index/taskRouting.js +39 -0
  34. package/templates/.claude/ukit/index/route-task.mjs +40 -0
  35. package/templates/AGENTS.md +46 -99
  36. package/templates/CLAUDE.md +46 -99
  37. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
  38. package/templates/docs/BUGFIX.md +2 -19
  39. package/templates/docs/BUG_INDEX.md +43 -0
  40. package/templates/docs/BUG_METRICS.md +1 -5
  41. package/templates/docs/BUG_TEMPLATE.md +1 -11
  42. package/templates/docs/UKIT_INTERNALS.md +4 -0
  43. package/templates/instructions/core.md +46 -99
  44. package/templates/ukit/storage/config.json +30 -0
@@ -6,6 +6,9 @@ import {
6
6
  writePromptCacheEntry,
7
7
  } from '../token/index.js';
8
8
  import { listMemoryItems } from './store.js';
9
+ import { isRecordUsable } from './records.js';
10
+ import { loadV2Records } from './storeV2Loader.js';
11
+ import { loadRuntimeConfig } from '../runtimeConfig.js';
9
12
 
10
13
  const STOPWORDS = new Set([
11
14
  'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
@@ -83,6 +86,7 @@ function getTimestamp(item) {
83
86
  return item.content?.updatedAt
84
87
  ?? item.content?.endedAt
85
88
  ?? item.content?.startedAt
89
+ ?? item.created_at
86
90
  ?? 0;
87
91
  }
88
92
 
@@ -158,7 +162,106 @@ function buildSearchResult(item, relevanceScore) {
158
162
  };
159
163
  }
160
164
 
165
+ // ---- Memory v2 records path (SPEC §2–§3) ----
166
+
167
+ const RECORD_TYPE_WEIGHTS = Object.freeze({
168
+ project_rule: 7,
169
+ derived_fact: 5,
170
+ procedure: 5,
171
+ episode: 3,
172
+ });
173
+
174
+ // Lazy import: storeV2.js is owned by TASK-002; missing module or a disabled
175
+ // memoryV2 config falls back to the legacy items path. queryRecords itself
176
+ // triggers ensureMigrated on the real store — no explicit migration here.
177
+ async function queryV2Records(projectRoot) {
178
+ try {
179
+ const config = await loadRuntimeConfig(projectRoot);
180
+ if (config?.memoryV2?.enabled === false) {
181
+ return [];
182
+ }
183
+ } catch {
184
+ // unreadable config → default enabled, keep going
185
+ }
186
+ return loadV2Records(projectRoot, {});
187
+ }
188
+
189
+ function scoreRecord(record, queryTokens) {
190
+ const typeWeight = RECORD_TYPE_WEIGHTS[record.type] ?? 1;
191
+ if (queryTokens.length === 0) {
192
+ return typeWeight * Math.max(0.1, record.confidence ?? 1);
193
+ }
194
+
195
+ const recordTokens = new Set(tokenize(`${record.text ?? ''} ${record.provenance ?? ''}`));
196
+ let matched = 0;
197
+ for (const token of queryTokens) {
198
+ if (recordTokens.has(token)) {
199
+ matched += 1;
200
+ }
201
+ }
202
+ if (matched === 0) {
203
+ return 0;
204
+ }
205
+
206
+ const recencyBonus = record.created_at > 0
207
+ ? Math.min(1, record.created_at / Date.now())
208
+ : 0;
209
+ return typeWeight * matched * Math.max(0.1, record.confidence ?? 1) + recencyBonus;
210
+ }
211
+
212
+ // Legacy scope names ('user'|'project'|'session') map onto v2 scope/type:
213
+ // 'project' ↔ scope 'repo'/'task', 'session' ↔ type 'episode'.
214
+ const LEGACY_SCOPE_MATCH = {
215
+ user: (record) => record.scope === 'user',
216
+ project: (record) => record.scope === 'repo' || record.scope === 'task',
217
+ session: (record) => record.type === 'episode' || record.scope === 'session',
218
+ };
219
+
220
+ function recordWithinScope(record, scope, projectId) {
221
+ if (scope !== 'all') {
222
+ const matcher = LEGACY_SCOPE_MATCH[scope];
223
+ const matches = matcher ? matcher(record) : (record.scope === scope || record.type === scope);
224
+ if (!matches) return false;
225
+ }
226
+ if (projectId && record.project_id && record.project_id !== projectId) {
227
+ return false;
228
+ }
229
+ return true;
230
+ }
231
+
232
+ function buildRecordSearchResult(record, relevanceScore) {
233
+ return {
234
+ id: record.id,
235
+ summary: record.text,
236
+ relevanceScore,
237
+ source: record.type,
238
+ timestamp: record.created_at ?? 0,
239
+ sourcePath: null,
240
+ };
241
+ }
242
+
243
+ function searchRecords(records, query, scope, limit, projectId) {
244
+ const queryTokens = tokenize(query);
245
+ return records
246
+ .filter((record) => isRecordUsable(record))
247
+ .filter((record) => recordWithinScope(record, scope, projectId))
248
+ .map((record) => ({ record, score: scoreRecord(record, queryTokens) }))
249
+ .filter((entry) => entry.score > 0)
250
+ .sort((left, right) => (
251
+ right.score - left.score
252
+ || (right.record.created_at ?? 0) - (left.record.created_at ?? 0)
253
+ || String(right.record.text ?? '').localeCompare(String(left.record.text ?? ''))
254
+ ))
255
+ .slice(0, limit)
256
+ .map((entry) => buildRecordSearchResult(entry.record, entry.score));
257
+ }
258
+
161
259
  export async function search(query, scope = 'all', limit = 10, { projectRoot, projectId } = {}) {
260
+ const records = await queryV2Records(projectRoot);
261
+ if (records.length > 0) {
262
+ return searchRecords(records, query, scope, limit, projectId);
263
+ }
264
+
162
265
  const items = await listMemoryItems(projectRoot);
163
266
  const queryTokens = tokenize(query);
164
267
 
@@ -207,6 +310,33 @@ function findRelatedItemIds(targetItem, allItems) {
207
310
  }
208
311
 
209
312
  export async function expand(id, { projectRoot } = {}) {
313
+ const records = await queryV2Records(projectRoot);
314
+ if (records.length > 0) {
315
+ // Resolve legacy item ids too: 'session:<legacyId>' → episode with
316
+ // meta.legacyId, 'project:<projectId>' → records of that project.
317
+ const colonIdx = String(id).indexOf(':');
318
+ const legacyKind = colonIdx > 0 ? String(id).slice(0, colonIdx) : null;
319
+ const legacyId = colonIdx > 0 ? String(id).slice(colonIdx + 1) : id;
320
+ const target = records.find((record) => record.id === id)
321
+ ?? (legacyKind === 'session'
322
+ ? records.find((record) => record.type === 'episode' && record.meta?.legacyId === legacyId)
323
+ : legacyKind === 'project'
324
+ ? records.find((record) => record.project_id === legacyId)
325
+ : null);
326
+ if (!target) {
327
+ return { id, fullContent: '', relatedItems: [] };
328
+ }
329
+ return {
330
+ id: target.id,
331
+ fullContent: JSON.stringify(target, null, 2),
332
+ relatedItems: records
333
+ .filter((record) => record.id !== target.id)
334
+ .filter((record) => record.type === target.type || record.project_id === target.project_id)
335
+ .map((record) => record.id)
336
+ .slice(0, 5),
337
+ };
338
+ }
339
+
210
340
  const items = await listMemoryItems(projectRoot);
211
341
  const target = items.find((item) => item.id === id);
212
342
  if (!target) {
@@ -259,6 +389,15 @@ function buildInjectionSnippet(item) {
259
389
  return `user-rules=${(content.rules ?? []).slice(0, 3).join('; ') || 'n/a'}`;
260
390
  }
261
391
 
392
+ function includeUserMemoryOrRecord(includeUserMemory, record) {
393
+ return includeUserMemory || record.scope !== 'user';
394
+ }
395
+
396
+ function buildRecordSnippet(record) {
397
+ const text = String(record.text ?? '').trim();
398
+ return `${record.type}=${text.length > 160 ? `${text.slice(0, 157)}...` : text}`;
399
+ }
400
+
262
401
  function normalizePressurePhase(phase) {
263
402
  const normalized = String(phase ?? '').trim().toLowerCase();
264
403
  return ['monitor', 'soft', 'hard'].includes(normalized) ? normalized : 'monitor';
@@ -380,7 +519,8 @@ export async function getContextInjection(
380
519
  inputCompression = true,
381
520
  } = {},
382
521
  ) {
383
- const items = await listMemoryItems(projectRoot);
522
+ const records = await queryV2Records(projectRoot);
523
+ const items = records.length > 0 ? [] : await listMemoryItems(projectRoot);
384
524
  const pressureState = await readCompactPressureState(projectRoot);
385
525
  const recallPlan = resolveRecallPressurePlan({
386
526
  maxTokens,
@@ -388,14 +528,20 @@ export async function getContextInjection(
388
528
  pressureState,
389
529
  });
390
530
  const rankedItems = await search(currentTask, 'all', Math.max(limit * 3, 8), { projectRoot, projectId });
391
- const selectedItems = selectContextItems({
392
- items,
393
- rankedItems,
394
- projectId,
395
- limit: recallPlan.limit,
396
- includeUserMemory: recallPlan.includeUserMemory,
397
- allowProjectFallback: !String(currentTask ?? '').trim(),
398
- });
531
+ const selectedItems = records.length > 0
532
+ ? dedupeItemsById(rankedItems
533
+ .map((entry) => records.find((record) => record.id === entry.id))
534
+ .filter(Boolean)
535
+ .filter((record) => includeUserMemoryOrRecord(recallPlan.includeUserMemory, record)))
536
+ .slice(0, Math.max(1, recallPlan.limit))
537
+ : selectContextItems({
538
+ items,
539
+ rankedItems,
540
+ projectId,
541
+ limit: recallPlan.limit,
542
+ includeUserMemory: recallPlan.includeUserMemory,
543
+ allowProjectFallback: !String(currentTask ?? '').trim(),
544
+ });
399
545
 
400
546
  if (selectedItems.length === 0) {
401
547
  return '';
@@ -418,8 +564,12 @@ export async function getContextInjection(
418
564
  }
419
565
  }
420
566
 
421
- const rawBodyLines = selectedItems.map((item) => `- [${item.type}] ${buildDetailedInjectionSnippet(item)}`);
422
- const bodyLines = selectedItems.map((item) => `- ${buildInjectionSnippet(item)}`);
567
+ const rawBodyLines = records.length > 0
568
+ ? selectedItems.map((record) => `- [${record.type}] ${buildRecordSnippet(record)}`)
569
+ : selectedItems.map((item) => `- [${item.type}] ${buildDetailedInjectionSnippet(item)}`);
570
+ const bodyLines = records.length > 0
571
+ ? selectedItems.map((record) => `- ${buildRecordSnippet(record)}`)
572
+ : selectedItems.map((item) => `- ${buildInjectionSnippet(item)}`);
423
573
  const rawLines = ['## Previous Context', ...rawBodyLines];
424
574
  const rawText = rawLines.join('\n');
425
575
  const conciseText = ['## Previous Context', ...bodyLines].join('\n');
@@ -6,6 +6,178 @@ import { readJsonIfExists, writeJson } from '../fileOps.js';
6
6
  import { loadRuntimeConfig } from '../runtimeConfig.js';
7
7
  import { runHygiene } from './hygiene.js';
8
8
 
9
+ // ---- Memory v2 facade (SPEC §3/§11) ----
10
+ // Every exported signature and CLI output shape is preserved. When the v2
11
+ // store is active — `v2/records.json` exists, or lazy `ensureMigrated` inside
12
+ // storeV2's read path just ran the v1→v2 migration — reads/writes delegate to
13
+ // storeV2 records. Otherwise the legacy file-based path below runs unchanged.
14
+ // storeV2/migrate are lazy-imported to keep this module decoupled and to match
15
+ // the storeV2↔migrate cycle-avoidance pattern.
16
+
17
+ async function storeV2() {
18
+ return import('./storeV2.js');
19
+ }
20
+
21
+ async function v2Active(projectRoot) {
22
+ try {
23
+ const config = await loadRuntimeConfig(projectRoot);
24
+ if (config?.memoryV2?.enabled === false) return false;
25
+ } catch {
26
+ // unreadable config → default enabled, keep going
27
+ }
28
+
29
+ const v2 = await storeV2();
30
+ // loadRecords triggers ensureMigrated (lazy migration) when autoMigrate is
31
+ // on; records.json existing afterwards — or already — means v2 mode.
32
+ await v2.loadRecords(projectRoot);
33
+ try {
34
+ await fs.access(buildRuntimePaths(projectRoot).memoryV2RecordsPath);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+ const LEGACY_CONVENTION_PROVENANCE = new Set([
42
+ 'legacy:conventions',
43
+ 'propose-approved',
44
+ ]);
45
+
46
+ function parsePreferenceText(text) {
47
+ const raw = String(text ?? '');
48
+ const idx = raw.indexOf(' = ');
49
+ if (idx <= 0) return null;
50
+ const key = raw.slice(0, idx).trim();
51
+ const valueRaw = raw.slice(idx + 3);
52
+ let value = valueRaw;
53
+ try {
54
+ value = JSON.parse(valueRaw);
55
+ } catch {
56
+ // keep string form
57
+ }
58
+ return [key, value];
59
+ }
60
+
61
+ function recordDecisionParts(text) {
62
+ const raw = String(text ?? '');
63
+ const idx = raw.indexOf(' — ');
64
+ if (idx <= 0) return { what: raw };
65
+ return { what: raw.slice(0, idx), why: raw.slice(idx + 3) };
66
+ }
67
+
68
+ function summarizeSessionText(text) {
69
+ const raw = String(text ?? '');
70
+ const idx = raw.indexOf(' — ');
71
+ const task = idx > 0 ? raw.slice(0, idx) : raw;
72
+ const outcomeMatch = /outcome: ([^—]+)/.exec(raw);
73
+ const outcome = outcomeMatch ? outcomeMatch[1].trim() : 'unknown';
74
+ return `${task} — ${outcome}`;
75
+ }
76
+
77
+ function recordsToMemoryItems(records, runtimePaths) {
78
+ // list/export keep legacy semantics — all non-archived records stay visible
79
+ // (TTL-expired episodes included; recall applies isRecordUsable on top).
80
+ const usable = records.filter((record) => record.status !== 'archived');
81
+
82
+ // ---- user item (always emitted, like the legacy path) ----
83
+ const preferences = {};
84
+ const rules = [];
85
+ for (const record of usable.filter((r) => r.scope === 'user')) {
86
+ if (record.type === 'project_rule') {
87
+ rules.push(record.text);
88
+ } else {
89
+ const parsed = parsePreferenceText(record.text);
90
+ if (parsed) preferences[parsed[0]] = parsed[1];
91
+ }
92
+ }
93
+ const userMemory = { preferences, rules, updatedAt: Date.now() };
94
+
95
+ // ---- project items grouped by project_id ----
96
+ const projectIds = [...new Set(
97
+ usable
98
+ .filter((r) => typeof r.project_id === 'string' && r.project_id)
99
+ .map((r) => r.project_id),
100
+ )];
101
+ const projectItems = projectIds.map((projectId) => {
102
+ const scoped = usable.filter((r) => r.project_id === projectId && r.type !== 'episode');
103
+ const conventions = scoped
104
+ .filter((r) => r.type === 'project_rule'
105
+ && (LEGACY_CONVENTION_PROVENANCE.has(r.provenance)
106
+ || r.meta?.legacyStatus === 'approved'))
107
+ .map((r) => r.text);
108
+ const activeRules = scoped
109
+ .filter((r) => r.type === 'project_rule' && !conventions.includes(r.text))
110
+ .map((r) => r.text);
111
+ const decisions = scoped
112
+ .filter((r) => r.type === 'derived_fact' && r.provenance === 'legacy:decisions')
113
+ .map((r) => ({ id: r.meta?.id ?? r.id, ...recordDecisionParts(r.text), when: r.created_at }));
114
+ const profileFacts = scoped
115
+ .filter((r) => r.type === 'derived_fact' && r.provenance === 'legacy:project-profile')
116
+ .map((r) => r.text);
117
+ const content = {
118
+ id: projectId,
119
+ name: projectId,
120
+ architecture: profileFacts[0] ?? null,
121
+ techStack: profileFacts.slice(1),
122
+ conventions,
123
+ activeRules,
124
+ decisions,
125
+ patternCandidates: [],
126
+ sessions: [],
127
+ updatedAt: usable
128
+ .filter((r) => r.project_id === projectId)
129
+ .reduce((max, r) => Math.max(max, r.created_at ?? 0), 0),
130
+ };
131
+ return {
132
+ id: `project:${projectId}`,
133
+ type: 'project',
134
+ summary: summarizeProjectMemory(content),
135
+ searchableText: createSearchableText('project', content),
136
+ sourcePath: path.join(runtimePaths.projectsDir, `${sanitizeProjectId(projectId)}.json`),
137
+ content,
138
+ };
139
+ });
140
+
141
+ // ---- session items from episode records ----
142
+ const sessionItems = usable
143
+ .filter((r) => r.type === 'episode')
144
+ .map((record) => {
145
+ const legacyId = record.meta?.legacyId ?? record.id;
146
+ const content = {
147
+ id: legacyId,
148
+ projectId: record.project_id ?? null,
149
+ taskDescription: String(record.text ?? '').split(' — ')[0],
150
+ outcome: summarizeSessionText(record.text).split(' — ').pop(),
151
+ keyActions: [],
152
+ nextSteps: [],
153
+ filesChanged: [],
154
+ startedAt: record.created_at ?? null,
155
+ endedAt: record.created_at ?? null,
156
+ };
157
+ return {
158
+ id: `session:${legacyId}`,
159
+ type: 'session',
160
+ summary: summarizeSessionMemory(content),
161
+ searchableText: createSearchableText('session', content),
162
+ sourcePath: path.join(runtimePaths.sessionsDir, `${legacyId}.json`),
163
+ content,
164
+ };
165
+ });
166
+
167
+ return [
168
+ {
169
+ id: 'user:user',
170
+ type: 'user',
171
+ summary: summarizeUserMemory(userMemory),
172
+ searchableText: createSearchableText('user', userMemory),
173
+ sourcePath: runtimePaths.userMemoryPath,
174
+ content: userMemory,
175
+ },
176
+ ...projectItems,
177
+ ...sessionItems,
178
+ ];
179
+ }
180
+
9
181
  function defaultUserMemory() {
10
182
  return {
11
183
  preferences: {},
@@ -100,6 +272,17 @@ function createSearchableText(type, content) {
100
272
 
101
273
  export async function exportMemory(projectRoot) {
102
274
  const runtimePaths = buildRuntimePaths(projectRoot);
275
+ if (await v2Active(projectRoot)) {
276
+ const v2 = await storeV2();
277
+ const items = recordsToMemoryItems(await v2.loadRecords(projectRoot), runtimePaths);
278
+ const user = items.find((item) => item.type === 'user')?.content ?? defaultUserMemory();
279
+ return {
280
+ user,
281
+ projects: items.filter((item) => item.type === 'project').map((item) => item.content),
282
+ sessions: items.filter((item) => item.type === 'session').map((item) => item.content),
283
+ };
284
+ }
285
+
103
286
  const user = (await readMemoryJson(runtimePaths.userMemoryPath)) ?? defaultUserMemory();
104
287
  const projects = (await readDirectoryJsonItems(runtimePaths.projectsDir)).map((item) => item.content);
105
288
  const sessions = (await readDirectoryJsonItems(runtimePaths.sessionsDir)).map((item) => item.content);
@@ -107,8 +290,34 @@ export async function exportMemory(projectRoot) {
107
290
  return { user, projects, sessions };
108
291
  }
109
292
 
293
+ // Read-compat: corrupt legacy files still surface the same warning the legacy
294
+ // read path emits, so `ukit memory list` output stays identical post-migration.
295
+ async function warnOnCorruptLegacyFiles(runtimePaths) {
296
+ await readMemoryJson(runtimePaths.userMemoryPath);
297
+ for (const dirPath of [runtimePaths.projectsDir, runtimePaths.sessionsDir]) {
298
+ let entries = [];
299
+ try {
300
+ entries = await fs.readdir(dirPath, { withFileTypes: true });
301
+ } catch {
302
+ continue;
303
+ }
304
+ for (const entry of entries) {
305
+ if (entry.isFile() && entry.name.endsWith('.json')) {
306
+ await readMemoryJson(path.join(dirPath, entry.name));
307
+ }
308
+ }
309
+ }
310
+ }
311
+
110
312
  export async function listMemoryItems(projectRoot) {
111
313
  const runtimePaths = buildRuntimePaths(projectRoot);
314
+ if (await v2Active(projectRoot)) {
315
+ const v2 = await storeV2();
316
+ const records = await v2.loadRecords(projectRoot);
317
+ await warnOnCorruptLegacyFiles(runtimePaths);
318
+ return recordsToMemoryItems(records, runtimePaths);
319
+ }
320
+
112
321
  const userMemory = (await readMemoryJson(runtimePaths.userMemoryPath)) ?? defaultUserMemory();
113
322
  const projectMemories = await readDirectoryJsonItems(runtimePaths.projectsDir);
114
323
  const sessionMemories = await readDirectoryJsonItems(runtimePaths.sessionsDir);
@@ -141,8 +350,70 @@ export async function listMemoryItems(projectRoot) {
141
350
  ];
142
351
  }
143
352
 
353
+ async function forgetMemoryItemV2(projectRoot, memoryId, runtimePaths) {
354
+ const v2 = await storeV2();
355
+ const archiveAll = async (records) => {
356
+ let archived = 0;
357
+ for (const record of records) {
358
+ if (record.status !== 'archived') {
359
+ await v2.updateRecord(projectRoot, record.id, { status: 'archived' });
360
+ archived += 1;
361
+ }
362
+ }
363
+ return archived;
364
+ };
365
+
366
+ if (memoryId === 'user:user' || memoryId === 'user') {
367
+ const userRecords = await v2.queryRecords(projectRoot, { scope: 'user' });
368
+ const archived = await archiveAll(userRecords);
369
+ await writeJson(runtimePaths.userMemoryPath, defaultUserMemory());
370
+ return { removed: archived > 0 || true, type: 'user', path: runtimePaths.userMemoryPath };
371
+ }
372
+
373
+ const id = String(memoryId);
374
+ const separatorIndex = id.indexOf(':');
375
+ if (separatorIndex <= 0 || separatorIndex === id.length - 1) {
376
+ return { removed: false, type: null, path: null };
377
+ }
378
+ const type = id.slice(0, separatorIndex);
379
+ const rawId = id.slice(separatorIndex + 1);
380
+ if (rawId.includes('/') || rawId.includes('\\') || rawId === '..') {
381
+ return { removed: false, type, path: null };
382
+ }
383
+
384
+ const records = await v2.loadRecords(projectRoot);
385
+ const matched = type === 'session'
386
+ ? records.filter((r) => r.type === 'episode' && (r.meta?.legacyId === rawId || r.id === rawId))
387
+ : type === 'project'
388
+ ? records.filter((r) => r.project_id === rawId)
389
+ : [];
390
+ const archived = await archiveAll(matched);
391
+
392
+ // Keep the legacy mirror in sync so a later read-compat consumer cannot
393
+ // resurrect a forgotten item.
394
+ const baseDir = type === 'project'
395
+ ? runtimePaths.projectsDir
396
+ : type === 'session'
397
+ ? runtimePaths.sessionsDir
398
+ : null;
399
+ let legacyRemoved = false;
400
+ if (baseDir) {
401
+ try {
402
+ await fs.unlink(path.join(baseDir, `${rawId}.json`));
403
+ legacyRemoved = true;
404
+ } catch {
405
+ legacyRemoved = false;
406
+ }
407
+ }
408
+
409
+ return { removed: archived > 0 || legacyRemoved, type: baseDir ? type : null, path: null };
410
+ }
411
+
144
412
  export async function forgetMemoryItem(projectRoot, memoryId) {
145
413
  const runtimePaths = buildRuntimePaths(projectRoot);
414
+ if (await v2Active(projectRoot)) {
415
+ return forgetMemoryItemV2(projectRoot, memoryId, runtimePaths);
416
+ }
146
417
  if (memoryId === 'user:user' || memoryId === 'user') {
147
418
  await writeJson(runtimePaths.userMemoryPath, defaultUserMemory());
148
419
  return { removed: true, type: 'user', path: runtimePaths.userMemoryPath };
@@ -246,8 +517,106 @@ export async function runProjectHygiene(projectRoot, projectId) {
246
517
  return persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
247
518
  }
248
519
 
520
+ function patternCandidateFromRecord(record) {
521
+ return {
522
+ id: record.meta?.id ?? record.id,
523
+ text: record.text,
524
+ category: record.meta?.category ?? null,
525
+ detectedFrom: record.meta?.detectedFrom ?? null,
526
+ detectedAt: record.meta?.detectedAt ?? record.created_at ?? null,
527
+ status: record.meta?.legacyStatus ?? 'pending',
528
+ _recordId: record.id,
529
+ };
530
+ }
531
+
532
+ async function proposePatternCandidateV2(projectRoot, projectId, candidate) {
533
+ const v2 = await storeV2();
534
+ const normalizedText = normalizePatternText(candidate?.text);
535
+ if (!normalizedText) {
536
+ throw new Error('proposePatternCandidate requires candidate.text');
537
+ }
538
+
539
+ const existing = (await v2.queryRecords(projectRoot, { projectId }))
540
+ .filter((record) => record.provenance === 'pattern-candidate'
541
+ || String(record.provenance ?? '').includes('pattern-candidate'))
542
+ .find((record) => record.meta?.legacyStatus === 'pending'
543
+ && normalizePatternText(record.text) === normalizedText);
544
+ if (existing) {
545
+ return patternCandidateFromRecord(existing);
546
+ }
547
+
548
+ const entry = {
549
+ id: `pc_${crypto.randomBytes(6).toString('hex')}`,
550
+ text: candidate.text,
551
+ category: candidate?.category ?? null,
552
+ detectedFrom: candidate?.detectedFrom ?? null,
553
+ detectedAt: Date.now(),
554
+ status: 'pending',
555
+ };
556
+
557
+ await v2.addRecord(projectRoot, {
558
+ type: 'derived_fact',
559
+ scope: 'task',
560
+ text: entry.text,
561
+ provenance: 'pattern-candidate',
562
+ confidence: 0.4,
563
+ createdBy: 'propose',
564
+ projectId,
565
+ meta: { ...entry, legacyStatus: 'pending' },
566
+ });
567
+ return entry;
568
+ }
569
+
570
+ async function listPendingPatternCandidatesV2(projectRoot, projectId) {
571
+ const v2 = await storeV2();
572
+ return (await v2.queryRecords(projectRoot, { projectId }))
573
+ .filter((record) => record.type === 'derived_fact'
574
+ && record.meta?.legacyStatus === 'pending'
575
+ && record.status !== 'archived')
576
+ .map(patternCandidateFromRecord);
577
+ }
578
+
579
+ async function resolvePatternCandidateV2(projectRoot, projectId, candidateId, decision) {
580
+ const v2 = await storeV2();
581
+ const records = await v2.queryRecords(projectRoot, { projectId });
582
+ const target = records.find((record) => record.meta?.id === candidateId || record.id === candidateId);
583
+ if (!target) {
584
+ throw new Error(`Pattern candidate not found: ${candidateId}`);
585
+ }
586
+
587
+ let updated;
588
+ if (decision === 'approve') {
589
+ updated = await v2.updateRecord(projectRoot, target.id, {
590
+ type: 'project_rule',
591
+ scope: 'repo',
592
+ confidence: 0.9,
593
+ provenance: `${target.provenance ?? 'pattern-candidate'};promoted-from:${target.id}`,
594
+ meta: { ...target.meta, legacyStatus: 'approved' },
595
+ });
596
+ } else {
597
+ updated = await v2.updateRecord(projectRoot, target.id, {
598
+ status: 'archived',
599
+ meta: { ...target.meta, legacyStatus: 'rejected' },
600
+ });
601
+ }
602
+ return patternCandidateFromRecord(updated ?? target);
603
+ }
604
+
249
605
  export async function proposePatternCandidate(projectRoot, projectId, candidate) {
250
606
  const runtimePaths = buildRuntimePaths(projectRoot);
607
+ if (await v2Active(projectRoot)) {
608
+ const entry = await proposePatternCandidateV2(projectRoot, projectId, candidate);
609
+ // Mirror into the legacy project file (hygiene + conventions) so the
610
+ // untouched-on-disk legacy format stays read-compatible.
611
+ const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
612
+ const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
613
+ if (!patternCandidates.some((existing) => existing.id === entry.id)) {
614
+ memory.patternCandidates = [...patternCandidates, entry];
615
+ await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
616
+ }
617
+ return entry;
618
+ }
619
+
251
620
  const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
252
621
 
253
622
  const normalizedText = normalizePatternText(candidate?.text);
@@ -279,6 +648,10 @@ export async function proposePatternCandidate(projectRoot, projectId, candidate)
279
648
 
280
649
  export async function listPendingPatternCandidates(projectRoot, projectId) {
281
650
  const runtimePaths = buildRuntimePaths(projectRoot);
651
+ if (await v2Active(projectRoot)) {
652
+ return listPendingPatternCandidatesV2(projectRoot, projectId);
653
+ }
654
+
282
655
  const { memory } = await readProjectMemoryForId(runtimePaths, projectId);
283
656
  const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
284
657
  return patternCandidates.filter((entry) => entry.status === 'pending');
@@ -290,6 +663,31 @@ export async function resolvePatternCandidate(projectRoot, projectId, candidateI
290
663
  }
291
664
 
292
665
  const runtimePaths = buildRuntimePaths(projectRoot);
666
+ if (await v2Active(projectRoot)) {
667
+ const resolved = await resolvePatternCandidateV2(projectRoot, projectId, candidateId, decision);
668
+ // Mirror the resolution into the legacy project file.
669
+ const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
670
+ const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];
671
+ const target = patternCandidates.find((entry) => entry.id === candidateId);
672
+ if (target) {
673
+ target.status = resolved.status;
674
+ if (decision === 'approve') {
675
+ const conventions = Array.isArray(memory.conventions) ? memory.conventions : [];
676
+ if (!conventions.includes(target.text)) {
677
+ memory.conventions = [...conventions, target.text];
678
+ }
679
+ }
680
+ await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
681
+ } else if (decision === 'approve') {
682
+ const conventions = Array.isArray(memory.conventions) ? memory.conventions : [];
683
+ if (!conventions.includes(resolved.text)) {
684
+ memory.conventions = [...conventions, resolved.text];
685
+ await persistProjectMemoryWithHygiene(projectRoot, runtimePaths, projectId, filePath, memory);
686
+ }
687
+ }
688
+ return resolved;
689
+ }
690
+
293
691
  const { filePath, memory } = await readProjectMemoryForId(runtimePaths, projectId);
294
692
 
295
693
  const patternCandidates = Array.isArray(memory.patternCandidates) ? memory.patternCandidates : [];