@ngockhoale/ukit 3.0.7 → 3.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/CHANGELOG.md +18 -1
  2. package/manifests/documentation.yaml +11 -0
  3. package/package.json +1 -1
  4. package/scripts/audit/decision-coverage.mjs +29 -2
  5. package/scripts/bench/data-foundation.mjs +52 -3
  6. package/scripts/bench/decision-runtime-baseline.mjs +427 -0
  7. package/scripts/bench/decision-runtime-metrics.mjs +67 -0
  8. package/scripts/bench/decision-runtime-variant.mjs +626 -0
  9. package/scripts/bench/memory-ablation.mjs +495 -0
  10. package/scripts/bench/memory-baseline.mjs +596 -0
  11. package/scripts/bench/memory-bench.mjs +661 -0
  12. package/scripts/bench/memory-canary.mjs +321 -0
  13. package/scripts/bench/memory-corpus.mjs +354 -0
  14. package/scripts/bench/memory-gate.mjs +389 -0
  15. package/scripts/bench/memory-metrics.mjs +179 -0
  16. package/scripts/bench/parallel-agents.mjs +33 -11
  17. package/scripts/bench/recorder-overhead.mjs +204 -0
  18. package/scripts/bench/sqlite-spike.mjs +451 -0
  19. package/scripts/measure-decision-gateway.mjs +306 -0
  20. package/scripts/perf/audit-perf.mjs +35 -17
  21. package/src/bug/triageBug.js +4 -3
  22. package/src/cli/commands/memory.js +357 -63
  23. package/src/context/detectProjectContext.js +11 -1
  24. package/src/core/agentRuntime/adapters.js +254 -0
  25. package/src/core/agentRuntime/artifacts.js +192 -0
  26. package/src/core/agentRuntime/completionGate.js +176 -0
  27. package/src/core/agentRuntime/context.js +149 -0
  28. package/src/core/agentRuntime/contract.js +247 -0
  29. package/src/core/agentRuntime/diagnostics.js +244 -0
  30. package/src/core/agentRuntime/evaluation.js +163 -0
  31. package/src/core/agentRuntime/eventStore.js +404 -0
  32. package/src/core/agentRuntime/liveness.js +60 -0
  33. package/src/core/agentRuntime/planCompiler.js +322 -0
  34. package/src/core/agentRuntime/promotion.js +53 -0
  35. package/src/core/agentRuntime/qualityComparison.js +112 -0
  36. package/src/core/agentRuntime/recovery.js +266 -0
  37. package/src/core/agentRuntime/resourcePolicy.js +78 -0
  38. package/src/core/agentRuntime/runtimeSupport.js +237 -0
  39. package/src/core/agentRuntime/supervisor.js +565 -0
  40. package/src/core/agentRuntime/vmEngine.js +621 -0
  41. package/src/core/codeintel/analogy.js +3 -2
  42. package/src/core/experiments/dynamicWorkflow.js +17 -2
  43. package/src/core/fileOps.js +21 -3
  44. package/src/core/memory/deltaOverlays.js +75 -30
  45. package/src/core/memory/learningCandidates.js +93 -48
  46. package/src/core/memory/memoryFlags.js +83 -0
  47. package/src/core/memory/memoryFreshness.js +190 -0
  48. package/src/core/memory/memoryHit.js +144 -0
  49. package/src/core/memory/migrate.js +69 -189
  50. package/src/core/memory/migrateMapping.js +232 -0
  51. package/src/core/memory/mutateMemory.js +323 -0
  52. package/src/core/memory/policy.js +96 -0
  53. package/src/core/memory/projectIdentity.js +266 -0
  54. package/src/core/memory/recordIndex.js +178 -0
  55. package/src/core/memory/recordStore.js +133 -20
  56. package/src/core/memory/records.js +144 -6
  57. package/src/core/memory/retrieval.js +259 -125
  58. package/src/core/memory/store.js +16 -5
  59. package/src/core/memory/storeBackup.js +226 -0
  60. package/src/core/memory/storeV2.js +63 -26
  61. package/src/core/memory/storeV2Loader.js +30 -12
  62. package/src/core/memory/userMemory.js +38 -20
  63. package/src/core/memory/writeClassification.js +161 -0
  64. package/src/core/memory/writeGuard.js +129 -0
  65. package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
  66. package/src/core/observability/analytics/cohorts.js +148 -0
  67. package/src/core/observability/analytics/storeDigest.js +163 -0
  68. package/src/core/observability/evaluation/experimentPlan.js +95 -0
  69. package/src/core/observability/evaluation/findings.js +99 -0
  70. package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
  71. package/src/core/observability/evaluation/perturbation.js +273 -0
  72. package/src/core/observability/evaluation/replay.js +7 -1
  73. package/src/core/observability/evaluation/scorecard.js +23 -3
  74. package/src/core/observability/rollout.js +11 -7
  75. package/src/core/observability/schema/compatibility.js +135 -0
  76. package/src/core/observability/schema/registry.js +99 -0
  77. package/src/core/observability/schema/validate.js +7 -0
  78. package/src/core/observability/support/import.js +53 -9
  79. package/src/core/observability/support/paths.js +13 -3
  80. package/src/core/observability/support/projector.js +148 -12
  81. package/src/core/output/index.js +12 -2
  82. package/src/core/runtimeConfig.js +83 -0
  83. package/src/core/runtimePaths.js +3 -0
  84. package/src/core/sensitiveValueScanner.js +40 -0
  85. package/src/core/token/index.js +40 -3
  86. package/src/decision/client.js +37 -13
  87. package/src/decision/protocol.js +1 -1
  88. package/src/decision/registry.js +5 -3
  89. package/src/decision/runtimeDecide.js +242 -0
  90. package/src/decision/runtimeFilter.js +150 -0
  91. package/src/decision/runtimeScheduler.js +239 -0
  92. package/src/index/buildIndex.js +13 -12
  93. package/src/index/queryIndex.js +35 -14
  94. package/src/index/relatedTests.js +50 -8
  95. package/src/index/resolveContext.js +9 -4
  96. package/src/manifest/selectItems.js +7 -3
  97. package/src/render/instructionRenderer.js +17 -5
  98. package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
  99. package/template_project/.claude/ukit/index/route-task.mjs +121 -19
  100. package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
  101. package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
  102. package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
  103. package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
  104. package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
  105. package/template_project/.claude/ukit/runtime/reinject-context.mjs +145 -14
