@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
@@ -103,7 +103,7 @@ export async function ensureDir(dirPath) {
103
103
  await fs.mkdir(dirPath, { recursive: true });
104
104
  }
105
105
 
106
- export async function writeFileAtomic(filePath, content) {
106
+ export async function writeFileAtomic(filePath, content, { fsync = false } = {}) {
107
107
  const dir = path.dirname(filePath);
108
108
  await ensureDir(dir);
109
109
 
@@ -119,6 +119,24 @@ export async function writeFileAtomic(filePath, content) {
119
119
  } else {
120
120
  await withTransientFsRetry(() => fs.writeFile(tempPath, content, 'utf8'));
121
121
  }
122
+ if (fsync) {
123
+ // Best-effort durability: flush the tmp file to stable storage before
124
+ // the rename publishes it. Filesystems without fsync support report
125
+ // ENOTSUP/EINVAL/EBADF — those are ignored so writes still land.
126
+ let handle;
127
+ try {
128
+ handle = await fs.open(tempPath, 'r');
129
+ await handle.sync();
130
+ } catch (syncError) {
131
+ if (!['ENOTSUP', 'EINVAL', 'EBADF'].includes(syncError?.code)) throw syncError;
132
+ } finally {
133
+ if (handle) await handle.close().catch(() => {});
134
+ }
135
+ }
136
+ // Test-only crash-injection hook (TASK-002): UKIT_TEST_CRASH_AT=
137
+ // 'pre-rename' kills the process after the tmp write but before the
138
+ // rename publishes it — proving a mid-write crash is all-or-nothing.
139
+ if (process.env.UKIT_TEST_CRASH_AT === 'pre-rename') process.exit(1);
122
140
  try {
123
141
  await withTransientFsRetry(() => fs.rename(tempPath, filePath));
124
142
  } catch (renameError) {
@@ -179,8 +197,8 @@ export async function copyFileRawExclusive(fromPath, toPath) {
179
197
  await fs.copyFile(fromPath, toPath, fs.constants.COPYFILE_EXCL);
180
198
  }
181
199
 
182
- export async function writeJson(filePath, data) {
183
- await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`);
200
+ export async function writeJson(filePath, data, opts) {
201
+ await writeFileAtomic(filePath, `${JSON.stringify(data, null, 2)}\n`, opts);
184
202
  }
185
203
 
186
204
  const LOCK_STALE_MS = 10_000;
@@ -30,7 +30,8 @@
30
30
 
31
31
  import crypto from 'node:crypto';
32
32
  import { loadRuntimeConfig, resolveConfigStage } from '../runtimeConfig.js';
33
- import { addRecord, queryRecords, updateRecord } from './storeV2.js';
33
+ import { queryRecords } from './storeV2.js';
34
+ import { mutateMemory } from './mutateMemory.js';
34
35
 
35
36
  export const OVERLAY_PROVENANCE = 'delta-overlay';
36
37
  export const OVERLAY_OPS = Object.freeze(['insert', 'suppress', 'replace-field']);
@@ -261,13 +262,29 @@ async function findOverlayRecord(projectRoot, overlayId) {
261
262
  ) ?? null;
262
263
  }
263
264
 
264
- async function persistOverlayStatus(projectRoot, overlayId, status, extraMeta = {}) {
265
+ export async function persistOverlayStatus(projectRoot, overlayId, status, extraMeta = {}, { expectedRevision } = {}) {
265
266
  try {
266
267
  const record = await findOverlayRecord(projectRoot, overlayId);
267
268
  if (!record) return null;
268
- return await updateRecord(projectRoot, record.id, {
269
- meta: { ...record.meta, status, ...extraMeta },
270
- });
269
+ const res = await mutateMemory(
270
+ { kind: 'project', projectRoot },
271
+ {
272
+ op: 'update',
273
+ payload: {
274
+ id: record.id,
275
+ patch: {
276
+ meta: { ...record.meta, status, ...extraMeta },
277
+ // Approval is a trust transition — the overlay becomes
278
+ // authoritative (FR-018); other statuses keep the tier.
279
+ ...(status === 'approved' ? { trust_tier: 'verified' } : {}),
280
+ },
281
+ },
282
+ expectedRevision: expectedRevision ?? record.revision ?? 1,
283
+ actor: 'learning',
284
+ },
285
+ );
286
+ if (res.status === 'ok') return res.record;
287
+ return null;
271
288
  } catch {
272
289
  return null;
273
290
  }
@@ -357,22 +374,33 @@ export async function registerOverlay(projectRoot, overlay) {
357
374
  };
358
375
 
359
376
  const existing = await findOverlayRecord(projectRoot, overlay.id);
360
- if (existing) {
361
- return updateRecord(projectRoot, existing.id, {
362
- meta: { ...existing.meta, ...meta, registeredAt: existing.meta?.registeredAt ?? meta.registeredAt },
363
- });
364
- }
377
+ if (existing) return existing;
365
378
 
366
- return addRecord(projectRoot, {
367
- type: 'derived_fact',
368
- scope: OVERLAY_TO_RECORD_SCOPE[overlay.scope] ?? 'session',
369
- text: `Delta overlay ${overlay.id} on ${overlay.baseContract}@${overlay.baseVersion} (${overlay.operations.length} op${overlay.operations.length === 1 ? '' : 's'})`,
370
- provenance: OVERLAY_PROVENANCE,
371
- confidence: 0.8,
372
- createdBy: 'delta-overlay',
373
- projectId: overlay.projectId ?? null,
374
- meta,
375
- });
379
+ try {
380
+ const res = await mutateMemory(
381
+ { kind: 'project', projectRoot },
382
+ {
383
+ op: 'add',
384
+ payload: {
385
+ type: 'derived_fact',
386
+ scope: OVERLAY_TO_RECORD_SCOPE[overlay.scope] ?? 'session',
387
+ text: `Delta overlay ${overlay.id} on ${overlay.baseContract}@${overlay.baseVersion} (${overlay.operations.length} op${overlay.operations.length === 1 ? '' : 's'})`,
388
+ provenance: OVERLAY_PROVENANCE,
389
+ confidence: 0.8,
390
+ createdBy: 'delta-overlay',
391
+ projectId: overlay.projectId ?? null,
392
+ trustTier: meta.status === 'approved' ? 'verified' : 'candidate',
393
+ meta,
394
+ },
395
+ idempotencyKey: `ov:${overlay.id}`,
396
+ actor: 'learning',
397
+ },
398
+ );
399
+ if (res.status === 'ok' || res.status === 'duplicate') return res.record ?? null;
400
+ return null;
401
+ } catch {
402
+ return null;
403
+ }
376
404
  }
377
405
 
378
406
  /**
@@ -382,16 +410,33 @@ export async function registerOverlay(projectRoot, overlay) {
382
410
  */
383
411
  export async function rollbackOverlay(projectRoot, overlayId) {
384
412
  if (!isNonEmptyString(overlayId)) return null;
385
- const record = await findOverlayRecord(projectRoot, overlayId);
386
- if (!record) return null;
387
- return updateRecord(projectRoot, record.id, {
388
- meta: {
389
- ...record.meta,
390
- status: 'expired',
391
- enabled: false,
392
- rolledBackAt: Date.now(),
393
- },
394
- });
413
+ try {
414
+ const record = await findOverlayRecord(projectRoot, overlayId);
415
+ if (!record) return null;
416
+ const res = await mutateMemory(
417
+ { kind: 'project', projectRoot },
418
+ {
419
+ op: 'update',
420
+ payload: {
421
+ id: record.id,
422
+ patch: {
423
+ meta: {
424
+ ...record.meta,
425
+ status: 'expired',
426
+ enabled: false,
427
+ rolledBackAt: Date.now(),
428
+ },
429
+ },
430
+ },
431
+ expectedRevision: record.revision ?? 1,
432
+ actor: 'learning',
433
+ },
434
+ );
435
+ if (res.status === 'ok') return res.record;
436
+ return null;
437
+ } catch {
438
+ return null;
439
+ }
395
440
  }
396
441
 
397
442
  /**
@@ -22,7 +22,8 @@
22
22
 
23
23
  import crypto from 'node:crypto';
24
24
  import { loadRuntimeConfig, resolveConfigStage } from '../runtimeConfig.js';
25
- import { addRecord, queryRecords } from './storeV2.js';
25
+ import { queryRecords } from './storeV2.js';
26
+ import { mutateMemory } from './mutateMemory.js';
26
27
 
27
28
  export const OBSERVATION_PROVENANCE = 'learning-observation';
28
29
  export const CANDIDATE_PROVENANCE = 'learning-candidate';
@@ -175,27 +176,52 @@ export async function observeLearningSignal(projectRoot, signal = {}) {
175
176
  const text = boundText(signal.text, MAX_SIGNAL_TEXT);
176
177
  const target = boundText(signal.target);
177
178
  const signature = `${signal.kind}:${normalizeText(target ?? text)}`.slice(0, MAX_META_FIELD);
178
-
179
- return addRecord(projectRoot, {
180
- type: 'derived_fact',
181
- scope: CANDIDATE_TO_RECORD_SCOPE[signalScopeHint(signal)],
182
- text: `learning-signal ${signal.kind}: ${String(text).trim()}`,
183
- provenance: OBSERVATION_PROVENANCE,
184
- confidence: 0.3,
185
- createdBy: 'learning-observe',
186
- projectId: boundText(signal.projectId),
187
- meta: {
188
- kind: signal.kind,
189
- signature,
190
- sessionId: boundText(signal.sessionId),
191
- scopeHint: signalScopeHint(signal),
192
- target,
193
- condition: boundText(signal.condition),
194
- proposedDelta: boundDelta(signal.proposedDelta),
195
- evidenceRef: boundText(signal.evidenceRef),
196
- observedAt: Date.now(),
197
- },
198
- });
179
+ // Idempotency is per (signature, session): the same signal re-observed in
180
+ // the same session is one record, while distinct sessions still count as
181
+ // separate occurrences for the minSessions promotion gate.
182
+ const signalHash = crypto.createHash('sha1')
183
+ .update(`${signature}|${boundText(signal.sessionId) ?? ''}`)
184
+ .digest('hex');
185
+
186
+ try {
187
+ const res = await mutateMemory(
188
+ { kind: 'project', projectRoot },
189
+ {
190
+ op: 'add',
191
+ payload: {
192
+ type: 'derived_fact',
193
+ scope: CANDIDATE_TO_RECORD_SCOPE[signalScopeHint(signal)],
194
+ text: `learning-signal ${signal.kind}: ${String(text).trim()}`,
195
+ provenance: OBSERVATION_PROVENANCE,
196
+ confidence: 0.3,
197
+ createdBy: 'learning-observe',
198
+ projectId: boundText(signal.projectId),
199
+ trustTier: 'observation',
200
+ meta: {
201
+ kind: signal.kind,
202
+ signature,
203
+ sessionId: boundText(signal.sessionId),
204
+ scopeHint: signalScopeHint(signal),
205
+ target,
206
+ condition: boundText(signal.condition),
207
+ proposedDelta: boundDelta(signal.proposedDelta),
208
+ evidenceRef: boundText(signal.evidenceRef),
209
+ observedAt: Date.now(),
210
+ },
211
+ },
212
+ idempotencyKey: `obs:${signalHash}`,
213
+ actor: 'learning',
214
+ },
215
+ // Recurring signals share meta.signature with different text — generic
216
+ // classification would REVIEW-archive them as 'contradiction'. Dedupe
217
+ // here is the idempotency journal, not text classification.
218
+ { classifyAdd: () => ({ action: 'ADD' }) },
219
+ );
220
+ if (res.status === 'ok' || res.status === 'duplicate') return res.record ?? null;
221
+ return null;
222
+ } catch {
223
+ return null;
224
+ }
199
225
  }
200
226
 
201
227
  function candidateText(kind, signalText, occurrences, sessions) {
@@ -270,32 +296,51 @@ export async function maybeCreateCandidate(projectRoot) {
270
296
  const conflicts = detectCandidateConflicts(draft, records);
271
297
 
272
298
  const candidateId = `lc_${crypto.randomBytes(6).toString('hex')}`;
273
- return addRecord(projectRoot, {
274
- type: 'derived_fact',
275
- scope: CANDIDATE_TO_RECORD_SCOPE[scope],
276
- text: candidateText(first.meta?.kind, first.text, group.length, sessions.size),
277
- provenance: CANDIDATE_PROVENANCE,
278
- confidence: Math.min(0.9, 0.4 + group.length * 0.1),
279
- createdBy: 'learning-candidate',
280
- projectId,
281
- meta: {
282
- id: candidateId,
283
- candidateId,
284
- schemaVersion: 1,
285
- status: 'proposed',
286
- signature,
287
- kind: first.meta?.kind ?? null,
288
- scope,
289
- proposalType: PROPOSAL_TYPE_BY_KIND[first.meta?.kind] ?? 'knowledge',
290
- condition: first.meta?.condition ?? `when ${first.meta?.kind ?? 'signal'} recurs`,
291
- proposedDelta: draft.proposedDelta,
292
- target: draft.target,
293
- evidenceRefs,
294
- occurrences: group.length,
295
- sessions: sessions.size,
296
- conflicts,
297
- },
298
- });
299
+ try {
300
+ const res = await mutateMemory(
301
+ { kind: 'project', projectRoot },
302
+ {
303
+ op: 'add',
304
+ payload: {
305
+ type: 'derived_fact',
306
+ scope: CANDIDATE_TO_RECORD_SCOPE[scope],
307
+ text: candidateText(first.meta?.kind, first.text, group.length, sessions.size),
308
+ provenance: CANDIDATE_PROVENANCE,
309
+ confidence: Math.min(0.9, 0.4 + group.length * 0.1),
310
+ createdBy: 'learning-candidate',
311
+ projectId,
312
+ trustTier: 'candidate',
313
+ meta: {
314
+ id: candidateId,
315
+ candidateId,
316
+ schemaVersion: 1,
317
+ status: 'proposed',
318
+ signature,
319
+ kind: first.meta?.kind ?? null,
320
+ scope,
321
+ proposalType: PROPOSAL_TYPE_BY_KIND[first.meta?.kind] ?? 'knowledge',
322
+ condition: first.meta?.condition ?? `when ${first.meta?.kind ?? 'signal'} recurs`,
323
+ proposedDelta: draft.proposedDelta,
324
+ target: draft.target,
325
+ evidenceRefs,
326
+ occurrences: group.length,
327
+ sessions: sessions.size,
328
+ conflicts,
329
+ },
330
+ },
331
+ idempotencyKey: `cand:${projectId ?? ''}|${signature}`,
332
+ actor: 'learning',
333
+ },
334
+ // Candidate text differs from its source observations under the same
335
+ // meta.signature — generic classification would REVIEW-archive it as
336
+ // 'contradiction'. Dedupe is the signature scan above + journal.
337
+ { classifyAdd: () => ({ action: 'ADD' }) },
338
+ );
339
+ if (res.status === 'ok' || res.status === 'duplicate') return res.record ?? null;
340
+ return null;
341
+ } catch {
342
+ return null;
343
+ }
299
344
  }
300
345
 
301
346
  return firstExisting;
@@ -0,0 +1,83 @@
1
+ // memoryFlags — unified memoryV2 rollout control (SPEC §5 FR-002/FR-004).
2
+ //
3
+ // Four planes (eligibility/writer/index/decision) share the generic
4
+ // resolveConfigStage machinery: off → shadow → canary → default. Flags only
5
+ // ever REDUCE capability — malformed/missing resolves 'off', killSwitch is
6
+ // absolute, and 'canary' without an opted-in projectId resolves 'off'.
7
+ //
8
+ // Shadow receipts are the only telemetry: bounded JSONL of counts/codes/
9
+ // latency bands — never record text, prompt bodies, or secrets.
10
+
11
+ import fs from 'node:fs/promises';
12
+ import path from 'node:path';
13
+
14
+ import { resolveConfigStage } from '../runtimeConfig.js';
15
+
16
+ export const MEMORY_PLANES = Object.freeze(['eligibility', 'writer', 'index', 'decision']);
17
+
18
+ const RECEIPT_MAX_BYTES = 64 * 1024;
19
+ const RECEIPT_KEEP_LINES = 200;
20
+ const RECEIPT_FIELDS = ['plane', 'stage', 'outcome', 'code', 'latencyBand'];
21
+
22
+ /**
23
+ * resolveMemoryStage(config, plane, { projectId } = {})
24
+ * → 'off'|'shadow'|'canary'|'default'
25
+ * Pure, no I/O. Resolution order: killSwitch → 'off'; malformed/missing →
26
+ * 'off'; 'canary' + projectId not in canaryProjects → 'off'.
27
+ */
28
+ export function resolveMemoryStage(config, plane, { projectId } = {}) {
29
+ const memoryV2 = config?.memoryV2;
30
+ if (memoryV2 === null || typeof memoryV2 !== 'object' || Array.isArray(memoryV2)) {
31
+ return 'off';
32
+ }
33
+ if (memoryV2.killSwitch === true) {
34
+ return 'off';
35
+ }
36
+ const stage = resolveConfigStage(config, `memoryV2.${plane}.stage`);
37
+ if (stage === 'canary') {
38
+ const list = Array.isArray(memoryV2.canaryProjects) ? memoryV2.canaryProjects : [];
39
+ return projectId != null && list.includes(projectId) ? 'canary' : 'off';
40
+ }
41
+ return stage;
42
+ }
43
+
44
+ /** 'canary'|'default' → the new path is live for this project. */
45
+ export function isMemoryPlaneLive(stage) {
46
+ return stage === 'canary' || stage === 'default';
47
+ }
48
+
49
+ /** Latency band for receipt telemetry — bands only, never raw timings. */
50
+ export function latencyBandForMs(ms) {
51
+ if (!Number.isFinite(ms) || ms < 0) return 'over';
52
+ if (ms < 50) return 'p50';
53
+ if (ms < 200) return 'p95';
54
+ if (ms < 1000) return 'p99';
55
+ return 'over';
56
+ }
57
+
58
+ /**
59
+ * appendRolloutReceipt(root, receipt) → Promise<void>
60
+ * Bounded JSONL at .ukit/storage/memory/rollout-receipts.jsonl; rotates to
61
+ * the last 200 lines once the file exceeds 64KB. Only the whitelisted fields
62
+ * are written — content fields are stripped, never persisted. All failures
63
+ * are swallowed: telemetry must never break the operation it measures.
64
+ */
65
+ export async function appendRolloutReceipt(root, receipt = {}) {
66
+ try {
67
+ const line = { ts: Date.now() };
68
+ for (const field of RECEIPT_FIELDS) {
69
+ if (receipt[field] !== undefined) line[field] = receipt[field];
70
+ }
71
+ const dir = path.join(root, '.ukit', 'storage', 'memory');
72
+ const file = path.join(dir, 'rollout-receipts.jsonl');
73
+ await fs.mkdir(dir, { recursive: true });
74
+ await fs.appendFile(file, `${JSON.stringify(line)}\n`, 'utf8');
75
+ const stat = await fs.stat(file);
76
+ if (stat.size > RECEIPT_MAX_BYTES) {
77
+ const lines = (await fs.readFile(file, 'utf8')).split('\n').filter(Boolean);
78
+ await fs.writeFile(file, `${lines.slice(-RECEIPT_KEEP_LINES).join('\n')}\n`, 'utf8');
79
+ }
80
+ } catch {
81
+ // EACCES / ENOSPC / vanished dir — swallowed by contract.
82
+ }
83
+ }
@@ -0,0 +1,190 @@
1
+ // memoryFreshness.js — bounded freshness resolver for v2 memory records.
2
+ //
3
+ // Maps a record's evidence to { state: 'fresh'|'stale'|'unknown', detail }
4
+ // using file fingerprints and caller-supplied watermarks. `unknown` is a
5
+ // label, never an upgrade. I/O is capped at MAX_EVIDENCE (8) stat calls per
6
+ // record — evidence locators only, no directory walks, no git spawns.
7
+ //
8
+ // Contract (SPEC §5 FR-003/FR-004, §8):
9
+ // resolveRecordFreshness(record, { projectRoot, watermarks?, now? })
10
+ // -> Promise<{ state, detail }> // never rejects
11
+ // fileFingerprint(absPath)
12
+ // -> Promise<'fs:<mtimeMs>:<size>'|null>
13
+
14
+ import crypto from 'node:crypto';
15
+ import fsp from 'node:fs/promises';
16
+ import path from 'node:path';
17
+
18
+ import { MAX_EVIDENCE } from './records.js';
19
+
20
+ const FRESH = 'fresh';
21
+ const STALE = 'stale';
22
+ const UNKNOWN = 'unknown';
23
+
24
+ /**
25
+ * fileFingerprint(absPath) → 'fs:<mtimeMs>:<size>' or null on any fs error.
26
+ */
27
+ export async function fileFingerprint(absPath) {
28
+ try {
29
+ const st = await fsp.stat(absPath);
30
+ if (!st.isFile()) return null;
31
+ return `fs:${st.mtimeMs}:${st.size}`;
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ async function sha1Fingerprint(absPath) {
38
+ try {
39
+ const buf = await fsp.readFile(absPath);
40
+ return `sha1:${crypto.createHash('sha1').update(buf).digest('hex')}`;
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ // Resolve a file-evidence locator against projectRoot. Returns the absolute
47
+ // path when it stays inside projectRoot, else null (unverifiable — never
48
+ // followed). Absolute locators are used as-is but must still land inside
49
+ // projectRoot.
50
+ function resolveLocator(projectRoot, locator) {
51
+ if (typeof locator !== 'string' || locator.length === 0) return null;
52
+ const root = path.resolve(projectRoot);
53
+ const abs = path.resolve(root, locator);
54
+ if (abs !== root && !abs.startsWith(root + path.sep)) return null;
55
+ return abs;
56
+ }
57
+
58
+ async function checkFileEvidence(projectRoot, entry) {
59
+ const abs = resolveLocator(projectRoot, entry.locator);
60
+ if (abs == null) return { state: UNKNOWN, detail: 'locator-unverifiable' };
61
+
62
+ // Symlink defense (SPEC §14): a locator that resolves inside projectRoot
63
+ // but lands on a symlink pointing outside must never be followed — the
64
+ // realpath containment check makes escapes unverifiable → 'unknown'.
65
+ try {
66
+ const lst = await fsp.lstat(abs);
67
+ if (lst.isSymbolicLink()) {
68
+ const root = path.resolve(projectRoot);
69
+ const real = await fsp.realpath(abs).catch(() => null);
70
+ if (real == null || (real !== root && !real.startsWith(root + path.sep))) {
71
+ return { state: UNKNOWN, detail: 'locator-unverifiable' };
72
+ }
73
+ }
74
+ } catch {
75
+ // lstat failure → fall through to the normal stat path, which maps
76
+ // ENOENT to 'file-missing' and other errors to 'file-unverified'.
77
+ }
78
+
79
+ const fp = entry.fingerprint;
80
+ if (typeof fp === 'string' && fp.startsWith('sha1:')) {
81
+ const actual = await sha1Fingerprint(abs);
82
+ if (actual == null) {
83
+ // Distinguish missing file from unreadable file.
84
+ const probe = await fileFingerprint(abs);
85
+ if (probe == null) {
86
+ try {
87
+ await fsp.stat(abs);
88
+ return { state: UNKNOWN, detail: 'file-unverified' }; // exists, unreadable
89
+ } catch {
90
+ return { state: STALE, detail: 'file-missing' };
91
+ }
92
+ }
93
+ return { state: UNKNOWN, detail: 'file-unverified' };
94
+ }
95
+ return actual === fp
96
+ ? { state: FRESH, detail: 'file-match' }
97
+ : { state: STALE, detail: 'file-changed' };
98
+ }
99
+
100
+ let st;
101
+ try {
102
+ st = await fsp.stat(abs);
103
+ } catch (err) {
104
+ if (err && err.code === 'ENOENT') return { state: STALE, detail: 'file-missing' };
105
+ return { state: UNKNOWN, detail: 'file-unverified' };
106
+ }
107
+ if (!st.isFile()) return { state: STALE, detail: 'file-missing' };
108
+
109
+ if (typeof fp !== 'string' || fp.length === 0) {
110
+ return { state: UNKNOWN, detail: 'file-unverified' };
111
+ }
112
+ if (fp.startsWith('fs:')) {
113
+ return `fs:${st.mtimeMs}:${st.size}` === fp
114
+ ? { state: FRESH, detail: 'file-match' }
115
+ : { state: STALE, detail: 'file-changed' };
116
+ }
117
+ // Unrecognized fingerprint scheme — cannot verify.
118
+ return { state: UNKNOWN, detail: 'file-unverified' };
119
+ }
120
+
121
+ // Non-file evidence (command|ledger|manual|import): compare the caller-
122
+ // supplied watermarks[kind] against the observed watermark. String
123
+ // watermarks (e.g. command → head sha) compare against entry.locator;
124
+ // numeric watermarks (e.g. ledger → generation/epoch) compare against
125
+ // entry.observed_at, falling back to record.revision.
126
+ function checkWatermarkEvidence(entry, record, watermarks) {
127
+ const wm = watermarks != null ? watermarks[entry.kind] : undefined;
128
+ if (wm == null) return { state: UNKNOWN, detail: 'watermark-missing' };
129
+ if (typeof wm === 'string') {
130
+ return wm === entry.locator
131
+ ? { state: FRESH, detail: 'watermark-match' }
132
+ : { state: STALE, detail: 'watermark-stale' };
133
+ }
134
+ if (typeof wm === 'number' && Number.isFinite(wm)) {
135
+ const observed = Number.isFinite(entry.observed_at)
136
+ ? entry.observed_at
137
+ : record.revision;
138
+ return observed === wm
139
+ ? { state: FRESH, detail: 'watermark-match' }
140
+ : { state: STALE, detail: 'watermark-stale' };
141
+ }
142
+ return { state: UNKNOWN, detail: 'watermark-missing' };
143
+ }
144
+
145
+ /**
146
+ * resolveRecordFreshness(record, { projectRoot, watermarks = {}, now } = {})
147
+ * → Promise<{ state: 'fresh'|'stale'|'unknown', detail: string }>
148
+ *
149
+ * Aggregate precedence: any stale → stale; else any unknown → unknown;
150
+ * else fresh. Never rejects — every per-entry error degrades to `unknown`.
151
+ */
152
+ export async function resolveRecordFreshness(record, { projectRoot, watermarks = {}, now } = {}) {
153
+ void now;
154
+ try {
155
+ const evidence = Array.isArray(record?.evidence) ? record.evidence : [];
156
+ if (evidence.length === 0) return { state: UNKNOWN, detail: 'no-evidence' };
157
+
158
+ let sawStale = false;
159
+ let sawUnknown = false;
160
+ let firstDetail = 'no-evidence';
161
+
162
+ for (const entry of evidence.slice(0, MAX_EVIDENCE)) {
163
+ let verdict;
164
+ try {
165
+ if (entry != null && entry.kind === 'file') {
166
+ verdict = await checkFileEvidence(projectRoot, entry);
167
+ } else {
168
+ verdict = checkWatermarkEvidence(entry ?? {}, record ?? {}, watermarks);
169
+ }
170
+ } catch {
171
+ verdict = { state: UNKNOWN, detail: 'file-unverified' };
172
+ }
173
+ if (verdict.state === STALE) {
174
+ if (!sawStale) firstDetail = verdict.detail;
175
+ sawStale = true;
176
+ } else if (verdict.state === UNKNOWN) {
177
+ if (!sawStale && !sawUnknown) firstDetail = verdict.detail;
178
+ sawUnknown = true;
179
+ } else if (!sawStale && !sawUnknown) {
180
+ firstDetail = verdict.detail;
181
+ }
182
+ }
183
+
184
+ if (sawStale) return { state: STALE, detail: firstDetail };
185
+ if (sawUnknown) return { state: UNKNOWN, detail: firstDetail };
186
+ return { state: FRESH, detail: firstDetail };
187
+ } catch {
188
+ return { state: UNKNOWN, detail: 'resolver-error' };
189
+ }
190
+ }