@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.
- package/CHANGELOG.md +18 -1
- package/manifests/documentation.yaml +11 -0
- package/package.json +1 -1
- package/scripts/audit/decision-coverage.mjs +29 -2
- package/scripts/bench/data-foundation.mjs +52 -3
- package/scripts/bench/decision-runtime-baseline.mjs +427 -0
- package/scripts/bench/decision-runtime-metrics.mjs +67 -0
- package/scripts/bench/decision-runtime-variant.mjs +626 -0
- package/scripts/bench/memory-ablation.mjs +495 -0
- package/scripts/bench/memory-baseline.mjs +596 -0
- package/scripts/bench/memory-bench.mjs +661 -0
- package/scripts/bench/memory-canary.mjs +321 -0
- package/scripts/bench/memory-corpus.mjs +354 -0
- package/scripts/bench/memory-gate.mjs +389 -0
- package/scripts/bench/memory-metrics.mjs +179 -0
- package/scripts/bench/parallel-agents.mjs +33 -11
- package/scripts/bench/recorder-overhead.mjs +204 -0
- package/scripts/bench/sqlite-spike.mjs +451 -0
- package/scripts/measure-decision-gateway.mjs +306 -0
- package/scripts/perf/audit-perf.mjs +35 -17
- package/src/bug/triageBug.js +4 -3
- package/src/cli/commands/memory.js +357 -63
- package/src/context/detectProjectContext.js +11 -1
- package/src/core/agentRuntime/adapters.js +254 -0
- package/src/core/agentRuntime/artifacts.js +192 -0
- package/src/core/agentRuntime/completionGate.js +176 -0
- package/src/core/agentRuntime/context.js +149 -0
- package/src/core/agentRuntime/contract.js +247 -0
- package/src/core/agentRuntime/diagnostics.js +244 -0
- package/src/core/agentRuntime/evaluation.js +163 -0
- package/src/core/agentRuntime/eventStore.js +404 -0
- package/src/core/agentRuntime/liveness.js +60 -0
- package/src/core/agentRuntime/planCompiler.js +322 -0
- package/src/core/agentRuntime/promotion.js +53 -0
- package/src/core/agentRuntime/qualityComparison.js +112 -0
- package/src/core/agentRuntime/recovery.js +266 -0
- package/src/core/agentRuntime/resourcePolicy.js +78 -0
- package/src/core/agentRuntime/runtimeSupport.js +237 -0
- package/src/core/agentRuntime/supervisor.js +565 -0
- package/src/core/agentRuntime/vmEngine.js +621 -0
- package/src/core/codeintel/analogy.js +3 -2
- package/src/core/experiments/dynamicWorkflow.js +17 -2
- package/src/core/fileOps.js +21 -3
- package/src/core/memory/deltaOverlays.js +75 -30
- package/src/core/memory/learningCandidates.js +93 -48
- package/src/core/memory/memoryFlags.js +83 -0
- package/src/core/memory/memoryFreshness.js +190 -0
- package/src/core/memory/memoryHit.js +144 -0
- package/src/core/memory/migrate.js +69 -189
- package/src/core/memory/migrateMapping.js +232 -0
- package/src/core/memory/mutateMemory.js +323 -0
- package/src/core/memory/policy.js +96 -0
- package/src/core/memory/projectIdentity.js +266 -0
- package/src/core/memory/recordIndex.js +178 -0
- package/src/core/memory/recordStore.js +133 -20
- package/src/core/memory/records.js +144 -6
- package/src/core/memory/retrieval.js +259 -125
- package/src/core/memory/store.js +16 -5
- package/src/core/memory/storeBackup.js +226 -0
- package/src/core/memory/storeV2.js +63 -26
- package/src/core/memory/storeV2Loader.js +30 -12
- package/src/core/memory/userMemory.js +38 -20
- package/src/core/memory/writeClassification.js +161 -0
- package/src/core/memory/writeGuard.js +129 -0
- package/src/core/observability/adapters/hookTelemetryAdapter.js +90 -0
- package/src/core/observability/analytics/cohorts.js +148 -0
- package/src/core/observability/analytics/storeDigest.js +163 -0
- package/src/core/observability/evaluation/experimentPlan.js +95 -0
- package/src/core/observability/evaluation/findings.js +99 -0
- package/src/core/observability/evaluation/optimizationKnowledge.js +10 -1
- package/src/core/observability/evaluation/perturbation.js +273 -0
- package/src/core/observability/evaluation/replay.js +7 -1
- package/src/core/observability/evaluation/scorecard.js +23 -3
- package/src/core/observability/rollout.js +11 -7
- package/src/core/observability/schema/compatibility.js +135 -0
- package/src/core/observability/schema/registry.js +99 -0
- package/src/core/observability/schema/validate.js +7 -0
- package/src/core/observability/support/import.js +53 -9
- package/src/core/observability/support/paths.js +13 -3
- package/src/core/observability/support/projector.js +148 -12
- package/src/core/output/index.js +12 -2
- package/src/core/runtimeConfig.js +83 -0
- package/src/core/runtimePaths.js +3 -0
- package/src/core/sensitiveValueScanner.js +40 -0
- package/src/core/token/index.js +40 -3
- package/src/decision/client.js +37 -13
- package/src/decision/protocol.js +1 -1
- package/src/decision/registry.js +5 -3
- package/src/decision/runtimeDecide.js +242 -0
- package/src/decision/runtimeFilter.js +150 -0
- package/src/decision/runtimeScheduler.js +239 -0
- package/src/index/buildIndex.js +13 -12
- package/src/index/queryIndex.js +35 -14
- package/src/index/relatedTests.js +50 -8
- package/src/index/resolveContext.js +9 -4
- package/src/manifest/selectItems.js +7 -3
- package/src/render/instructionRenderer.js +17 -5
- package/template_project/.claude/ukit/index/lib/index-core.mjs +94 -39
- package/template_project/.claude/ukit/index/route-task.mjs +121 -19
- package/template_project/.claude/ukit/index/unic-decision.mjs +28 -13
- package/template_project/.claude/ukit/runtime/memory-flags.mjs +51 -0
- package/template_project/.claude/ukit/runtime/memory-freshness.mjs +155 -0
- package/template_project/.claude/ukit/runtime/memory-policy.mjs +286 -0
- package/template_project/.claude/ukit/runtime/output-compression.mjs +3 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5757
|
-
|
|
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
|
-
|
|
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
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
+
}
|