@@ -6,29 +6,14 @@ import {
6
6
  writePromptCacheEntry,
7
7
  } from '../token/index.js';
8
8
  import { listMemoryItems } from './store.js';
9
- import { isRecordUsable } from './records.js';
10
9
  import { loadV2Records } from './storeV2Loader.js';
10
+ import { POLICY_VERSION, eligible, resolveEffectiveScope } from './policy.js';
11
11
  import { loadRuntimeConfig } from '../runtimeConfig.js';
12
-
13
- const STOPWORDS = new Set([
14
- 'the', 'a', 'an', 'and', 'or', 'to', 'for', 'of', 'with', 'in', 'on', 'is', 'are',
15
- 'this', 'that', 'it', 'as', 'by', 'be', 'use', 'using', 'implement', 'fix', 'task',
16
- 'cần', 'và', 'là', 'cho', 'một', 'những', 'dùng',
17
- ]);
18
-
19
- function normalize(text) {
20
- return String(text ?? '')
21
- .toLowerCase()
22
- .replace(/[^\p{L}\p{N}\s./:_-]/gu, ' ')
23
- .replace(/\s+/g, ' ')
24
- .trim();
25
- }
26
-
27
- function tokenize(text) {
28
- return normalize(text)
29
- .split(/\s+/)
30
- .filter((token) => token && !STOPWORDS.has(token) && token.length > 1);
31
- }
12
+ import { resolveMemoryStage, appendRolloutReceipt, latencyBandForMs } from './memoryFlags.js';
13
+ import { createIndex, normalize, scoreRecordTokens, tokenize } from './recordIndex.js';
14
+ import { buildMemoryHit, rankHits } from './memoryHit.js';
15
+ import { resolveRecordFreshness } from './memoryFreshness.js';
16
+ import { getHeadSha } from '../codeintel/freshness.js';
32
17
 
