@ngockhoale/ukit 3.0.8 → 3.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/manifests/documentation.yaml +11 -0
  3. package/package.json +1 -1
  4. package/scripts/audit/decision-coverage.mjs +29 -2
  5. package/scripts/bench/data-foundation.mjs +52 -3
  6. package/scripts/bench/decision-runtime-baseline.mjs +427 -0
  7. package/scripts/bench/decision-runtime-metrics.mjs +67 -0
  8. package/scripts/bench/decision-runtime-variant.mjs +626 -0
  9. package/scripts/bench/memory-ablation.mjs +495 -0
  10. package/scripts/bench/memory-baseline.mjs +596 -0
  11. package/scripts/bench/memory-bench.mjs +661 -0
  12. package/scripts/bench/memory-canary.mjs +321 -0
  13. package/scripts/bench/memory-corpus.mjs +354 -0
  14. package/scripts/bench/memory-gate.mjs +389 -0
  15. package/scripts/bench/memory-metrics.mjs +179 -0
  16. package/scripts/bench/parallel-agents.mjs +33 -11
  17. package/scripts/bench/recorder-overhead.mjs +204 -0
  18. package/scripts/bench/sqlite-spike.mjs +451 -0
  19. package/scripts/measure-decision-gateway.mjs +306 -0
  20. package/scripts/perf/audit-perf.mjs +35 -17
  21. package/src/bug/triageBug.js +4 -3
  22. package/src/cli/commands/memory.js +357 -63
  23. package/src/context/detectProjectContext.js +11 -1
  24. package/src/core/agentRuntime/adapters.js +254 -0
  25. package/src/core/agentRuntime/artifacts.js +192 -0
  26. package/src/core/agentRuntime/completionGate.js +176 -0
  27. package/src/core/agentRuntime/context.js +149 -0
  28. package/src/core/agentRuntime/contract.js +247 -0
  29. package/src/core/agentRuntime/diagnostics.js +244 -0
  30. package/src/core/agentRuntime/evaluation.js +163 -0
  31. package/src/core/agentRuntime/eventStore.js +404 -0
  32. package/src/core/agentRuntime/liveness.js +60 -0
  33. package/src/core/agentRuntime/planCompiler.js +322 -0
  34. package/src/core/agentRuntime/promotion.js +53 -0
  35. package/src/core/agentRuntime/qualityComparison.js +112 -0
  36. package/src/core/agentRuntime/recovery.js +266 -0
  37. package/src/core/agentRuntime/resourcePolicy.js +78 -0
  38. package/src/core/agentRuntime/runtimeSupport.js +237 -0
  39. package/src/core/agentRuntime/supervisor.js +565 -0
  40. package/src/core/agentRuntime/vmEngine.js +621 -0
  41. package/src/core/codeintel/analogy.js +3 -2
  42. package/src/core/experiments/dynamicWorkflow.js +17 -2
  43. package/src/core/fileOps.js +21 -3
  44. package/src/core/memory/deltaOverlays.js +75 -30
  45. package/src/core/memory/learningCandidates.js +93 -48
  46. package/src/core/memory/memoryFlags.js +83 -0
  47. package/src/core/memory/memoryFreshness.js +190 -0
  48. package/src/core/memory/memoryHit.js +144 -0
  49. package/src/core/memory/migrate.js +69 -189
  50. package/src/core/memory/migrateMapping.js +232 -0
  51. package/src/core/memory/mutateMemory.js +323 -0
  52. package/src/core/memory/policy.js +96 -0
  53. package/src/core/memory/projectIdentity.js +266 -0
  54. package/src/core/memory/recordIndex.js +178 -0
  55. package/src/core/memory/recordStore.js +133 -20
  56. package/src/core/memory/records.js +144 -6
  57. package/src/core/memory/retrieval.js +259 -125
  58. package/src/core/memory/store.js +16 -5
  59. package/src/core/memory/storeBackup.js +226 -0
  60. package/src/core/memory/storeV2.js +63 -26
  61. package/src/core/memory/storeV2Loader.js +30 -12
  62. package/src/core/memory/userMemory.js +38 -20
  63. package/src/core/memory/writeClassification.js +161 -0
  64. package/src/core/memory/writeGuard.js +129 -0
  65. package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
  66. package/src/core/observability/analytics/cohorts.js +148 -0
  67. package/src/core/observability/analytics/storeDigest.js +163 -0
  68. package/src/core/observability/evaluation/experimentPlan.js +95 -0
  69. package/src/core/observability/evaluation/findings.js +99 -0
  70. package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
  71. package/src/core/observability/evaluation/perturbation.js +273 -0
  72. package/src/core/observability/evaluation/replay.js +7 -1
  73. package/src/core/observability/evaluation/scorecard.js +23 -3
  74. package/src/core/observability/rollout.js +11 -7
  75. package/src/core/observability/schema/compatibility.js +135 -0
  76. package/src/core/observability/schema/registry.js +99 -0
  77. package/src/core/observability/schema/validate.js +7 -0
  78. package/src/core/observability/support/import.js +53 -9
  79. package/src/core/observability/support/paths.js +13 -3
  80. package/src/core/observability/support/projector.js +148 -12
  81. package/src/core/output/index.js +12 -2
  82. package/src/core/runtimeConfig.js +83 -0
  83. package/src/core/runtimePaths.js +3 -0
  84. package/src/core/sensitiveValueScanner.js +40 -0
  85. package/src/core/token/index.js +40 -3
  86. package/src/decision/client.js +37 -13
  87. package/src/decision/protocol.js +1 -1
  88. package/src/decision/registry.js +5 -3
  89. package/src/decision/runtimeDecide.js +242 -0
  90. package/src/decision/runtimeFilter.js +150 -0
  91. package/src/decision/runtimeScheduler.js +239 -0
  92. package/src/index/buildIndex.js +13 -12
  93. package/src/index/queryIndex.js +35 -14
  94. package/src/index/relatedTests.js +50 -8
  95. package/src/index/resolveContext.js +9 -4
  96. package/src/manifest/selectItems.js +7 -3
  97. package/src/render/instructionRenderer.js +17 -5
  98. package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
  99. package/template_project/.claude/ukit/index/route-task.mjs +121 -19
  100. package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
  101. package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
  102. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
  103. package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
  104. package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
  105. package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
