@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,286 @@
1
+ // Memory eligibility policy — shipped mirror of src/core/memory/policy.js
2
+ // (M01-04). Self-contained: no src/ imports, no UKit runtime deps, so
3
+ // route-task/reinject hooks enforce the identical policy version.
4
+ // READ-ONLY by contract: resolveProjectIdentityMirror NEVER mints and NEVER
5
+ // writes the registry — minting stays host-side in src/core/memory/
6
+ // projectIdentity.js.
7
+ //
8
+ // Parity is enforced by tests/consistency/memoryPolicyParity.test.js — keep
9
+ // POLICY_VERSION and every verdict identical to src/core/memory/policy.js.
10
+
11
+ import fs from 'node:fs/promises';
12
+ import os from 'node:os';
13
+ import path from 'node:path';
14
+
15
+ export const POLICY_VERSION = 'w1-2';
16
+
17
+ const REPO_BOUND_SCOPES = Object.freeze(['repo', 'project', 'task', 'session']);
18
+ const UNVERIFIED_PROVENANCE = Object.freeze([
19
+ 'learning-observation',
20
+ 'learning-candidate',
21
+ 'delta-overlay',
22
+ ]);
23
+ // Trust tiers that must never surface as a current authoritative fact
24
+ // (TASK-005 / FR-010): candidates and observations are unreviewed input —
25
+ // returning them as eligible is a false-authority breach. 'verified' and
26
+ // 'legacy-unverified' (migrated records) remain admissible.
27
+ const NON_AUTHORITY_TRUST_TIERS = Object.freeze(['candidate', 'observation']);
28
+ const REQUESTED_SCOPES = Object.freeze(['repo', 'user', 'session', 'task', 'all']);
29
+
30
+ function deny(reason) {
31
+ return { ok: false, reason };
32
+ }
33
+
34
+ // hostContext = {projectId: string|null, ambiguous?: boolean, sessionId?, taskId?, branch?}
35
+ // requestedScope ∈ 'repo'|'user'|'session'|'task'|'all' (default 'all').
36
+ // → {scope, projectId, includeUser, denied}
37
+ export function resolveEffectiveScope(hostContext = {}, requestedScope = 'all') {
38
+ const scope = REQUESTED_SCOPES.includes(requestedScope) ? requestedScope : 'all';
39
+ const projectId = typeof hostContext.projectId === 'string' ? hostContext.projectId : null;
40
+ const ambiguous = hostContext.ambiguous === true;
41
+
42
+ if (scope === 'user') {
43
+ return { scope, projectId: null, includeUser: true, denied: false };
44
+ }
45
+
46
+ if (scope === 'all') {
47
+ // 'all' keeps projectId but does not opt into user records
48
+ // (user-repo-precedence: user records need explicit opt-in).
49
+ return { scope, projectId, includeUser: false, denied: false };
50
+ }
51
+
52
+ // repo / task / session require a non-null, non-ambiguous project binding.
53
+ if (projectId == null || ambiguous) {
54
+ return { scope, projectId, includeUser: false, denied: true };
55
+ }
56
+ return { scope, projectId, includeUser: false, denied: false };
57
+ }
58
+
59
+ // record per src/core/memory/records.js
60
+ // (scope, status, project_id, valid_until, provenance, type).
61
+ // context = {projectId, projectAliases?: string[], includeUser, now?}
62
+ // policy = {legacyNullProjectId: 'deny'|'compat'} — default 'deny'.
63
+ // → {ok: boolean, reason: string}
64
+ export function eligible(record, context = {}, policy = {}) {
65
+ const now = typeof context.now === 'number' ? context.now : Date.now();
66
+ const legacyNull = policy.legacyNullProjectId === 'compat' ? 'compat' : 'deny';
67
+
68
+ if (record == null || typeof record !== 'object') {
69
+ return deny('status');
70
+ }
71
+ if (record.status !== 'active') {
72
+ return deny('status');
73
+ }
74
+ if (record.valid_until != null && record.valid_until <= now) {
75
+ return deny('expired');
76
+ }
77
+ if (UNVERIFIED_PROVENANCE.includes(record.provenance)) {
78
+ return deny('unverified-provenance');
79
+ }
80
+ if (NON_AUTHORITY_TRUST_TIERS.includes(record.trust_tier)) {
81
+ return deny('non-authority-trust-tier');
82
+ }
83
+
84
+ if (record.scope === 'user') {
85
+ if (context.includeUser !== true) {
86
+ return deny('user-scope-not-included');
87
+ }
88
+ return { ok: true, reason: 'ok' };
89
+ }
90
+
91
+ if (REPO_BOUND_SCOPES.includes(record.scope)) {
92
+ if (context.projectId == null) {
93
+ return deny('no-project-binding');
94
+ }
95
+ if (record.project_id != null && record.project_id !== context.projectId
96
+ && !(Array.isArray(context.projectAliases)
97
+ && context.projectAliases.includes(record.project_id))) {
98
+ return deny('project-mismatch');
99
+ }
100
+ if (record.project_id == null && legacyNull !== 'compat') {
101
+ return deny('null-project-id');
102
+ }
103
+ }
104
+
105
+ return { ok: true, reason: 'ok' };
106
+ }
107
+
108
+ // ---- Read-only project identity mirror (SPEC §5 FR-001..003) ----
109
+ // Same resolution order as src/core/memory/projectIdentity.js minus minting:
110
+ // (1) canonical realpath in registry roots; (2) git common-dir binding;
111
+ // (3) alias index — multiple hits → ambiguous deny; otherwise unverified.
112
+
113
+ function registryPathFor(homeDir) {
114
+ const home = typeof homeDir === 'string' && homeDir ? homeDir : os.homedir();
115
+ return path.join(home, '.ukit', 'storage', 'projects', 'registry.json');
116
+ }
117
+
118
+ async function readJsonIfExists(filePath) {
119
+ try {
120
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
121
+ } catch (error) {
122
+ if (error?.code === 'ENOENT') return null;
123
+ throw error;
124
+ }
125
+ }
126
+
127
+ // Tolerant registry read. Missing → empty registry. Corrupt → null sentinel
128
+ // (caller degrades to `unverified`; the file is NEVER overwritten on read).
129
+ async function readRegistry(regPath) {
130
+ let data;
131
+ try {
132
+ data = await readJsonIfExists(regPath);
133
+ } catch {
134
+ return null; // corrupt JSON — degrade, never throw, never rewrite
135
+ }
136
+ if (data === null) return { schemaVersion: 1, projects: {}, aliasIndex: {} };
137
+ if (typeof data !== 'object' || typeof data.projects !== 'object' || data.projects === null) {
138
+ return null;
139
+ }
140
+ if (typeof data.aliasIndex !== 'object' || data.aliasIndex === null) {
141
+ data.aliasIndex = {};
142
+ }
143
+ return data;
144
+ }
145
+
146
+ async function realpathOrNull(target) {
147
+ try {
148
+ return await fs.realpath(target);
149
+ } catch {
150
+ return null;
151
+ }
152
+ }
153
+
154
+ // `.git` dir → itself; `.git` file (`gitdir: <p>`) → `<p>/commondir` (linked
155
+ // worktrees share the main common dir). Null when there is no git binding.
156
+ async function resolveGitCommonDir(projectRoot) {
157
+ const gitPath = path.join(projectRoot, '.git');
158
+ let stat;
159
+ try {
160
+ stat = await fs.stat(gitPath);
161
+ } catch {
162
+ return null;
163
+ }
164
+
165
+ if (stat.isDirectory()) {
166
+ return await realpathOrNull(gitPath);
167
+ }
168
+ if (!stat.isFile()) return null;
169
+
170
+ let content;
171
+ try {
172
+ content = await fs.readFile(gitPath, 'utf8');
173
+ } catch {
174
+ return null;
175
+ }
176
+ const match = content.match(/^gitdir:\s*(.+)$/m);
177
+ if (!match) return null;
178
+ const gitDir = path.resolve(projectRoot, match[1].trim());
179
+
180
+ let commonDirRel;
181
+ try {
182
+ commonDirRel = (await fs.readFile(path.join(gitDir, 'commondir'), 'utf8')).trim();
183
+ } catch {
184
+ commonDirRel = '';
185
+ }
186
+ const commonDir = commonDirRel ? path.resolve(gitDir, commonDirRel) : gitDir;
187
+ return await realpathOrNull(commonDir);
188
+ }
189
+
190
+ async function aliasesFor(projectRoot, realRoot) {
191
+ const names = new Set();
192
+ const base = path.basename(realRoot ?? projectRoot);
193
+ if (base) names.add(base);
194
+ const pkg = await readJsonIfExists(path.join(projectRoot, 'package.json')).catch(() => null);
195
+ if (typeof pkg?.name === 'string' && pkg.name.trim() !== '') {
196
+ names.add(pkg.name.trim());
197
+ }
198
+ return [...names];
199
+ }
200
+
201
+ function findByRoot(registry, realRoot) {
202
+ for (const [projectId, entry] of Object.entries(registry.projects)) {
203
+ if (Array.isArray(entry?.roots) && entry.roots.includes(realRoot)) {
204
+ return projectId;
205
+ }
206
+ }
207
+ return null;
208
+ }
209
+
210
+ function findByGitCommonDir(registry, commonDir) {
211
+ if (!commonDir) return null;
212
+ for (const [projectId, entry] of Object.entries(registry.projects)) {
213
+ if (Array.isArray(entry?.gitCommonDirs) && entry.gitCommonDirs.includes(commonDir)) {
214
+ return projectId;
215
+ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ function findByAlias(registry, aliases) {
221
+ const hits = new Set();
222
+ for (const name of aliases) {
223
+ const entry = registry.aliasIndex?.[name];
224
+ if (entry === 'ambiguous') {
225
+ hits.add('ambiguous');
226
+ continue;
227
+ }
228
+ if (Array.isArray(entry)) {
229
+ for (const id of entry) hits.add(id);
230
+ } else if (typeof entry === 'string' && entry) {
231
+ hits.add(entry);
232
+ }
233
+ }
234
+ return hits;
235
+ }
236
+
237
+ /**
238
+ * Read-only mirror of resolveProjectIdentity (mint:false path only).
239
+ * Corrupt/missing registry → {projectId:null, source:'unverified'}.
240
+ * NEVER mints, NEVER writes.
241
+ *
242
+ * @returns {Promise<{projectId: string|null, source: 'registry'|'git-common-dir'|'alias'|'unverified', ambiguous: boolean}>}
243
+ */
244
+ export async function resolveProjectIdentityMirror(projectRoot, { homeDir } = {}) {
245
+ const unverified = { projectId: null, source: 'unverified', ambiguous: false };
246
+ const regPath = registryPathFor(homeDir);
247
+
248
+ const registry = await readRegistry(regPath);
249
+ if (registry === null) return unverified; // corrupt — never overwrite on read
250
+
251
+ const realRoot = (await realpathOrNull(projectRoot)) ?? path.resolve(projectRoot);
252
+ const byRoot = findByRoot(registry, realRoot);
253
+ if (byRoot) {
254
+ return {
255
+ projectId: byRoot,
256
+ source: 'registry',
257
+ ambiguous: false,
258
+ aliases: Array.isArray(registry.projects[byRoot]?.aliases)
259
+ ? registry.projects[byRoot].aliases.filter((a) => typeof a === 'string')
260
+ : [],
261
+ };
262
+ }
263
+
264
+ const commonDir = await resolveGitCommonDir(projectRoot);
265
+ const byGit = findByGitCommonDir(registry, commonDir);
266
+ if (byGit) {
267
+ return {
268
+ projectId: byGit,
269
+ source: 'git-common-dir',
270
+ ambiguous: false,
271
+ aliases: Array.isArray(registry.projects[byGit]?.aliases)
272
+ ? registry.projects[byGit].aliases.filter((a) => typeof a === 'string')
273
+ : [],
274
+ };
275
+ }
276
+
277
+ const aliases = await aliasesFor(projectRoot, realRoot);
278
+ // Alias is a weak display-name signal only: any hit for a different root
279
+ // denies (ambiguous) rather than letting a same-named repo inherit an id.
280
+ const aliasHits = findByAlias(registry, aliases);
281
+ if (aliasHits.size > 0) {
282
+ return { projectId: null, source: 'alias', ambiguous: true };
283
+ }
284
+
285
+ return unverified;
286
+ }
@@ -372,6 +372,9 @@ function buildRawOutputText({ command = '', stdout = '', stderr = '', exitCode =
372
372
  }
373
373
 
374
374
  function buildRecoveryHintSummary(summary, rawPath, { tokensBefore = 0 } = {}) {
375
+ // TASK-003 fix round 1: a failed tee persist returns rawPath='' — never emit a
376
+ // dangling `- Full output: ` hint that points at nothing.
377
+ if (!rawPath) return null;
375
378
  const recoveryLine = `- Full output: ${rawPath}`;
376
379
  const summaryLines = String(summary ?? '')
377
380
  .split(/\r?\n/)
@@ -24,6 +24,12 @@ import {
24
24
  readResumableRun,
25
25
  resumableRunSourceFingerprint,
26
26
  } from './resumable-run.mjs';
27
+ import {
28
+ eligible as memoryRecordEligible,
29
+ resolveProjectIdentityMirror,
30
+ } from './memory-policy.mjs';
31
+ import { resolveRecordFreshness } from './memory-freshness.mjs';
32
+ import { resolveMemoryStage } from './memory-flags.mjs';
27
33
 
28
34
  // Hook-context self-deadline (2.4.1 orphan-leak class): the reinject-context hook passes
29
35
  // UKIT_HOOK_DEADLINE_MS so a wedged read can never orphan this process past the hook
@@ -320,6 +326,13 @@ function defaultRuntimeConfig() {
320
326
  progressiveRetrieval: true,
321
327
  maxInjectionTokens: 320,
322
328
  },
329
+ // M06 rollout defaults (FR-001): the shipped default keeps the v2 read
330
+ // lane live; absent user config must not resolve 'off'.
331
+ memoryV2: {
332
+ eligibility: { stage: 'default' },
333
+ killSwitch: false,
334
+ canaryProjects: [],
335
+ },
323
336
  };
324
337
  }
325
338
 
@@ -913,6 +926,13 @@ function getMemoryTimestamp(item) {
913
926
 
914
927
  function buildMemorySegments(item) {
915
928
  const content = item.content ?? {};
929
+ if (item.type === 'record') {
930
+ return [
931
+ { text: content.recordType, weight: 1 },
932
+ { text: content.text, weight: 3 },
933
+ ];
934
+ }
935
+
916
936
  if (item.type === 'project') {
917
937
  return [
918
938
  { text: content.name, weight: 2 },
@@ -991,21 +1011,95 @@ async function readDirectoryJsonItems(dirPath) {
991
1011
  return items.sort((left, right) => left.fileName.localeCompare(right.fileName));
992
1012
  }
993
1013
 
994
- async function detectProjectId(projectRoot) {
1014
+ // M01-05: project identity is owned by memory-policy.mjs (read-only mirror —
1015
+ // never mints, never writes). Legacy project/session items are keyed by the
1016
+ // display name, so binding also accepts the alias names (pkg name, basename).
1017
+ async function resolveProjectBinding(projectRoot) {
1018
+ const identity = await resolveProjectIdentityMirror(projectRoot).catch(() => null);
1019
+ const names = new Set();
1020
+ const base = path.basename(projectRoot);
1021
+ if (base) names.add(base);
995
1022
  const pkg = await readJson(path.join(projectRoot, 'package.json'), null);
996
1023
  if (typeof pkg?.name === 'string' && pkg.name.trim()) {
997
- return pkg.name.trim();
1024
+ names.add(pkg.name.trim());
1025
+ }
1026
+ return {
1027
+ projectId: identity?.projectId ?? null,
1028
+ ambiguous: identity?.ambiguous === true,
1029
+ // Registry-attested aliases only (never raw pkg name) — migrated v1
1030
+ // records carry the legacy name as project_id.
1031
+ aliases: Array.isArray(identity?.aliases) ? identity.aliases : [],
1032
+ names,
1033
+ };
1034
+ }
1035
+
1036
+ function matchesProjectBinding(item, binding) {
1037
+ const boundId = item.type === 'project' ? item.content?.id : item.content?.projectId;
1038
+ if (boundId == null) return false;
1039
+ if (binding.projectId != null && boundId === binding.projectId) return true;
1040
+ return binding.names.has(boundId);
1041
+ }
1042
+
1043
+ // v2/legacy dedup (M01-05): meta.legacyId → session:<id>; project_id →
1044
+ // project:<id>. The legacy item wins — richer structured summary.
1045
+ function recordLegacyKey(item) {
1046
+ const record = item.record;
1047
+ if (!record) return null;
1048
+ const legacyId = record.meta?.legacyId;
1049
+ if (typeof legacyId === 'string' && legacyId) {
1050
+ return `session:${legacyId}`;
998
1051
  }
1052
+ if (typeof record.project_id === 'string' && record.project_id) {
1053
+ return `project:${record.project_id}`;
1054
+ }
1055
+ return null;
1056
+ }
999
1057
 
1000
- return path.basename(projectRoot);
1058
+ const MEMORY_V2_RECORD_POOL_LIMIT = 20;
1059
+
1060
+ // v2 lane (M01-05): approved records live in a single records.json document.
1061
+ // Missing/malformed store → [] (never-throw, same as readDirectoryJsonItems).
1062
+ // Stale snapshots excluded here AND re-checked by eligible() downstream.
1063
+ // M06 rollout flag (FR-005): eligibility stage 'off' (incl. killSwitch or an
1064
+ // unlisted canary project) suppresses v2 record items entirely — identical
1065
+ // verdict to src/core/memory/memoryFlags.js via memory-flags.mjs.
1066
+ // Accepts either a runtimePaths object or a projectRoot string (tests).
1067
+ export async function listMemoryV2RecordItems(runtimePathsOrRoot, { config, projectId } = {}) {
1068
+ const memoryRoot = typeof runtimePathsOrRoot === 'string'
1069
+ ? path.join(runtimePathsOrRoot, '.ukit', 'storage', 'memory')
1070
+ : runtimePathsOrRoot.memoryRoot;
1071
+ if (resolveMemoryStage(config, 'eligibility', { projectId }) === 'off') {
1072
+ return [];
1073
+ }
1074
+ const doc = await readJson(path.join(memoryRoot, 'v2', 'records.json'), null);
1075
+ const records = Array.isArray(doc?.records) ? doc.records : [];
1076
+ const now = Date.now();
1077
+
1078
+ return records
1079
+ .filter((record) => record && typeof record === 'object')
1080
+ .filter((record) => record.status === 'active')
1081
+ .filter((record) => record.valid_until == null || record.valid_until > now)
1082
+ .sort((left, right) => (right.created_at ?? 0) - (left.created_at ?? 0))
1083
+ .slice(0, MEMORY_V2_RECORD_POOL_LIMIT)
1084
+ .map((record) => ({
1085
+ id: `record:${record.id}`,
1086
+ type: 'record',
1087
+ record,
1088
+ content: {
1089
+ recordType: record.type,
1090
+ text: record.text,
1091
+ projectId: record.project_id,
1092
+ updatedAt: record.created_at,
1093
+ },
1094
+ }));
1001
1095
  }
1002
1096
 
1003
- async function listMemoryItems(projectRoot) {
1097
+ async function listMemoryItems(projectRoot, { config, projectId } = {}) {
1004
1098
  const runtimePaths = buildRuntimePaths(projectRoot);
1005
1099
  const userMemory = (await readJson(runtimePaths.userMemoryPath, null)) ?? { preferences: {}, rules: [] };
1006
1100
  const projectMemories = await readDirectoryJsonItems(runtimePaths.projectsDir);
1007
1101
  const sessionMemories = await readDirectoryJsonItems(runtimePaths.sessionsDir);
1008
-
1102
+ const recordMemories = await listMemoryV2RecordItems(runtimePaths, { config, projectId });
1009
1103
  return [
1010
1104
  {
1011
1105
  id: 'user:user',
@@ -1022,11 +1116,27 @@ async function listMemoryItems(projectRoot) {
1022
1116
  type: 'session',
1023
1117
  content: item.content,
1024
1118
  })),
1119
+ ...recordMemories,
1025
1120
  ];
1026
1121
  }
1027
1122
 
1028
- function buildPreviousContextSnippet(item) {
1123
+ // M05-01 (FR-008): record snippets carry a freshness label — ' [stale]' or
1124
+ // ' [unknown]' — resolved via memory-freshness.mjs for the ≤2 rendered items
1125
+ // only. `unknown` is rendered only when the record carries evidence that
1126
+ // failed verification; no-evidence records stay unlabeled (token cost).
1127
+ function buildPreviousContextSnippet(item, freshness = null) {
1029
1128
  const content = item.content ?? {};
1129
+ if (item.type === 'record') {
1130
+ const text = String(content.text ?? '').trim();
1131
+ const truncated = text.length > 120 ? `${text.slice(0, 117)}...` : text;
1132
+ const label = freshness?.state === 'stale'
1133
+ ? ' [stale]'
1134
+ : freshness?.state === 'unknown' && Array.isArray(item.record?.evidence) && item.record.evidence.length > 0
1135
+ ? ' [unknown]'
1136
+ : '';
1137
+ return `[${content.recordType ?? 'record'}] ${truncated}${label}`;
1138
+ }
1139
+
1030
1140
  if (item.type === 'project') {
1031
1141
  const decisions = compactPhraseList((content.decisions ?? []).map((decision) => decision.what), { limit: 1 });
1032
1142
  const rules = compactPhraseList(content.activeRules ?? [], { limit: 1 });
@@ -1089,16 +1199,28 @@ async function buildPreviousContextLines(projectRoot, state, config) {
1089
1199
  return [`- Previous context: ${sharedPreviousContextLine}`];
1090
1200
  }
1091
1201
 
1092
- const projectId = await detectProjectId(projectRoot);
1093
- const items = await listMemoryItems(projectRoot);
1202
+ const binding = await resolveProjectBinding(projectRoot);
1203
+ const items = await listMemoryItems(projectRoot, { config, projectId: binding.projectId });
1094
1204
  const queryTokens = tokenize(taskQuery);
1205
+ // v2/legacy dedup (M01-05): drop a record whose logical twin
1206
+ // (meta.legacyId → session:<id>, project_id → project:<id>) exists.
1207
+ const legacyIds = new Set(
1208
+ items.filter((item) => item.type !== 'record').map((item) => item.id),
1209
+ );
1210
+ const eligibleCtx = { projectId: binding.projectId, projectAliases: binding.aliases, includeUser: false };
1095
1211
  const rankedItems = items
1096
1212
  .filter((item) => item.type !== 'user')
1097
- .filter((item) => (
1098
- item.type === 'project'
1099
- ? (item.content?.id === projectId)
1100
- : (item.type === 'session' ? item.content?.projectId === projectId : true)
1101
- ))
1213
+ .filter((item) => {
1214
+ if (item.type === 'record') {
1215
+ if (!memoryRecordEligible(item.record, eligibleCtx).ok) return false;
1216
+ const legacyKey = recordLegacyKey(item);
1217
+ return !(legacyKey && legacyIds.has(legacyKey));
1218
+ }
1219
+ if (item.type === 'project' || item.type === 'session') {
1220
+ return matchesProjectBinding(item, binding);
1221
+ }
1222
+ return true;
1223
+ })
1102
1224
  .map((item) => ({ item, score: scoreMemoryItem(item, queryTokens) }))
1103
1225
  .filter((entry) => entry.score > 0)
1104
1226
  .sort((left, right) => (
@@ -1111,8 +1233,17 @@ async function buildPreviousContextLines(projectRoot, state, config) {
1111
1233
  if (rankedItems.length === 0) {
1112
1234
  return [];
1113
1235
  }
1236
+ // Freshness is resolved post-limit only — the ≤2 rendered items, never the
1237
+ // pool. Resolver errors degrade to `unknown` and never break the hook.
1238
+ const freshnessById = new Map();
1239
+ await Promise.all(rankedItems.map(async (item) => {
1240
+ if (item.type !== 'record') return;
1241
+ try {
1242
+ freshnessById.set(item.id, await resolveRecordFreshness(item.record, { projectRoot }));
1243
+ } catch { /* absent → unlabeled */ }
1244
+ }));
1114
1245
 
1115
- return [`- Previous context: ${rankedItems.map((item) => buildPreviousContextSnippet(item)).join(' | ')}`];
1246
+ return [`- Previous context: ${rankedItems.map((item) => buildPreviousContextSnippet(item, freshnessById.get(item.id))).join(' | ')}`];
1116
1247
  }
1117
1248
 
1118
1249
  // BUG-C23-13: only run main() on direct CLI invocation — an unguarded bottom