@ngockhoale/ukit 3.0.8 → 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 +14 -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
@@ -26,6 +26,13 @@ import {
26
26
  safeTaskId,
27
27
  writeResumableRun,
28
28
  } from '../runtime/resumable-run.mjs';
29
+ import {
30
+ POLICY_VERSION as MEMORY_POLICY_VERSION,
31
+ eligible as memoryRecordEligible,
32
+ resolveProjectIdentityMirror,
33
+ } from '../runtime/memory-policy.mjs';
34
+ import { resolveRecordFreshness } from '../runtime/memory-freshness.mjs';
35
+ import { resolveMemoryStage } from '../runtime/memory-flags.mjs';
29
36
 
30
37
  const {
31
38
  resolveContext,
@@ -5635,21 +5642,58 @@ async function readDirectoryJsonItems(dirPath) {
5635
5642
  return items.sort((left, right) => left.fileName.localeCompare(right.fileName));
5636
5643
  }
5637
5644
 
5638
- async function detectProjectId(rootDir) {
5645
+ // M01-05: project identity is owned by memory-policy.mjs (read-only mirror —
5646
+ // never mints, never writes). Legacy project/session items are keyed by the
5647
+ // display name, so binding also accepts the alias names (pkg name, basename).
5648
+ async function resolveRouteProjectBinding(rootDir) {
5649
+ const identity = await resolveProjectIdentityMirror(rootDir).catch(() => null);
5650
+ const names = new Set();
5651
+ const base = path.basename(rootDir);
5652
+ if (base) names.add(base);
5639
5653
  const pkg = await readJson(path.join(rootDir, 'package.json'), null);
5640
5654
  if (typeof pkg?.name === 'string' && pkg.name.trim()) {
5641
- return pkg.name.trim();
5655
+ names.add(pkg.name.trim());
5642
5656
  }
5657
+ return {
5658
+ projectId: identity?.projectId ?? null,
5659
+ ambiguous: identity?.ambiguous === true,
5660
+ // Registry-attested aliases only (never raw pkg name) — migrated v1
5661
+ // records carry the legacy name as project_id.
5662
+ aliases: Array.isArray(identity?.aliases) ? identity.aliases : [],
5663
+ names,
5664
+ };
5665
+ }
5666
+
5667
+ function matchesProjectBinding(item, binding) {
5668
+ const boundId = item.type === 'project' ? item.content?.id : item.content?.projectId;
5669
+ if (boundId == null) return false;
5670
+ if (binding.projectId != null && boundId === binding.projectId) return true;
5671
+ return binding.names.has(boundId);
5672
+ }
5643
5673
 
5644
- return path.basename(rootDir);
5674
+ // v2/legacy dedup (M01-05): a record carrying meta.legacyId is the same
5675
+ // logical item as `session:<legacyId>`/`project:<legacyId>`; a record whose
5676
+ // project_id equals a legacy project item's id is that project's twin. The
5677
+ // legacy item wins — it carries the richer structured summary.
5678
+ function recordLegacyKey(item) {
5679
+ const record = item.record;
5680
+ if (!record) return null;
5681
+ const legacyId = record.meta?.legacyId;
5682
+ if (typeof legacyId === 'string' && legacyId) {
5683
+ return `session:${legacyId}`;
5684
+ }
5685
+ if (typeof record.project_id === 'string' && record.project_id) {
5686
+ return `project:${record.project_id}`;
5687
+ }
5688
+ return null;
5645
5689
  }
5646
5690
 
5647
- async function listMemoryItems(rootDir) {
5691
+ async function listMemoryItems(rootDir, { config, projectId } = {}) {
5648
5692
  const runtimeRoot = path.join(rootDir, '.ukit', 'storage', 'memory');
5649
5693
  const userMemory = (await readJson(path.join(runtimeRoot, 'user.json'), null)) ?? { preferences: {}, rules: [] };
5650
5694
  const projectMemories = await readDirectoryJsonItems(path.join(runtimeRoot, 'projects'));
5651
5695
  const sessionMemories = await readDirectoryJsonItems(path.join(runtimeRoot, 'sessions'));
5652
- const recordMemories = await listMemoryV2RecordItems(runtimeRoot);
5696
+ const recordMemories = await listMemoryV2RecordItems(runtimeRoot, { config, projectId });
5653
5697
 
5654
5698
  return [
5655
5699
  {
@@ -5677,7 +5721,18 @@ const MEMORY_V2_RECORD_POOL_LIMIT = 20;
5677
5721
  // Missing/malformed store → [] (same never-throw discipline as
5678
5722
  // readDirectoryJsonItems). Candidates: status 'active' AND (valid_until == null
5679
5723
  // OR valid_until > now), capped at 20 newest by created_at.
5680
- async function listMemoryV2RecordItems(runtimeRoot) {
5724
+ // M06 rollout flag (FR-005): eligibility stage 'off' (incl. killSwitch or an
5725
+ // unlisted canary project) suppresses v2 record items entirely — identical
5726
+ // verdict to src/core/memory/memoryFlags.js via memory-flags.mjs.
5727
+ // Accepts either a memory-runtime root or a projectRoot string (tests).
5728
+ export async function listMemoryV2RecordItems(runtimeRootOrProjectRoot, { config, projectId } = {}) {
5729
+ const runtimeRoot = typeof runtimeRootOrProjectRoot === 'string'
5730
+ && !runtimeRootOrProjectRoot.endsWith(path.join('.ukit', 'storage', 'memory'))
5731
+ ? path.join(runtimeRootOrProjectRoot, '.ukit', 'storage', 'memory')
5732
+ : runtimeRootOrProjectRoot;
5733
+ if (resolveMemoryStage(config, 'eligibility', { projectId }) === 'off') {
5734
+ return [];
5735
+ }
5681
5736
  const doc = await readJson(path.join(runtimeRoot, 'v2', 'records.json'), null);
5682
5737
  const records = Array.isArray(doc?.records) ? doc.records : [];
5683
5738
  const now = Date.now();
@@ -5691,6 +5746,8 @@ async function listMemoryV2RecordItems(runtimeRoot) {
5691
5746
  .map((record) => ({
5692
5747
  id: `record:${record.id}`,
5693
5748
  type: 'record',
5749
+ // Raw record retained for memory-policy eligible() (M01-05).
5750
+ record,
5694
5751
  content: {
5695
5752
  recordType: record.type,
5696
5753
  text: record.text,
@@ -5700,12 +5757,21 @@ async function listMemoryV2RecordItems(runtimeRoot) {
5700
5757
  }));
5701
5758
  }
5702
5759
 
5703
- function buildPreviousContextSnippet(item) {
5760
+ // M05-01 (FR-008): record snippets carry a freshness label — ' [stale]' or
5761
+ // ' [unknown]' — resolved via memory-freshness.mjs for the ≤2 rendered items
5762
+ // only. `unknown` is rendered only when the record carries evidence that
5763
+ // failed verification; no-evidence records stay unlabeled (token cost).
5764
+ function buildPreviousContextSnippet(item, freshness = null) {
5704
5765
  const content = item.content ?? {};
5705
5766
  if (item.type === 'record') {
5706
5767
  const text = String(content.text ?? '').trim();
5707
5768
  const truncated = text.length > 120 ? `${text.slice(0, 117)}...` : text;
5708
- return `[${content.recordType ?? 'record'}] ${truncated}`;
5769
+ const label = freshness?.state === 'stale'
5770
+ ? ' [stale]'
5771
+ : freshness?.state === 'unknown' && Array.isArray(item.record?.evidence) && item.record.evidence.length > 0
5772
+ ? ' [unknown]'
5773
+ : '';
5774
+ return `[${content.recordType ?? 'record'}] ${truncated}${label}`;
5709
5775
  }
5710
5776
 
5711
5777
  if (item.type === 'project') {
@@ -5753,20 +5819,45 @@ async function buildPreviousContextSnapshot({ rootDir = process.cwd(), routingCo
5753
5819
  return null;
5754
5820
  }
5755
5821
 
5756
- const projectId = await detectProjectId(rootDir);
5757
- const items = await listMemoryItems(rootDir);
5822
+ const binding = await resolveRouteProjectBinding(rootDir);
5823
+ // M06 rollout flag (FR-005): the merged config gates the v2 record lane —
5824
+ // 'off'/killSwitch suppresses v2 memory lines identically on all engines.
5825
+ // Shipped defaults are applied first so an absent memoryV2 block keeps the
5826
+ // lane live (mirrors loadRuntimeConfig's default merge in src/).
5827
+ const rawConfig = await readMergedConfig(rootDir);
5828
+ const memoryConfig = {
5829
+ ...rawConfig,
5830
+ memoryV2: {
5831
+ eligibility: { stage: 'default' },
5832
+ writer: { stage: 'default' },
5833
+ index: { stage: 'default' },
5834
+ decision: { stage: 'off' },
5835
+ canaryProjects: [],
5836
+ killSwitch: false,
5837
+ ...(rawConfig?.memoryV2 && typeof rawConfig.memoryV2 === 'object' ? rawConfig.memoryV2 : {}),
5838
+ },
5839
+ };
5840
+ const items = await listMemoryItems(rootDir, { config: memoryConfig, projectId: binding.projectId });
5758
5841
  const queryTokens = tokenize(taskQuery);
5842
+ // v2/legacy dedup: collect the legacy-lane ids first, then drop any record
5843
+ // whose logical twin (meta.legacyId → session:<id>, project_id →
5844
+ // project:<id>) is present — the legacy item carries the richer summary.
5845
+ const legacyIds = new Set(
5846
+ items.filter((item) => item.type !== 'record').map((item) => item.id),
5847
+ );
5848
+ const eligibleCtx = { projectId: binding.projectId, projectAliases: binding.aliases, includeUser: false };
5759
5849
  const rankedItems = items
5760
5850
  .filter((item) => item.type !== 'user')
5761
5851
  .filter((item) => {
5762
- if (item.type === 'project') {
5763
- return item.content?.id === projectId;
5764
- }
5765
- if (item.type === 'session') {
5766
- return item.content?.projectId === projectId;
5767
- }
5768
5852
  if (item.type === 'record') {
5769
- return item.content?.projectId == null || item.content?.projectId === projectId;
5853
+ // Policy-owned eligibility (M01-05): repo-bound records require
5854
+ // project_id === resolved id; null project_id denied by default.
5855
+ if (!memoryRecordEligible(item.record, eligibleCtx).ok) return false;
5856
+ const legacyKey = recordLegacyKey(item);
5857
+ return !(legacyKey && legacyIds.has(legacyKey));
5858
+ }
5859
+ if (item.type === 'project' || item.type === 'session') {
5860
+ return matchesProjectBinding(item, binding);
5770
5861
  }
5771
5862
  return true;
5772
5863
  })
@@ -5786,12 +5877,23 @@ async function buildPreviousContextSnapshot({ rootDir = process.cwd(), routingCo
5786
5877
  return null;
5787
5878
  }
5788
5879
 
5880
+ // Freshness is resolved post-limit only — the ≤2 rendered items, never the
5881
+ // pool. Resolver errors degrade to `unknown` and never break routing.
5882
+ const freshnessById = new Map();
5883
+ await Promise.all(rankedItems.map(async (item) => {
5884
+ if (item.type !== 'record') return;
5885
+ try {
5886
+ freshnessById.set(item.id, await resolveRecordFreshness(item.record, { projectRoot: rootDir }));
5887
+ } catch { /* absent → unlabeled */ }
5888
+ }));
5889
+
5789
5890
  return {
5790
- line: rankedItems.map((item) => buildPreviousContextSnippet(item)).join(' | '),
5891
+ line: rankedItems.map((item) => buildPreviousContextSnippet(item, freshnessById.get(item.id))).join(' | '),
5791
5892
  selectedIds: rankedItems.map((item) => item.id),
5792
5893
  fingerprint: buildCompactMachineKey('route-memory-v2', {
5793
5894
  taskQuery: normalize(taskQuery),
5794
- projectId,
5895
+ projectId: binding.projectId,
5896
+ policyVersion: MEMORY_POLICY_VERSION,
5795
5897
  items: rankedItems.map((item) => ({
5796
5898
  id: item.id,
5797
5899
  timestamp: getMemoryTimestamp(item),
@@ -184,7 +184,7 @@ function validateValue(question, args) {
184
184
  const value = args?.value;
185
185
  if (question.kind === 'choice') {
186
186
  if (typeof value !== 'string') return 'missing-value';
187
- if (!question.candidates.includes(value)) return 'out-of-enum';
187
+ if (!Array.isArray(question.candidates) || !question.candidates.includes(value)) return 'out-of-enum';
188
188
  return { value };
189
189
  }
190
190
  if (question.kind === 'noul') {
@@ -542,18 +542,33 @@ export async function requestBatch(batch, {
542
542
  return outcome('invalid', { fallbackCode: 'empty-batch' });
543
543
  }
544
544
 
545
- const serialized =
546
- typeof batch?.statePacket === 'string'
547
- ? batch.statePacket
548
- : stableStringify(batch?.statePacket ?? {});
549
- const questionText = questions
550
- .map((q) => `${q.decisionKey} ${q.instruction} ${(q.candidates ?? []).join(' ')}`)
551
- .join(' ');
552
- const languageClass = classifyLanguage(`${serialized} ${questionText}`);
553
- const checkpoint = resolveCheckpoint(languageClass, config);
554
-
555
- if (stateBudgetExceeded(serialized, languageClass, dp.maxStateTokens)) {
556
- return outcome('invalid', { fallbackCode: 'state-too-large', checkpoint });
545
+ // Language classification covers state + question text; the English
546
+ // checkpoint is reachable only when the whole request proves English.
547
+ // Malformed batch input (cyclic state, non-serializable values, internal
548
+ // budget-gate faults) resolves to a typed 'invalid' outcome instead of
549
+ // throwing — requestBatch never throws (src/decision/client.js parity).
550
+ let serialized;
551
+ let checkpoint;
552
+ try {
553
+ serialized =
554
+ typeof batch?.statePacket === 'string'
555
+ ? batch.statePacket
556
+ : stableStringify(batch?.statePacket ?? {});
557
+ const questionText = questions
558
+ .map((q) => `${q.decisionKey} ${q.instruction} ${(q.candidates ?? []).join(' ')}`)
559
+ .join(' ');
560
+ const languageClass = classifyLanguage(`${serialized} ${questionText}`);
561
+ checkpoint = resolveCheckpoint(languageClass, config);
562
+
563
+ if (stateBudgetExceeded(serialized, languageClass, dp.maxStateTokens)) {
564
+ return outcome('invalid', { fallbackCode: 'state-too-large', checkpoint });
565
+ }
566
+ } catch (error) {
567
+ return outcome('invalid', {
568
+ fallbackCode: 'malformed-question',
569
+ checkpoint: checkpoint ?? null,
570
+ detail: error?.message ?? String(error),
571
+ });
557
572
  }
558
573
  if (sensitiveGate(serialized, config).blocked) {
559
574
  return outcome('blocked-sensitive', { checkpoint });
@@ -0,0 +1,51 @@
1
+ // memory-flags.mjs — read-only mirror of src/core/memory/memoryFlags.js
2
+ // (SPEC §5 FR-005). Self-contained: mirrors cannot import src/, so the stage
3
+ // resolution is duplicated here and pinned by tests/consistency/
4
+ // memoryFlagsParity.test.js. Receipts are NOT emitted from mirrors — this is
5
+ // a read-only label used to suppress v2 memory lines on 'off'/killSwitch.
6
+
7
+ const VALID_ROUTE_STAGES = new Set(['off', 'shadow', 'canary', 'default']);
8
+
9
+ function isPlainObject(value) {
10
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
11
+ }
12
+
13
+ // Same semantics as resolveConfigStage in src/core/runtimeConfig.js: absent
14
+ // config, absent key, non-object intermediate, or malformed stage value all
15
+ // resolve 'off' — a bad config can never promote a feature.
16
+ function resolveConfigStage(config, path) {
17
+ const keys = String(path ?? '').split('.');
18
+ let node = config;
19
+ for (const key of keys) {
20
+ if (!isPlainObject(node)) return 'off';
21
+ node = node[key];
22
+ }
23
+ return VALID_ROUTE_STAGES.has(node) ? node : 'off';
24
+ }
25
+
26
+ /**
27
+ * resolveMemoryStage(config, plane, { projectId } = {})
28
+ * → 'off'|'shadow'|'canary'|'default'
29
+ * plane: 'eligibility'|'writer'|'index'|'decision'. killSwitch → 'off';
30
+ * malformed/missing → 'off'; 'canary' + unlisted project → 'off'.
31
+ */
32
+ export function resolveMemoryStage(config, plane, { projectId } = {}) {
33
+ const memoryV2 = config?.memoryV2;
34
+ if (!isPlainObject(memoryV2)) {
35
+ return 'off';
36
+ }
37
+ if (memoryV2.killSwitch === true) {
38
+ return 'off';
39
+ }
40
+ const stage = resolveConfigStage(config, `memoryV2.${plane}.stage`);
41
+ if (stage === 'canary') {
42
+ const list = Array.isArray(memoryV2.canaryProjects) ? memoryV2.canaryProjects : [];
43
+ return projectId != null && list.includes(projectId) ? 'canary' : 'off';
44
+ }
45
+ return stage;
46
+ }
47
+
48
+ /** 'canary'|'default' → the new path is live for this project. */
49
+ export function isMemoryPlaneLive(stage) {
50
+ return stage === 'canary' || stage === 'default';
51
+ }
@@ -0,0 +1,155 @@
1
+ // memory-freshness.mjs — shipped-runtime mirror of
2
+ // src/core/memory/memoryFreshness.js (M05-01, SPEC §5 FR-008).
3
+ //
4
+ // Self-contained: no src/ import, no other runtime dependency. Deliberately
5
+ // watermark-free — the hook path has no cheap ledger/head accessor, so
6
+ // non-file evidence always resolves `unknown` (the safe verdict). Verdict
7
+ // strings are identical to the src resolver; parity is asserted by
8
+ // tests/consistency/memoryFreshnessParity.test.js.
9
+ //
10
+ // Contract:
11
+ // resolveRecordFreshness(record, { projectRoot, now? })
12
+ // -> Promise<{ state: 'fresh'|'stale'|'unknown', detail }> // never rejects
13
+ // fileFingerprint(absPath) -> Promise<'fs:<mtimeMs>:<size>'|null>
14
+
15
+ import crypto from 'node:crypto';
16
+ import fsp from 'node:fs/promises';
17
+ import path from 'node:path';
18
+
19
+ // Duplicated from src/core/memory/records.js — mirrors cannot import src/.
20
+ const MAX_EVIDENCE = 8;
21
+
22
+ const FRESH = 'fresh';
23
+ const STALE = 'stale';
24
+ const UNKNOWN = 'unknown';
25
+
26
+ /**
27
+ * fileFingerprint(absPath) → 'fs:<mtimeMs>:<size>' or null on any fs error.
28
+ */
29
+ export async function fileFingerprint(absPath) {
30
+ try {
31
+ const st = await fsp.stat(absPath);
32
+ if (!st.isFile()) return null;
33
+ return `fs:${st.mtimeMs}:${st.size}`;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
39
+ async function sha1Fingerprint(absPath) {
40
+ try {
41
+ const buf = await fsp.readFile(absPath);
42
+ return `sha1:${crypto.createHash('sha1').update(buf).digest('hex')}`;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ // Resolve a file-evidence locator against projectRoot. Returns the absolute
49
+ // path when it stays inside projectRoot, else null (unverifiable — never
50
+ // followed). Absolute locators are used as-is but must still land inside
51
+ // projectRoot.
52
+ function resolveLocator(projectRoot, locator) {
53
+ if (typeof locator !== 'string' || locator.length === 0) return null;
54
+ const root = path.resolve(projectRoot);
55
+ const abs = path.resolve(root, locator);
56
+ if (abs !== root && !abs.startsWith(root + path.sep)) return null;
57
+ return abs;
58
+ }
59
+
60
+ async function checkFileEvidence(projectRoot, entry) {
61
+ const abs = resolveLocator(projectRoot, entry.locator);
62
+ if (abs == null) return { state: UNKNOWN, detail: 'locator-unverifiable' };
63
+
64
+ const fp = entry.fingerprint;
65
+ if (typeof fp === 'string' && fp.startsWith('sha1:')) {
66
+ const actual = await sha1Fingerprint(abs);
67
+ if (actual == null) {
68
+ // Distinguish missing file from unreadable file.
69
+ const probe = await fileFingerprint(abs);
70
+ if (probe == null) {
71
+ try {
72
+ await fsp.stat(abs);
73
+ return { state: UNKNOWN, detail: 'file-unverified' }; // exists, unreadable
74
+ } catch {
75
+ return { state: STALE, detail: 'file-missing' };
76
+ }
77
+ }
78
+ return { state: UNKNOWN, detail: 'file-unverified' };
79
+ }
80
+ return actual === fp
81
+ ? { state: FRESH, detail: 'file-match' }
82
+ : { state: STALE, detail: 'file-changed' };
83
+ }
84
+
85
+ let st;
86
+ try {
87
+ st = await fsp.stat(abs);
88
+ } catch (err) {
89
+ if (err && err.code === 'ENOENT') return { state: STALE, detail: 'file-missing' };
90
+ return { state: UNKNOWN, detail: 'file-unverified' };
91
+ }
92
+ if (!st.isFile()) return { state: STALE, detail: 'file-missing' };
93
+
94
+ if (typeof fp !== 'string' || fp.length === 0) {
95
+ return { state: UNKNOWN, detail: 'file-unverified' };
96
+ }
97
+ if (fp.startsWith('fs:')) {
98
+ return `fs:${st.mtimeMs}:${st.size}` === fp
99
+ ? { state: FRESH, detail: 'file-match' }
100
+ : { state: STALE, detail: 'file-changed' };
101
+ }
102
+ // Unrecognized fingerprint scheme — cannot verify.
103
+ return { state: UNKNOWN, detail: 'file-unverified' };
104
+ }
105
+
106
+ /**
107
+ * resolveRecordFreshness(record, { projectRoot, now } = {})
108
+ * → Promise<{ state: 'fresh'|'stale'|'unknown', detail: string }>
109
+ *
110
+ * Aggregate precedence: any stale → stale; else any unknown → unknown;
111
+ * else fresh. Never rejects — every per-entry error degrades to `unknown`.
112
+ * Non-file evidence is always `unknown`/`watermark-missing`: the shipped
113
+ * runtime carries no watermark source.
114
+ */
115
+ export async function resolveRecordFreshness(record, { projectRoot, now } = {}) {
116
+ void now;
117
+ try {
118
+ const evidence = Array.isArray(record?.evidence) ? record.evidence : [];
119
+ if (evidence.length === 0) return { state: UNKNOWN, detail: 'no-evidence' };
120
+
121
+ let sawStale = false;
122
+ let sawUnknown = false;
123
+ let firstDetail = 'no-evidence';
124
+
125
+ for (const entry of evidence.slice(0, MAX_EVIDENCE)) {
126
+ let verdict;
127
+ try {
128
+ if (entry != null && entry.kind === 'file') {
129
+ verdict = await checkFileEvidence(projectRoot, entry);
130
+ } else {
131
+ // No watermark source on the hook path — non-file evidence is
132
+ // unverifiable by construction.
133
+ verdict = { state: UNKNOWN, detail: 'watermark-missing' };
134
+ }
135
+ } catch {
136
+ verdict = { state: UNKNOWN, detail: 'file-unverified' };
137
+ }
138
+ if (verdict.state === STALE) {
139
+ if (!sawStale) firstDetail = verdict.detail;
140
+ sawStale = true;
141
+ } else if (verdict.state === UNKNOWN) {
142
+ if (!sawStale && !sawUnknown) firstDetail = verdict.detail;
143
+ sawUnknown = true;
144
+ } else if (!sawStale && !sawUnknown) {
145
+ firstDetail = verdict.detail;
146
+ }
147
+ }
148
+
149
+ if (sawStale) return { state: STALE, detail: firstDetail };
150
+ if (sawUnknown) return { state: UNKNOWN, detail: firstDetail };
151
+ return { state: FRESH, detail: firstDetail };
152
+ } catch {
153
+ return { state: UNKNOWN, detail: 'resolver-error' };
154
+ }
155
+ }