@@ -0,0 +1,226 @@
1
+ // storeBackup — operator lane over whole v2 store docs (SPEC §5 FR-008/FR-009).
2
+ // createBackup snapshots records.json copies + a sha256 manifest into a
3
+ // timestamped dir under <memoryRoot>/backups/ (or explicit outDir). Every
4
+ // restore write still commits through mutateMemory op 'restore', so guard
5
+ // redaction, fingerprint dedupe, and tombstone precedence are never
6
+ // duplicated here. diagnoseStore is strictly read-only — it never triggers
7
+ // the quarantine path that a mutation would.
8
+ //
9
+ // Backup copies may contain secrets → mode 0600, same boundary as the
10
+ // canonical store and quarantine copies (SPEC §14).
11
+
12
+ import crypto from 'node:crypto';
13
+ import fs from 'node:fs/promises';
14
+ import path from 'node:path';
15
+
16
+ import { ensureDir, pathExists, readJsonIfExists, writeJson } from '../fileOps.js';
17
+ import { buildRuntimePaths } from '../runtimePaths.js';
18
+ import { buildUserPaths } from '../userPaths.js';
19
+ import { mutateMemory } from './mutateMemory.js';
20
+ import { readRecordStore } from './recordStore.js';
21
+
22
+ const MANIFEST_VERSION = 1;
23
+ const COPY_MODE = 0o600;
24
+
25
+ function sha256(buf) {
26
+ return crypto.createHash('sha256').update(buf).digest('hex');
27
+ }
28
+
29
+ function resolveStores({ projectRoot, homeDir, includeUser } = {}) {
30
+ const stores = [];
31
+ if (projectRoot) {
32
+ const paths = buildRuntimePaths(projectRoot);
33
+ stores.push({
34
+ kind: 'project',
35
+ paths,
36
+ recordsPath: paths.memoryV2RecordsPath,
37
+ target: { kind: 'project', projectRoot },
38
+ });
39
+ }
40
+ if (includeUser || (homeDir && !projectRoot)) {
41
+ const paths = buildUserPaths({ homeDir });
42
+ stores.push({
43
+ kind: 'user',
44
+ paths,
45
+ recordsPath: paths.memoryV2RecordsPath,
46
+ target: { kind: 'user', homeDir },
47
+ });
48
+ }
49
+ return stores;
50
+ }
51
+
52
+ function backupFileName(kind) {
53
+ return `${kind}-records.json`;
54
+ }
55
+
56
+ function stateHint(state) {
57
+ switch (state) {
58
+ case 'missing': return 'no v2 store yet — records appear after the first memory write or migration';
59
+ case 'empty': return 'store exists but holds no usable records';
60
+ case 'corrupt': return 'records.json failed to parse — restore from a backup; a mutation will quarantine the file first';
61
+ case 'unsupported-schema': return 'written by a newer UKit build — upgrade before mutating';
62
+ default: return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * createBackup({projectRoot, homeDir?, outDir?, includeUser?}) → {backupDir, manifest}
68
+ * Copies each store's records.json byte-identical (corrupt bytes included —
69
+ * the manifest marks state:'corrupt' so restore skips it). Default location
70
+ * `<projectMemoryRoot>/backups/<ts>/`; outDir is the explicit escape.
71
+ */
72
+ export async function createBackup({ projectRoot, homeDir, outDir, includeUser } = {}) {
73
+ const stores = resolveStores({ projectRoot, homeDir, includeUser });
74
+ if (stores.length === 0) throw new Error('createBackup: projectRoot or homeDir required');
75
+
76
+ const stamp = `${new Date().toISOString().replace(/[:.]/g, '-')}-${crypto.randomBytes(3).toString('hex')}`;
77
+ const backupDir = outDir ?? path.join(stores[0].paths.memoryRoot, 'backups', stamp);
78
+ await ensureDir(backupDir);
79
+
80
+ const manifest = { version: MANIFEST_VERSION, createdAt: new Date().toISOString(), stores: [] };
81
+ for (const store of stores) {
82
+ const doc = await readRecordStore(store.recordsPath);
83
+ const entry = {
84
+ kind: store.kind,
85
+ state: doc.state,
86
+ generation: doc.generation,
87
+ recordCount: doc.records.length,
88
+ tombstoneCount: doc.tombstones.length,
89
+ sha256: null,
90
+ file: null,
91
+ };
92
+ if (doc.state !== 'missing' && await pathExists(store.recordsPath)) {
93
+ const bytes = await fs.readFile(store.recordsPath);
94
+ const dest = path.join(backupDir, backupFileName(store.kind));
95
+ await fs.writeFile(dest, bytes, { mode: COPY_MODE });
96
+ await fs.chmod(dest, COPY_MODE).catch(() => {});
97
+ entry.sha256 = sha256(bytes);
98
+ entry.file = backupFileName(store.kind);
99
+ }
100
+ manifest.stores.push(entry);
101
+ }
102
+ const manifestPath = path.join(backupDir, 'manifest.json');
103
+ await writeJson(manifestPath, manifest, { fsync: true });
104
+ await fs.chmod(manifestPath, COPY_MODE).catch(() => {});
105
+ return { backupDir, manifest };
106
+ }
107
+
108
+ /**
109
+ * restoreBackup(backupDir, {projectRoot, homeDir?, dryRun?}) →
110
+ * {restored, skipped, plan[]}
111
+ * Each store file is verified against the manifest sha256 and parsed before
112
+ * any write; a tampered/malformed backup file is a plan 'skip', never a
113
+ * partial merge. Merges go through mutateMemory op 'restore' — a corrupt
114
+ * target is quarantined inside that path (first call returns 'corrupt' after
115
+ * the quarantine copy, the retry merges onto the now-missing doc).
116
+ */
117
+ export async function restoreBackup(backupDir, { projectRoot, homeDir, dryRun } = {}) {
118
+ const plan = [];
119
+ let restored = 0;
120
+ let skipped = 0;
121
+
122
+ const manifest = await readJsonIfExists(path.join(backupDir, 'manifest.json'));
123
+ if (!manifest || !Array.isArray(manifest.stores)) {
124
+ return { restored, skipped, plan: [{ kind: 'manifest', action: 'skip', reason: 'manifest-missing-or-invalid' }] };
125
+ }
126
+
127
+ const stores = resolveStores({ projectRoot, homeDir, includeUser: true });
128
+ const byKind = new Map(stores.map((s) => [s.kind, s]));
129
+
130
+ for (const entry of manifest.stores) {
131
+ const store = byKind.get(entry.kind);
132
+ if (!store) {
133
+ skipped += 1;
134
+ plan.push({ kind: entry.kind, action: 'skip', reason: 'no-target' });
135
+ continue;
136
+ }
137
+ if (!entry.file) {
138
+ skipped += 1;
139
+ plan.push({ kind: entry.kind, action: 'skip', reason: `backup-state:${entry.state ?? 'missing'}` });
140
+ continue;
141
+ }
142
+ // Boundary (SPEC §14): a manifest file entry must resolve inside
143
+ // backupDir — a crafted '..' path is a skip, never a read outside.
144
+ const backupRoot = path.resolve(backupDir);
145
+ const filePath = path.resolve(backupRoot, String(entry.file));
146
+ if (filePath !== backupRoot && !filePath.startsWith(backupRoot + path.sep)) {
147
+ skipped += 1;
148
+ plan.push({ kind: entry.kind, action: 'skip', reason: 'backup-file-out-of-boundary' });
149
+ continue;
150
+ }
151
+ let bytes;
152
+ try {
153
+ bytes = await fs.readFile(filePath);
154
+ } catch {
155
+ skipped += 1;
156
+ plan.push({ kind: entry.kind, action: 'skip', reason: 'backup-file-missing' });
157
+ continue;
158
+ }
159
+ if (entry.sha256 && sha256(bytes) !== entry.sha256) {
160
+ skipped += 1;
161
+ plan.push({ kind: entry.kind, action: 'skip', reason: 'sha256-mismatch' });
162
+ continue;
163
+ }
164
+ let doc;
165
+ try {
166
+ doc = JSON.parse(bytes.toString('utf8'));
167
+ } catch {
168
+ doc = null;
169
+ }
170
+ if (!doc || typeof doc !== 'object' || !Array.isArray(doc.records)) {
171
+ skipped += 1;
172
+ plan.push({ kind: entry.kind, action: 'skip', reason: 'corrupt-backup' });
173
+ continue;
174
+ }
175
+ if (dryRun) {
176
+ plan.push({ kind: entry.kind, action: 'dry-run', records: doc.records.length });
177
+ continue;
178
+ }
179
+
180
+ let res = await mutateMemory(store.target, { op: 'restore', payload: { records: doc.records } });
181
+ if (res?.status === 'corrupt') {
182
+ // Quarantine already happened inside the locked RMW; retry merges onto
183
+ // the now-missing doc. Never loop — a second 'corrupt' is a real failure.
184
+ res = await mutateMemory(store.target, { op: 'restore', payload: { records: doc.records } });
185
+ }
186
+ if (res?.status === 'ok') {
187
+ restored += res.added ?? 0;
188
+ plan.push({ kind: entry.kind, action: (res.added ?? 0) > 0 ? 'restored' : 'noop', added: res.added ?? 0 });
189
+ } else {
190
+ skipped += 1;
191
+ plan.push({ kind: entry.kind, action: 'skip', reason: `restore-status:${res?.status ?? 'null'}` });
192
+ }
193
+ }
194
+ return { restored, skipped, plan };
195
+ }
196
+
197
+ /**
198
+ * diagnoseStore({projectRoot, homeDir?}) → {stores:[StoreDiag]}
199
+ * Read-only doctor: typed state + counts + quarantine listing + hint.
200
+ * Never mutates — uses readRecordStore directly, not the mutate path.
201
+ */
202
+ export async function diagnoseStore({ projectRoot, homeDir } = {}) {
203
+ const stores = resolveStores({ projectRoot, homeDir, includeUser: Boolean(homeDir) });
204
+ const out = [];
205
+ for (const store of stores) {
206
+ const doc = await readRecordStore(store.recordsPath);
207
+ const quarantineDir = path.join(path.dirname(store.recordsPath), 'quarantine');
208
+ let quarantine = [];
209
+ try {
210
+ quarantine = (await fs.readdir(quarantineDir)).map((n) => path.join(quarantineDir, n));
211
+ } catch {
212
+ quarantine = [];
213
+ }
214
+ out.push({
215
+ kind: store.kind,
216
+ state: doc.state,
217
+ generation: doc.generation,
218
+ recordCount: doc.records.length,
219
+ tombstoneCount: doc.tombstones.length,
220
+ invalidSkipped: doc.invalidSkipped,
221
+ quarantine,
222
+ hint: stateHint(doc.state),
223
+ });
224
+ }
225
+ return { stores: out };
226
+ }
@@ -5,19 +5,17 @@
5
5
  // v1→v2 migration (SPEC §4); migrate.js is lazy-imported to break the
6
6
  // storeV2↔migrate import cycle. The record-document read/write core lives in
7
7
  // recordStore.js (FR-007) so the user-level store (userMemory.js) shares it
8
- // without the migration flow; mutations go through mutateRecordStore, which
9
- // serializes read-modify-write under the shared file lock.
8
+ // without the migration flow; all public mutations route through
9
+ // mutateMemory (mutateMemory.js) — the single guarded write entry.
10
10
 
11
11
  import { buildRuntimePaths } from '../runtimePaths.js';
12
12
  import { loadRuntimeConfig } from '../runtimeConfig.js';
13
13
  import { createRecord, normalizeRecord } from './records.js';
14
14
  import {
15
15
  readRecordStore,
16
- writeRecordStore,
17
- applyRecordPatch,
18
16
  isRawRecord,
19
- mutateRecordStore,
20
17
  } from './recordStore.js';
18
+ import { mutateMemory } from './mutateMemory.js';
21
19
 
22
20
  // Per-process memoization for ensureMigrated — one in-flight migration per
23
21
  // projectRoot. The PROMISE is cached (not a done-flag): concurrent callers
@@ -31,9 +29,13 @@ const migrationPromises = new Map();
31
29
  async function ensureMigrated(projectRoot) {
32
30
  const key = String(projectRoot);
33
31
  const existing = migrationPromises.get(key);
34
- if (existing) return existing;
32
+ if (existing) {
33
+ await existing.promise;
34
+ return { inFlight: true, failed: existing.failed };
35
+ }
35
36
 
36
- const promise = (async () => {
37
+ const entry = { promise: null, failed: false };
38
+ entry.promise = (async () => {
37
39
  try {
38
40
  const config = await loadRuntimeConfig(projectRoot);
39
41
  if (config?.memoryV2?.autoMigrate === false) return;
@@ -47,22 +49,48 @@ async function ensureMigrated(projectRoot) {
47
49
  }
48
50
  } catch {
49
51
  // Lazy migration is best-effort: a failed auto-run must not break reads.
52
+ entry.failed = true;
50
53
  }
51
54
  })();
52
- migrationPromises.set(key, promise);
55
+ migrationPromises.set(key, entry);
53
56
  try {
54
- await promise;
57
+ await entry.promise;
55
58
  } finally {
56
59
  migrationPromises.delete(key);
57
60
  }
61
+ return { inFlight: false, failed: entry.failed };
62
+ }
63
+
64
+ /**
65
+ * loadRecordsDetailed(projectRoot) → { state, records }
66
+ * Typed loader states (M01-03): 'disabled' (memoryV2.enabled===false),
67
+ * 'migrating' (auto-migration in-flight or failed), otherwise the
68
+ * readRecordStore state ('ok'|'empty'|'missing'|'corrupt').
69
+ */
70
+ export async function loadRecordsDetailed(projectRoot) {
71
+ try {
72
+ const config = await loadRuntimeConfig(projectRoot);
73
+ if (config?.memoryV2?.enabled === false) {
74
+ return { state: 'disabled', records: [] };
75
+ }
76
+ } catch {
77
+ // unreadable config → default enabled, keep going
78
+ }
79
+ const migration = await ensureMigrated(projectRoot);
80
+ if (migration.failed) {
81
+ return { state: 'migrating', records: [] };
82
+ }
83
+ // inFlight callers awaited the same migration promise — it has completed,
84
+ // so fall through and read the store like a normal caller.
85
+ const { records, state, generation } = await readStore(projectRoot);
86
+ return { state, records, generation };
58
87
  }
59
88
 
60
89
  /**
61
90
  * loadRecords(projectRoot) → record[] — tolerant: invalid entries skipped.
62
91
  */
63
92
  export async function loadRecords(projectRoot) {
64
- await ensureMigrated(projectRoot);
65
- const { records } = await readStore(projectRoot);
93
+ const { records } = await loadRecordsDetailed(projectRoot);
66
94
  return records;
67
95
  }
68
96
 
@@ -72,11 +100,18 @@ async function readStore(projectRoot) {
72
100
  }
73
101
 
74
102
  /**
75
- * saveRecords(projectRoot, records) → void — atomic write; drops unrecoverable entries.
103
+ * saveRecords(projectRoot, records) → void — explicit restore path
104
+ * (mutateMemory op:'restore'); drops unrecoverable entries.
76
105
  */
77
106
  export async function saveRecords(projectRoot, records) {
78
- const paths = buildRuntimePaths(projectRoot);
79
- await writeRecordStore(paths.memoryV2RecordsPath, records);
107
+ await ensureMigrated(projectRoot);
108
+ const res = await mutateMemory(
109
+ { kind: 'project', projectRoot },
110
+ { op: 'restore', payload: { records } },
111
+ );
112
+ if (res.status === 'rejected' || res.status === 'conflict' || res.status === 'corrupt') {
113
+ throw new Error(`saveRecords: restore ${res.status} — ${res.reason ?? 'no detail'}`);
114
+ }
80
115
  }
81
116
 
82
117
  /**
@@ -93,12 +128,13 @@ export async function addRecord(projectRoot, recordInput) {
93
128
  throw new Error('addRecord: input could not be normalized into a valid record');
94
129
  }
95
130
  await ensureMigrated(projectRoot);
96
- const paths = buildRuntimePaths(projectRoot);
97
- const stored = await mutateRecordStore(paths.memoryV2RecordsPath, (records) => ({
98
- result: record,
99
- records: [...records, record],
100
- }));
101
- return stored ?? record;
131
+ const res = await mutateMemory(
132
+ { kind: 'project', projectRoot },
133
+ { op: 'add', payload: record },
134
+ );
135
+ if (res.status === 'ok' || res.status === 'duplicate') return res.record ?? record;
136
+ if (res.status === 'disabled') return record;
137
+ throw new Error(`addRecord: ${res.status} — ${res.reason ?? 'no detail'}`);
102
138
  }
103
139
 
104
140
  /**
@@ -106,12 +142,13 @@ export async function addRecord(projectRoot, recordInput) {
106
142
  */
107
143
  export async function updateRecord(projectRoot, id, patch = {}) {
108
144
  await ensureMigrated(projectRoot);
109
- const paths = buildRuntimePaths(projectRoot);
110
- return mutateRecordStore(paths.memoryV2RecordsPath, (records) => {
111
- const result = applyRecordPatch(records, id, patch);
112
- if (!result) return null;
113
- return { result: result.record, records: result.records };
114
- });
145
+ const res = await mutateMemory(
146
+ { kind: 'project', projectRoot },
147
+ { op: 'update', payload: { id, patch } },
148
+ );
149
+ if (res.status === 'ok') return res.record;
150
+ if (res.status === 'not-found' || res.status === 'disabled') return null;
151
+ throw new Error(`updateRecord: ${res.status} — ${res.reason ?? 'no detail'}`);
115
152
  }
116
153
 
117
154
  /**
@@ -1,22 +1,40 @@
1
1
  // Lazy/defensive bridge to storeV2.js (owned by TASK-002).
2
- // Real storeV2.queryRecords performs ensureMigrated auto-migration internally —
3
- // consumers must never call migration themselves; just call this and treat an
4
- // empty result as "fall back to the legacy memory path".
2
+ // Real storeV2 performs ensureMigrated auto-migration internally —
3
+ // consumers must never call migration themselves; just call this and treat a
4
+ // non-'ok' state as "fall back to the legacy memory path".
5
+ import { resolveMemoryStage } from './memoryFlags.js';
5
6
 
6
7
  /**
7
- * loadV2Records(projectRoot) → record[] | []
8
- * Returns [] when storeV2 is unavailable, disabled upstream, or the v2 store
9
- * holds no records yet. Callers decide fallback.
8
+ * loadV2Records(projectRoot, filter, { config, projectId } = {}) → { state, records }
9
+ * Typed loader states (M01-03): 'ok'|'empty'|'missing'|'corrupt'|'disabled'|
10
+ * 'migrating'. Dynamic-import failure or a missing storeV2 API → 'missing'.
11
+ * Callers decide fallback on non-'ok' states.
12
+ *
13
+ * M06 rollout flag (SPEC §5 FR-003): when a config is supplied, the
14
+ * eligibility stage gates the v2 lane — 'off' (incl. killSwitch or an
15
+ * unlisted canary project) reports 'missing' so callers take the legacy
16
+ * lane exactly as if the v2 store were absent.
10
17
  */
11
- export async function loadV2Records(projectRoot, filter = {}) {
18
+ export async function loadV2Records(projectRoot, filter = {}, { config, projectId } = {}) {
19
+ if (config && resolveMemoryStage(config, 'eligibility', { projectId }) === 'off') {
20
+ return { state: 'missing', records: [] };
21
+ }
12
22
  try {
13
23
  const storeV2 = await import('./storeV2.js');
14
- if (typeof storeV2?.queryRecords !== 'function') {
15
- return [];
24
+ if (typeof storeV2?.queryRecords !== 'function'
25
+ || typeof storeV2?.loadRecordsDetailed !== 'function') {
26
+ return { state: 'missing', records: [] };
16
27
  }
17
- const records = await storeV2.queryRecords(projectRoot, filter);
18
- return Array.isArray(records) ? records : [];
28
+ const { state, records, generation } = await storeV2.loadRecordsDetailed(projectRoot);
29
+ const { type, scope, status, projectId } = filter ?? {};
30
+ const filtered = (Array.isArray(records) ? records : []).filter((r) => (
31
+ (type == null || r.type === type)
32
+ && (scope == null || r.scope === scope)
33
+ && (status == null || r.status === status)
34
+ && (projectId == null || r.project_id === projectId)
35
+ ));
36
+ return { state, records: filtered, generation: generation ?? 0 };
19
37
  } catch {
20
- return [];
38
+ return { state: 'missing', records: [] };
21
39
  }
22
40
  }
@@ -8,11 +8,9 @@ import { buildUserPaths } from '../userPaths.js';
8
8
  import { createRecord, normalizeRecord } from './records.js';
9
9
  import {
10
10
  readRecordStore,
11
- writeRecordStore,
12
- applyRecordPatch,
13
11
  isRawRecord,
14
- mutateRecordStore,
15
12
  } from './recordStore.js';
13
+ import { mutateMemory } from './mutateMemory.js';
16
14
  import { loadRecords } from './storeV2.js';
17
15
 
18
16
  function userRecordsPath(homeDir) {
@@ -27,11 +25,27 @@ export async function loadUserRecords({ homeDir } = {}) {
27
25
  return records;
28
26
  }
29
27
 
28
+ /**
29
+ * loadUserRecordsDetailed({homeDir}={}) → { state, records }
30
+ * Typed loader states (M01-03) via readRecordStore: 'ok'|'empty'|'missing'|
31
+ * 'corrupt'. No 'disabled'/'migrating' — the user layer has no v1 migration.
32
+ */
33
+ export async function loadUserRecordsDetailed({ homeDir } = {}) {
34
+ const { records, state } = await readRecordStore(userRecordsPath(homeDir));
35
+ return { state, records };
36
+ }
37
+
30
38
  /**
31
39
  * saveUserRecords(records, {homeDir}={}) → void — atomic write.
32
40
  */
33
41
  export async function saveUserRecords(records, { homeDir } = {}) {
34
- await writeRecordStore(userRecordsPath(homeDir), records);
42
+ const res = await mutateMemory(
43
+ { kind: 'user', homeDir },
44
+ { op: 'restore', payload: { records } },
45
+ );
46
+ if (res.status === 'rejected' || res.status === 'conflict' || res.status === 'corrupt') {
47
+ throw new Error(`saveUserRecords: restore ${res.status} — ${res.reason ?? 'no detail'}`);
48
+ }
35
49
  }
36
50
 
37
51
  /**
@@ -45,22 +59,25 @@ export async function addUserRecord(recordInput, { homeDir } = {}) {
45
59
  if (!record) {
46
60
  throw new Error('addUserRecord: input could not be normalized into a valid record');
47
61
  }
48
- const stored = await mutateRecordStore(userRecordsPath(homeDir), (records) => ({
49
- result: record,
50
- records: [...records, record],
51
- }));
52
- return stored ?? record;
62
+ const res = await mutateMemory(
63
+ { kind: 'user', homeDir },
64
+ { op: 'add', payload: record },
65
+ );
66
+ if (res.status === 'ok' || res.status === 'duplicate') return res.record ?? record;
67
+ throw new Error(`addUserRecord: ${res.status} — ${res.reason ?? 'no detail'}`);
53
68
  }
54
69
 
55
70
  /**
56
71
  * updateUserRecord(id, patch, {homeDir}={}) → record | null
57
72
  */
58
73
  export async function updateUserRecord(id, patch = {}, { homeDir } = {}) {
59
- return mutateRecordStore(userRecordsPath(homeDir), (records) => {
60
- const result = applyRecordPatch(records, id, patch);
61
- if (!result) return null;
62
- return { result: result.record, records: result.records };
63
- });
74
+ const res = await mutateMemory(
75
+ { kind: 'user', homeDir },
76
+ { op: 'update', payload: { id, patch } },
77
+ );
78
+ if (res.status === 'ok') return res.record;
79
+ if (res.status === 'not-found') return null;
80
+ throw new Error(`updateUserRecord: ${res.status} — ${res.reason ?? 'no detail'}`);
64
81
  }
65
82
 
66
83
  /**
@@ -70,12 +87,13 @@ export async function updateUserRecord(id, patch = {}, { homeDir } = {}) {
70
87
  * or null when the id is absent.
71
88
  */
72
89
  export async function removeUserRecord(id, { homeDir } = {}) {
73
- return mutateRecordStore(userRecordsPath(homeDir), (records) => {
74
- const index = records.findIndex((r) => r.id === id);
75
- if (index === -1) return null;
76
- const removed = records[index];
77
- return { result: removed, records: records.filter((r) => r.id !== id) };
78
- });
90
+ const res = await mutateMemory(
91
+ { kind: 'user', homeDir },
92
+ { op: 'purge', payload: { id }, confirmScope: 'user' },
93
+ );
94
+ if (res.status === 'ok') return res.record;
95
+ if (res.status === 'not-found') return null;
96
+ throw new Error(`removeUserRecord: ${res.status} — ${res.reason ?? 'no detail'}`);
79
97
  }
80
98
 
81
99
  /**