33
18
  function compactPhraseList(values, { limit = 1, fallback = 'n/a' } = {}) {
34
19
  const normalized = [...new Set(
@@ -131,21 +116,33 @@ function scoreItem(item, queryTokens) {
131
116
  return score + recencyBonus;
132
117
  }
133
118
 
134
- function withinScope(item, scope, projectId) {
119
+ // projectId + registry-attested aliases (legacy display names recorded at
120
+ // mint time) — migrated v1 records keep the name as project_id (M01-05).
121
+ function boundProjectIds(projectId, projectAliases) {
122
+ const ids = new Set();
123
+ if (typeof projectId === 'string' && projectId) ids.add(projectId);
124
+ for (const alias of projectAliases ?? []) {
125
+ if (typeof alias === 'string' && alias) ids.add(alias);
126
+ }
127
+ return ids;
128
+ }
129
+
130
+ function withinScope(item, scope, projectId, projectAliases) {
135
131
  if (scope !== 'all' && item.type !== scope) {
136
132
  return false;
137
133
  }
138
134
 
139
- if (!projectId) {
135
+ const boundIds = boundProjectIds(projectId, projectAliases);
136
+ if (boundIds.size === 0) {
140
137
  return true;
141
138
  }
142
139
 
143
140
  if (item.type === 'project') {
144
- return item.content?.id === projectId;
141
+ return boundIds.has(item.content?.id);
145
142
  }
146
143
 
147
144
  if (item.type === 'session') {
148
- return item.content?.projectId === projectId;
145
+ return boundIds.has(item.content?.projectId);
149
146
  }
150
147
 
151
148
  return true;
@@ -164,109 +161,205 @@ function buildSearchResult(item, relevanceScore) {
164
161
 
165
162
  // ---- Memory v2 records path (SPEC §2–§3) ----
166
163
 
167
- const RECORD_TYPE_WEIGHTS = Object.freeze({
164
+ export const RECORD_TYPE_WEIGHTS = Object.freeze({
168
165
  project_rule: 7,
169
166
  derived_fact: 5,
170
167
  procedure: 5,
171
168
  episode: 3,
172
169
  });
173
170
 
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) {
171
+ // Lazy import: storeV2.js is owned by TASK-002; the loader reports a typed
172
+ // {state, records} result. Legacy fallback is allowed ONLY on 'missing' or
173
+ // 'empty' — 'corrupt'|'disabled'|'migrating' mean the v2 lane is authoritative
174
+ // and empty (FR-008). queryRecords itself triggers ensureMigrated on the real
175
+ // store — no explicit migration here.
176
+ async function queryV2Records(projectRoot, { projectId } = {}) {
177
+ let policy = {};
178
+ let config = null;
178
179
  try {
179
- const config = await loadRuntimeConfig(projectRoot);
180
+ config = await loadRuntimeConfig(projectRoot);
180
181
  if (config?.memoryV2?.enabled === false) {
181
- return [];
182
+ return { state: 'disabled', records: [], policy, generation: 0 };
183
+ }
184
+ if (config?.memoryV2?.policy && typeof config.memoryV2.policy === 'object') {
185
+ policy = config.memoryV2.policy;
182
186
  }
183
187
  } catch {
184
- // unreadable config → default enabled, keep going
185
- }
186
- return loadV2Records(projectRoot, {});
188
+ // unreadable config → default enabled, default policy, keep going
189
+ }
190
+ // M06 rollout flag (SPEC §5 FR-003): eligibility stage gates the v2 read
191
+ // lane. 'off' (incl. killSwitch / unlisted canary) → legacy lane output,
192
+ // byte-identical. 'shadow' runs the v2 load for measurement, discards it,
193
+ // emits a receipt, and still serves the legacy lane.
194
+ const eligibilityStage = config
195
+ ? resolveMemoryStage(config, 'eligibility', { projectId })
196
+ : 'default';
197
+ if (eligibilityStage === 'off') {
198
+ return { state: 'missing', records: [], policy, generation: 0 };
199
+ }
200
+ if (eligibilityStage === 'shadow') {
201
+ const startedAt = Date.now();
202
+ const probe = await loadV2Records(projectRoot, {});
203
+ await appendRolloutReceipt(projectRoot, {
204
+ plane: 'eligibility',
205
+ stage: 'shadow',
206
+ outcome: probe.state === 'ok' ? 'ok' : 'error',
207
+ code: probe.state,
208
+ latencyBand: latencyBandForMs(Date.now() - startedAt),
209
+ });
210
+ return { state: 'missing', records: [], policy, generation: 0 };
211
+ }
212
+ const { state, records, generation } = await loadV2Records(projectRoot, {}, { config, projectId });
213
+ // Index plane (FR-003): 'off' → linear scan; 'shadow' → indexed path runs
214
+ // for measurement only, linear result is served; live → indexed path.
215
+ const indexStage = config
216
+ ? resolveMemoryStage(config, 'index', { projectId })
217
+ : 'default';
218
+ return { state, records, policy, generation: generation ?? 0, indexStage };
187
219
  }
188
220
 
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;
221
+ // 'missing'|'empty' → legacy compat lane; every other state keeps the v2 lane
222
+ // authoritative (deny-by-default, no silent legacy injection).
223
+ function v2AllowsLegacyFallback(state) {
224
+ return state === 'missing' || state === 'empty';
210
225
  }
211
226
 
212
227
  // Legacy scope names ('user'|'project'|'session') map onto v2 scope/type:
213
228
  // 'project' ↔ scope 'repo'/'task', 'session' ↔ type 'episode'.
214
- const LEGACY_SCOPE_MATCH = {
229
+ export const LEGACY_SCOPE_MATCH = {
215
230
  user: (record) => record.scope === 'user',
216
231
  project: (record) => record.scope === 'repo' || record.scope === 'task',
217
232
  session: (record) => record.type === 'episode' || record.scope === 'session',
218
233
  };
219
234
 
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;
235
+ // TASK-003 (FR-005): both v2 search paths delegate ranking to rankHits.
236
+ // rankHits includes bonus-only (lexical=0) records; the index path can only
237
+ // surface token-matching candidates, so the scan path pre-filters to the
238
+ // same candidate pool (scoreRecordTokens > 0) — identical hits either way.
239
+ function searchRecords(records, query, scope, limit, eligibleCtx, policy) {
240
+ const queryTokens = tokenize(query);
241
+ const candidates = records.filter((record) => scoreRecordTokens(record, queryTokens) > 0);
242
+ return rankHits(candidates, { queryTokens, eligibleCtx, policy, scope, limit });
230
243
  }
231
244
 
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
- };
245
+ // Freshness is resolved post-limit only (≤ limit × MAX_EVIDENCE stats per
246
+ // call). Watermarks are caller-supplied; when omitted and a hit carries
247
+ // non-file evidence, `command` is filled lazily via getHeadSha (one bounded
248
+ // git spawn per call, never per record). Resolver errors degrade to
249
+ // 'unknown' and never break search.
250
+ async function attachFreshness(hits, records, { projectRoot, watermarks } = {}) {
251
+ if (!Array.isArray(hits) || hits.length === 0) return hits;
252
+ const recordById = new Map((records ?? []).map((record) => [record.id, record]));
253
+ const needsCommand = watermarks?.command == null && hits.some((hit) => {
254
+ const record = recordById.get(hit.id);
255
+ return Array.isArray(record?.evidence)
256
+ && record.evidence.some((entry) => entry != null && entry.kind !== 'file');
257
+ });
258
+ const resolvedWatermarks = { ...(watermarks ?? {}) };
259
+ if (needsCommand) {
260
+ try {
261
+ resolvedWatermarks.command = getHeadSha(projectRoot);
262
+ } catch { /* absent → unknown */ }
263
+ }
264
+ await Promise.all(hits.map(async (hit) => {
265
+ const record = recordById.get(hit.id);
266
+ hit.freshness = await resolveRecordFreshness(record, {
267
+ projectRoot,
268
+ watermarks: resolvedWatermarks,
269
+ });
270
+ }));
271
+ return hits;
272
+ }
273
+
274
+ // Legacy requested scopes map onto policy scopes: 'project' is repo-bound.
275
+ const REQUESTED_SCOPE_TO_POLICY = { project: 'repo' };
276
+
277
+ // TASK-003 (FR-006/FR-007): module-scoped inverted index, in-process only,
278
+ // never persisted. Cache key = projectRoot + project identity + POLICY_VERSION;
279
+ // validity additionally requires index.generation === store generation. Any
280
+ // mismatch, absence, or error falls back to the lexical scan — the index
281
+ // narrows candidates, rankHits re-gates + scores (identical output either
282
+ // path: the index candidate pool is exactly the score>0 set the scan uses).
283
+ // Module-scoped index cache (in-process only, never persisted).
284
+ const recordIndexes = new Map();
285
+
286
+ export function resetRecordIndexForTests() {
287
+ recordIndexes.clear();
241
288
  }
242
289
 
243
- function searchRecords(records, query, scope, limit, projectId) {
290
+ function searchRecordsIndexed(records, query, scope, limit, eligibleCtx, policy, indexKey, generation) {
244
291
  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));
292
+ if (queryTokens.length === 0) {
293
+ return searchRecords(records, query, scope, limit, eligibleCtx, policy);
294
+ }
295
+ try {
296
+ let index = recordIndexes.get(indexKey);
297
+ if (!index) {
298
+ index = createIndex();
299
+ recordIndexes.set(indexKey, index);
300
+ }
301
+ if (index.stats().generation !== generation) {
302
+ index.build(records, { generation });
303
+ }
304
+ const candidates = index.query(queryTokens, { limit: records.length })
305
+ .map((hit) => hit.record);
306
+ return rankHits(candidates, { queryTokens, eligibleCtx, policy, scope, limit });
307
+ } catch {
308
+ return searchRecords(records, query, scope, limit, eligibleCtx, policy);
309
+ }
257
310
  }
258
311
 
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);
312
+ export async function search(query, scope = 'all', limit = 10, { projectRoot, projectId, projectAliases, index = true, watermarks } = {}) {
313
+ const { state, records, policy, generation, indexStage } = await queryV2Records(projectRoot, { projectId });
314
+ const effective = resolveEffectiveScope(
315
+ { projectId: projectId ?? null },
316
+ REQUESTED_SCOPE_TO_POLICY[scope] ?? scope,
317
+ );
318
+ if (effective.denied) {
319
+ return [];
320
+ }
321
+ const eligibleCtx = {
322
+ projectId: effective.projectId,
323
+ projectAliases: projectAliases ?? [],
324
+ includeUser: effective.includeUser,
325
+ };
326
+
327
+ if (!v2AllowsLegacyFallback(state)) {
328
+ const indexKey = `${projectRoot ?? ''}::${effective.projectId ?? ''}::${POLICY_VERSION}`;
329
+ // Index plane (FR-003): 'off' forces the linear scan; 'shadow' runs the
330
+ // indexed path for measurement, discards it, and serves the scan result
331
+ // (zero output change) plus a bounded receipt.
332
+ if (index !== false && indexStage === 'shadow') {
333
+ const startedAt = Date.now();
334
+ try {
335
+ searchRecordsIndexed(records, query, scope, limit, eligibleCtx, policy, indexKey, generation);
336
+ await appendRolloutReceipt(projectRoot, {
337
+ plane: 'index', stage: 'shadow', outcome: 'ok', code: 'search',
338
+ latencyBand: latencyBandForMs(Date.now() - startedAt),
339
+ });
340
+ } catch {
341
+ await appendRolloutReceipt(projectRoot, {
342
+ plane: 'index', stage: 'shadow', outcome: 'error', code: 'search',
343
+ latencyBand: latencyBandForMs(Date.now() - startedAt),
344
+ });
345
+ }
346
+ }
347
+ const hits = index === false || indexStage === 'off' || indexStage === 'shadow'
348
+ ? searchRecords(records, query, scope, limit, eligibleCtx, policy)
349
+ : searchRecordsIndexed(
350
+ records, query, scope, limit, eligibleCtx, policy,
351
+ indexKey,
352
+ generation,
353
+ );
354
+ return attachFreshness(hits, records, { projectRoot, watermarks });
263
355
  }
264
356
 
265
357
  const items = await listMemoryItems(projectRoot);
266
358
  const queryTokens = tokenize(query);
267
359
 
360
+
268
361
  return items
269
- .filter((item) => withinScope(item, scope, projectId))
362
+ .filter((item) => withinScope(item, scope, projectId, projectAliases))
270
363
  .map((item) => ({
271
364
  item,
272
365
  score: scoreItem(item, queryTokens),
@@ -309,9 +402,15 @@ function findRelatedItemIds(targetItem, allItems) {
309
402
  .slice(0, 5);
310
403
  }
311
404
 
312
- export async function expand(id, { projectRoot } = {}) {
313
- const records = await queryV2Records(projectRoot);
314
- if (records.length > 0) {
405
+ export async function expand(id, { projectRoot, projectId, projectAliases } = {}) {
406
+ const { state, records, policy } = await queryV2Records(projectRoot, { projectId });
407
+ if (!v2AllowsLegacyFallback(state)) {
408
+ const effective = resolveEffectiveScope({ projectId: projectId ?? null }, 'all');
409
+ const eligibleCtx = {
410
+ projectId: effective.projectId,
411
+ projectAliases: projectAliases ?? [],
412
+ includeUser: effective.includeUser,
413
+ };
315
414
  // Resolve legacy item ids too: 'session:<legacyId>' → episode with
316
415
  // meta.legacyId, 'project:<projectId>' → records of that project.
317
416
  const colonIdx = String(id).indexOf(':');
@@ -326,12 +425,15 @@ export async function expand(id, { projectRoot } = {}) {
326
425
  if (!target) {
327
426
  return { id, fullContent: '', relatedItems: [] };
328
427
  }
428
+ const [hit] = await attachFreshness([buildMemoryHit(target)], [target], { projectRoot });
329
429
  return {
330
430
  id: target.id,
431
+ hit,
331
432
  fullContent: JSON.stringify(target, null, 2),
332
433
  relatedItems: records
333
434
  .filter((record) => record.id !== target.id)
334
435
  .filter((record) => record.type === target.type || record.project_id === target.project_id)
436
+ .filter((record) => eligible(record, eligibleCtx, policy).ok)
335
437
  .map((record) => record.id)
336
438
  .slice(0, 5),
337
439
  };
@@ -389,9 +491,6 @@ function buildInjectionSnippet(item) {
389
491
  return `user-rules=${(content.rules ?? []).slice(0, 3).join('; ') || 'n/a'}`;
390
492
  }
391
493
 
392
- function includeUserMemoryOrRecord(includeUserMemory, record) {
393
- return includeUserMemory || record.scope !== 'user';
394
- }
395
494
 
396
495
  function buildRecordSnippet(record) {
397
496
  const text = String(record.text ?? '').trim();
@@ -418,14 +517,6 @@ function resolveRecallPressurePlan({
418
517
  };
419
518
  }
420
519
 
421
- if (phase === 'soft') {
422
- return {
423
- phase,
424
- maxTokens: Math.min(maxTokens, Math.max(112, Math.round(maxTokens * 0.88))),
425
- limit: Math.max(1, Math.min(limit, 4)),
426
- includeUserMemory: true,
427
- };
428
- }
429
520
 
430
521
  return {
431
522
  phase,
@@ -435,14 +526,17 @@ function resolveRecallPressurePlan({
435
526
  };
436
527
  }
437
528
 
438
- function isProjectAnchor(item, projectId) {
439
- return Boolean(item && item.type === 'project' && (!projectId || item.content?.id === projectId));
529
+ function isProjectAnchor(item, boundIds) {
530
+ return Boolean(item && item.type === 'project'
531
+ && (boundIds.size === 0 || boundIds.has(item.content?.id)));
440
532
  }
441
533
 
442
- function isSessionAnchor(item, projectId) {
443
- return Boolean(item && item.type === 'session' && (!projectId || item.content?.projectId === projectId));
534
+ function isSessionAnchor(item, boundIds) {
535
+ return Boolean(item && item.type === 'session'
536
+ && (boundIds.size === 0 || boundIds.has(item.content?.projectId)));
444
537
  }
445
538
 
539
+
446
540
  function dedupeItemsById(items) {
447
541
  const seen = new Set();
448
542
  const unique = [];
@@ -460,10 +554,12 @@ function selectContextItems({
460
554
  items = [],
461
555
  rankedItems = [],
462
556
  projectId,
557
+ projectAliases = [],
463
558
  limit = 5,
464
559
  includeUserMemory = true,
465
560
  allowProjectFallback = false,
466
561
  } = {}) {
562
+ const boundIds = boundProjectIds(projectId, projectAliases);
467
563
  const itemById = new Map(items.map((item) => [item.id, item]));
468
564
  const rankedSelection = rankedItems
469
565
  .map((entry) => itemById.get(entry.id))
@@ -472,13 +568,13 @@ function selectContextItems({
472
568
  if (rankedSelection.length === 0 && !allowProjectFallback) {
473
569
  return [];
474
570
  }
475
- const projectAnchor = projectId
476
- ? items.find((item) => item.type === 'project' && item.content?.id === projectId)
571
+ const projectAnchor = boundIds.size > 0
572
+ ? items.find((item) => item.type === 'project' && boundIds.has(item.content?.id))
477
573
  : rankedSelection.find((item) => item.type === 'project');
478
574
  const prioritized = dedupeItemsById([
479
575
  rankedSelection.length > 0 || allowProjectFallback ? projectAnchor : null,
480
- ...rankedSelection.filter((item) => isSessionAnchor(item, projectId)),
481
- ...rankedSelection.filter((item) => isProjectAnchor(item, projectId) && item.id !== projectAnchor?.id),
576
+ ...rankedSelection.filter((item) => isSessionAnchor(item, boundIds)),
577
+ ...rankedSelection.filter((item) => isProjectAnchor(item, boundIds) && item.id !== projectAnchor?.id),
482
578
  ...rankedSelection.filter((item) => item.type !== 'session' && item.type !== 'project'),
483
579
  ]);
484
580
 
@@ -493,6 +589,7 @@ function buildContextRequestKey({
493
589
  inputCompression,
494
590
  selectedItems,
495
591
  pressurePhase,
592
+ freshnessById,
496
593
  }) {
497
594
  return buildCompactMachineKey('memory-recall-v1', {
498
595
  currentTask: normalize(currentTask),
@@ -501,10 +598,17 @@ function buildContextRequestKey({
501
598
  limit,
502
599
  inputCompression,
503
600
  pressurePhase,
601
+ policyVersion: POLICY_VERSION,
504
602
  items: selectedItems.map((item) => ({
505
603
  id: item.id,
506
604
  timestamp: getTimestamp(item),
507
605
  })),
606
+ // FR-006: freshness signature — a stale transition changes the key so a
607
+ // cached "fresh" block is never served after evidence changed.
608
+ freshness: selectedItems.map((item) => ({
609
+ id: item.id,
610
+ state: freshnessById?.get(item.id)?.state ?? null,
611
+ })),
508
612
  });
509
613
  }
510
614
 
@@ -517,27 +621,31 @@ export async function getContextInjection(
517
621
  limit = 5,
518
622
  promptCache = false,
519
623
  inputCompression = true,
624
+ projectAliases = [],
520
625
  } = {},
521
626
  ) {
522
- const records = await queryV2Records(projectRoot);
523
- const items = records.length > 0 ? [] : await listMemoryItems(projectRoot);
627
+ const { state, records, policy } = await queryV2Records(projectRoot, { projectId });
628
+ const v2Active = !v2AllowsLegacyFallback(state);
629
+ const items = v2Active ? [] : await listMemoryItems(projectRoot);
524
630
  const pressureState = await readCompactPressureState(projectRoot);
525
631
  const recallPlan = resolveRecallPressurePlan({
526
632
  maxTokens,
527
633
  limit,
528
634
  pressureState,
529
635
  });
530
- const rankedItems = await search(currentTask, 'all', Math.max(limit * 3, 8), { projectRoot, projectId });
531
- const selectedItems = records.length > 0
636
+ const rankedItems = await search(currentTask, 'all', Math.max(limit * 3, 8), { projectRoot, projectId, projectAliases });
637
+ const eligibleCtx = { projectId: projectId ?? null, projectAliases, includeUser: recallPlan.includeUserMemory };
638
+ const selectedItems = v2Active
532
639
  ? dedupeItemsById(rankedItems
533
640
  .map((entry) => records.find((record) => record.id === entry.id))
534
641
  .filter(Boolean)
535
- .filter((record) => includeUserMemoryOrRecord(recallPlan.includeUserMemory, record)))
642
+ .filter((record) => eligible(record, eligibleCtx, policy).ok))
536
643
  .slice(0, Math.max(1, recallPlan.limit))
537
644
  : selectContextItems({
538
645
  items,
539
646
  rankedItems,
540
647
  projectId,
648
+ projectAliases,
541
649
  limit: recallPlan.limit,
542
650
  includeUserMemory: recallPlan.includeUserMemory,
543
651
  allowProjectFallback: !String(currentTask ?? '').trim(),
@@ -547,6 +655,28 @@ export async function getContextInjection(
547
655
  return '';
548
656
  }
549
657
 
658
+ // FR-005/FR-006: freshness resolved post-selection only (≤ limit records),
659
+ // before the request key so a stale transition busts the prompt cache.
660
+ // Watermarks: command → getHeadSha once per call (bounded, never-throw);
661
+ // no cheap ledger-generation accessor exists → ledger omitted → unknown.
662
+ const freshnessById = new Map();
663
+ if (v2Active) {
664
+ const needsCommand = selectedItems.some((record) => Array.isArray(record?.evidence)
665
+ && record.evidence.some((entry) => entry != null && entry.kind !== 'file'));
666
+ const watermarks = {};
667
+ if (needsCommand) {
668
+ try {
669
+ watermarks.command = getHeadSha(projectRoot);
670
+ } catch { /* absent → unknown */ }
671
+ }
672
+ await Promise.all(selectedItems.map(async (record) => {
673
+ freshnessById.set(record.id, await resolveRecordFreshness(record, {
674
+ projectRoot,
675
+ watermarks,
676
+ }));
677
+ }));
678
+ }
679
+
550
680
  const requestKey = buildContextRequestKey({
551
681
  currentTask,
552
682
  projectId,
@@ -555,6 +685,7 @@ export async function getContextInjection(
555
685
  inputCompression,
556
686
  selectedItems,
557
687
  pressurePhase: recallPlan.phase,
688
+ freshnessById,
558
689
  });
559
690
 
560
691
  if (promptCache) {
@@ -564,11 +695,14 @@ export async function getContextInjection(
564
695
  }
565
696
  }
566
697
 
567
- const rawBodyLines = records.length > 0
568
- ? selectedItems.map((record) => `- [${record.type}] ${buildRecordSnippet(record)}`)
698
+ // FR-005: ' [stale]' appended only when freshness resolves stale; unknown
699
+ // adds no token.
700
+ const staleTag = (record) => (freshnessById.get(record.id)?.state === 'stale' ? ' [stale]' : '');
701
+ const rawBodyLines = v2Active
702
+ ? selectedItems.map((record) => `- [${record.type}] ${buildRecordSnippet(record)}${staleTag(record)}`)
569
703
  : selectedItems.map((item) => `- [${item.type}] ${buildDetailedInjectionSnippet(item)}`);
570
- const bodyLines = records.length > 0
571
- ? selectedItems.map((record) => `- ${buildRecordSnippet(record)}`)
704
+ const bodyLines = v2Active
705
+ ? selectedItems.map((record) => `- ${buildRecordSnippet(record)}${staleTag(record)}`)
572
706
  : selectedItems.map((item) => `- ${buildInjectionSnippet(item)}`);
573
707
  const rawLines = ['## Previous Context', ...rawBodyLines];
574
708
  const rawText = rawLines.join('\n');
@@ -5,6 +5,7 @@ import { buildRuntimePaths } from '../runtimePaths.js';
5
5
  import { readJsonIfExists, writeJson } from '../fileOps.js';
6
6
  import { loadRuntimeConfig } from '../runtimeConfig.js';
7
7
  import { runHygiene } from './hygiene.js';
8
+ import { redactWritePayload } from './writeGuard.js';
8
9
 
9
10
  // ---- Memory v2 facade (SPEC §3/§11) ----
10
11
  // Every exported signature and CLI output shape is preserved. When the v2
@@ -272,22 +273,26 @@ function createSearchableText(type, content) {
272
273
 
273
274
  export async function exportMemory(projectRoot) {
274
275
  const runtimePaths = buildRuntimePaths(projectRoot);
276
+ // FR-023/025: export is a bulk read lane — redact at the store boundary so
277
+ // every caller (CLI, future lanes) gets defense-in-depth, not just the CLI.
278
+ const config = await loadRuntimeConfig(projectRoot);
279
+ const redact = (data) => redactWritePayload({ meta: data }, { config }).payload.meta;
275
280
  if (await v2Active(projectRoot)) {
276
281
  const v2 = await storeV2();
277
282
  const items = recordsToMemoryItems(await v2.loadRecords(projectRoot), runtimePaths);
278
283
  const user = items.find((item) => item.type === 'user')?.content ?? defaultUserMemory();
279
- return {
284
+ return redact({
280
285
  user,
281
286
  projects: items.filter((item) => item.type === 'project').map((item) => item.content),
282
287
  sessions: items.filter((item) => item.type === 'session').map((item) => item.content),
283
- };
288
+ });
284
289
  }
285
290
 
286
291
  const user = (await readMemoryJson(runtimePaths.userMemoryPath)) ?? defaultUserMemory();
287
292
  const projects = (await readDirectoryJsonItems(runtimePaths.projectsDir)).map((item) => item.content);
288
293
  const sessions = (await readDirectoryJsonItems(runtimePaths.sessionsDir)).map((item) => item.content);
289
294
 
290
- return { user, projects, sessions };
295
+ return redact({ user, projects, sessions });
291
296
  }
292
297
 
293
298
  // Read-compat: corrupt legacy files still surface the same warning the legacy
@@ -352,12 +357,16 @@ export async function listMemoryItems(projectRoot) {
352
357
 
353
358
  async function forgetMemoryItemV2(projectRoot, memoryId, runtimePaths) {
354
359
  const v2 = await storeV2();
360
+ const { mutateMemory } = await import('./mutateMemory.js');
355
361
  const archiveAll = async (records) => {
356
362
  let archived = 0;
357
363
  for (const record of records) {
358
364
  if (record.status !== 'archived') {
359
- await v2.updateRecord(projectRoot, record.id, { status: 'archived' });
360
- archived += 1;
365
+ const res = await mutateMemory(
366
+ { kind: 'project', projectRoot },
367
+ { op: 'forget', payload: { id: record.id } },
368
+ );
369
+ if (res.status === 'ok') archived += 1;
361
370
  }
362
371
  }
363
372
  return archived;
@@ -641,6 +650,8 @@ async function resolvePatternCandidateV2(projectRoot, projectId, candidateId, de
641
650
  scope: 'repo',
642
651
  confidence: 0.9,
643
652
  provenance: `${target.provenance ?? 'pattern-candidate'};promoted-from:${target.id}`,
653
+ // Approval is a trust transition — the promoted record is authoritative.
654
+ trust_tier: 'verified',
644
655
  meta: {
645
656
  ...target.meta,
646
657
  legacyStatus: 'approved',