@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
@@ -0,0 +1,232 @@
1
+ // Pure v1→v2 record mapping — SPEC §5 FR-001/FR-003 (M04-01).
2
+ // mapLegacySources(sources, opts) → { records, plan[], skipped }
3
+ // sources: collectLegacySources shape
4
+ // { user, userFile?, projects:[{id,file?,content}], sessions:[{file?,session}|session],
5
+ // corruptFiles, corrupt?:[fileName] }
6
+ // opts: { projectId, now, episodeTtlMs?, tombstones?, resolveProjectId? }
7
+ // Deterministic: record ids are `v1-<sha1(sourceKind:type:normalizedText)>` —
8
+ // stable across runs and legacy file renames. No I/O; `now` is injected.
9
+ // Rules (FR-003): tombstone precedence → plan 'tombstoned'; alias collision
10
+ // (resolveProjectId returns a different verified identity) → plan 'conflict';
11
+ // pending candidates → trust_tier 'candidate' + meta.legacyStatus 'pending',
12
+ // never status 'active'; everything else → trust_tier 'legacy-unverified'.
13
+
14
+ import crypto from 'node:crypto';
15
+ import { createRecord } from './records.js';
16
+ import { isTombstoned } from './recordStore.js';
17
+
18
+ function normalizeText(text) {
19
+ return String(text).toLowerCase().replace(/\s+/g, ' ').trim();
20
+ }
21
+
22
+ function fingerprint(type, text) {
23
+ return crypto.createHash('sha1').update(`v1:${type}:${normalizeText(text)}`).digest('hex');
24
+ }
25
+
26
+ function recordId(sourceKind, type, text) {
27
+ return `v1-${crypto.createHash('sha1')
28
+ .update(`${sourceKind}:${type}:${normalizeText(text)}`)
29
+ .digest('hex')}`;
30
+ }
31
+
32
+ function sessionText(session) {
33
+ return [
34
+ session.taskDescription,
35
+ session.outcome ? `outcome: ${session.outcome}` : null,
36
+ ...(Array.isArray(session.keyActions) ? session.keyActions : []),
37
+ ].filter(Boolean).join(' — ');
38
+ }
39
+
40
+ /**
41
+ * mapLegacySources(sources, { projectId, now, episodeTtlMs, tombstones,
42
+ * resolveProjectId }) → { records, plan, skipped }
43
+ */
44
+ export function mapLegacySources(sources, {
45
+ projectId = null,
46
+ now = null,
47
+ episodeTtlMs = 90 * 24 * 60 * 60 * 1000,
48
+ tombstones = [],
49
+ resolveProjectId = null,
50
+ } = {}) {
51
+ const createdNow = Number.isFinite(now) ? now : Date.now();
52
+ const doc = { tombstones: Array.isArray(tombstones) ? tombstones : [] };
53
+ const records = [];
54
+ const plan = [];
55
+ let skipped = 0;
56
+
57
+ const emit = (spec, source, sourceKind) => {
58
+ const id = recordId(sourceKind, spec.type, spec.text);
59
+ const fp = fingerprint(spec.type, spec.text);
60
+ if (isTombstoned(doc, { id, fingerprint: fp })) {
61
+ plan.push({ id, source, action: 'tombstoned', reason: 'tombstone-precedence' });
62
+ skipped += 1;
63
+ return;
64
+ }
65
+ let record;
66
+ try {
67
+ record = createRecord({
68
+ id,
69
+ type: spec.type,
70
+ scope: spec.scope,
71
+ text: spec.text,
72
+ provenance: spec.provenance,
73
+ sourceFingerprint: fp,
74
+ confidence: spec.confidence,
75
+ createdBy: 'migration',
76
+ projectId: spec.projectId ?? null,
77
+ validUntil: spec.validUntil ?? null,
78
+ meta: spec.meta ?? {},
79
+ trustTier: spec.trustTier ?? 'legacy-unverified',
80
+ reason: 'migration',
81
+ });
82
+ } catch {
83
+ plan.push({ id, source, action: 'skip', reason: 'invalid-record' });
84
+ skipped += 1;
85
+ return;
86
+ }
87
+ if (Number.isFinite(spec.createdAt)) record.created_at = spec.createdAt;
88
+ else record.created_at = createdNow;
89
+ if (spec.status) record.status = spec.status;
90
+ records.push(record);
91
+ plan.push({ id, source, action: 'add', reason: 'mapped' });
92
+ };
93
+
94
+ // ── user memory ──
95
+ const user = sources?.user;
96
+ const userFile = sources?.userFile ?? 'user.json';
97
+ if (user && typeof user === 'object') {
98
+ for (const rule of Array.isArray(user.rules) ? user.rules : []) {
99
+ if (typeof rule !== 'string' || !rule) continue;
100
+ emit({
101
+ type: 'project_rule', scope: 'user', text: rule,
102
+ confidence: 1.0, provenance: 'legacy:user-rules', createdAt: user.updatedAt,
103
+ }, userFile, 'user-rules');
104
+ }
105
+ const prefs = user.preferences && typeof user.preferences === 'object' ? user.preferences : {};
106
+ for (const [key, value] of Object.entries(prefs)) {
107
+ const text = `${key} = ${typeof value === 'string' ? value : JSON.stringify(value)}`;
108
+ emit({
109
+ type: 'derived_fact', scope: 'user', text,
110
+ confidence: 1.0, provenance: 'legacy:user-preferences', createdAt: user.updatedAt,
111
+ }, userFile, 'user-preferences');
112
+ }
113
+ }
114
+
115
+ // ── project memory ──
116
+ for (const entry of Array.isArray(sources?.projects) ? sources.projects : []) {
117
+ const content = entry?.content ?? entry;
118
+ const legacyId = entry?.id ?? content?.id ?? null;
119
+ const file = entry?.file ?? `${legacyId ?? 'project'}.json`;
120
+ if (!content || typeof content !== 'object') continue;
121
+
122
+ // Alias collision: a legacy id that resolves to a DIFFERENT verified
123
+ // identity is a conflict — never silently rebound to this project.
124
+ if (typeof resolveProjectId === 'function' && legacyId) {
125
+ const resolved = resolveProjectId(legacyId);
126
+ if (resolved && resolved !== legacyId) {
127
+ plan.push({
128
+ id: `v1-${crypto.createHash('sha1').update(`project:${legacyId}`).digest('hex')}`,
129
+ source: file,
130
+ action: 'conflict',
131
+ reason: `alias-collision:${resolved}`,
132
+ });
133
+ skipped += 1;
134
+ continue;
135
+ }
136
+ }
137
+ const pid = legacyId ?? projectId;
138
+ const createdAt = content.updatedAt;
139
+
140
+ for (const text of Array.isArray(content.conventions) ? content.conventions : []) {
141
+ if (typeof text !== 'string' || !text) continue;
142
+ emit({
143
+ type: 'project_rule', scope: 'repo', text,
144
+ confidence: 0.9, provenance: 'legacy:conventions', projectId: pid, createdAt,
145
+ }, file, 'conventions');
146
+ }
147
+ for (const text of Array.isArray(content.activeRules) ? content.activeRules : []) {
148
+ if (typeof text !== 'string' || !text) continue;
149
+ emit({
150
+ type: 'project_rule', scope: 'repo', text,
151
+ confidence: 1.0, provenance: 'legacy:activeRules', projectId: pid, createdAt,
152
+ }, file, 'activeRules');
153
+ }
154
+ for (const decision of Array.isArray(content.decisions) ? content.decisions : []) {
155
+ if (!decision || typeof decision !== 'object') continue;
156
+ const text = decision.why ? `${decision.what} — ${decision.why}` : String(decision.what ?? '');
157
+ if (!text) continue;
158
+ emit({
159
+ type: 'derived_fact', scope: 'repo', text,
160
+ confidence: 0.9, provenance: 'legacy:decisions', projectId: pid,
161
+ createdAt: decision.when ?? createdAt,
162
+ }, file, 'decisions');
163
+ }
164
+ const profileEntries = [
165
+ ...(typeof content.architecture === 'string' && content.architecture ? [content.architecture] : []),
166
+ ...(Array.isArray(content.techStack)
167
+ ? content.techStack.filter((t) => typeof t === 'string' && t) : []),
168
+ ];
169
+ for (const text of profileEntries) {
170
+ emit({
171
+ type: 'derived_fact', scope: 'repo', text,
172
+ confidence: 0.8, provenance: 'legacy:project-profile', projectId: pid, createdAt,
173
+ }, file, 'project-profile');
174
+ }
175
+ for (const pc of Array.isArray(content.patternCandidates) ? content.patternCandidates : []) {
176
+ if (!pc || typeof pc !== 'object' || typeof pc.text !== 'string' || !pc.text) continue;
177
+ const legacyStatus = pc.status ?? 'pending';
178
+ const meta = { ...pc, legacyStatus };
179
+ const base = {
180
+ text: pc.text, provenance: 'legacy:pattern-candidate', projectId: pid,
181
+ createdAt: pc.detectedAt ?? createdAt, meta,
182
+ };
183
+ if (legacyStatus === 'approved') {
184
+ emit({ ...base, type: 'project_rule', scope: 'repo', confidence: 0.9 }, file, 'pattern-candidate');
185
+ } else if (legacyStatus === 'rejected') {
186
+ emit({ ...base, type: 'derived_fact', scope: 'task', confidence: 0.4, status: 'archived' },
187
+ file, 'pattern-candidate');
188
+ } else {
189
+ // Candidate isolation: pending candidates keep candidate trust and
190
+ // never land as active records.
191
+ emit({
192
+ ...base, type: 'derived_fact', scope: 'task', confidence: 0.4,
193
+ status: 'stale', trustTier: 'candidate',
194
+ }, file, 'pattern-candidate');
195
+ }
196
+ }
197
+ }
198
+
199
+ // ── sessions ──
200
+ for (const entry of Array.isArray(sources?.sessions) ? sources.sessions : []) {
201
+ const session = entry?.session ?? entry;
202
+ const file = entry?.file ?? 'sessions';
203
+ if (!session || typeof session !== 'object') continue;
204
+ const text = sessionText(session);
205
+ if (!text) continue;
206
+ const createdAt = session.startedAt ?? session.endedAt ?? null;
207
+ emit({
208
+ type: 'episode', scope: 'session', text,
209
+ confidence: 0.7, provenance: 'legacy:session',
210
+ projectId: typeof session.projectId === 'string' ? session.projectId : projectId,
211
+ createdAt,
212
+ validUntil: createdAt != null ? createdAt + episodeTtlMs : null,
213
+ meta: { legacyId: session.id ?? null },
214
+ }, file, 'session');
215
+ }
216
+
217
+ // ── corrupt legacy files: counted, never treated as empty ──
218
+ const corruptCount = Number.isFinite(sources?.corruptFiles) ? sources.corruptFiles : 0;
219
+ const corruptNames = Array.isArray(sources?.corrupt) ? sources.corrupt : [];
220
+ for (let i = 0; i < corruptCount; i += 1) {
221
+ const name = corruptNames[i] ?? `corrupt-file-${i + 1}`;
222
+ plan.push({
223
+ id: `v1-${crypto.createHash('sha1').update(`corrupt:${name}`).digest('hex')}`,
224
+ source: name,
225
+ action: 'skip',
226
+ reason: 'corrupt-legacy-file',
227
+ });
228
+ skipped += 1;
229
+ }
230
+
231
+ return { records, plan, skipped };
232
+ }
@@ -0,0 +1,323 @@
1
+ // mutateMemory — single guarded write entry for project + user stores
2
+ // (SPEC §5 FR-011..FR-015). Every public write (add/update/archive/forget/
3
+ // purge/restore) funnels through here so tombstone checks, optimistic
4
+ // version checks, write-guard rejection, and the idempotency journal run
5
+ // inside the same locked read-modify-write — no unguarded bypass remains.
6
+ //
7
+ // Domain failures are typed results, never throws. Lock-timeout and
8
+ // unexpected I/O errors still propagate per the mutateRecordStore
9
+ // fail-closed contract; StoreCorruptError is caught and mapped to
10
+ // {status:'corrupt'} (quarantine already happened inside the lock).
11
+
12
+ import crypto from 'node:crypto';
13
+ import path from 'node:path';
14
+
15
+ import { readJsonIfExists, writeJson } from '../fileOps.js';
16
+ import { buildRuntimePaths } from '../runtimePaths.js';
17
+ import { buildUserPaths } from '../userPaths.js';
18
+ import { loadRuntimeConfig } from '../runtimeConfig.js';
19
+ import {
20
+ mutateRecordStore,
21
+ isTombstoned,
22
+ applyRecordPatch,
23
+ isRawRecord,
24
+ StoreCorruptError,
25
+ } from './recordStore.js';
26
+ import { createRecord, normalizeRecord } from './records.js';
27
+ import { guardWritePayload, redactWritePayload } from './writeGuard.js';
28
+ import { classifyWrite, mergeEvidence, REVIEW_QUEUE_MAX } from './writeClassification.js';
29
+ import { resolveMemoryStage, appendRolloutReceipt, latencyBandForMs } from './memoryFlags.js';
30
+ import { resolveProjectIdentity } from './projectIdentity.js';
31
+
32
+ const JOURNAL_MAX = 256;
33
+ const OPS = new Set(['add', 'update', 'archive', 'forget', 'purge', 'restore']);
34
+
35
+ function sha1(text) {
36
+ return crypto.createHash('sha1').update(String(text)).digest('hex');
37
+ }
38
+
39
+ function resolveTarget(target) {
40
+ if (target?.kind === 'project') {
41
+ const paths = buildRuntimePaths(target.projectRoot);
42
+ return { paths, recordsPath: paths.memoryV2RecordsPath };
43
+ }
44
+ if (target?.kind === 'user') {
45
+ const paths = buildUserPaths({ homeDir: target.homeDir });
46
+ return { paths, recordsPath: paths.memoryV2RecordsPath };
47
+ }
48
+ throw new Error(`mutateMemory: unknown target kind ${target?.kind}`);
49
+ }
50
+
51
+ async function readJournal(journalPath) {
52
+ const doc = await readJsonIfExists(journalPath);
53
+ const entries = doc && typeof doc.entries === 'object' && doc.entries ? doc.entries : {};
54
+ return { entries };
55
+ }
56
+
57
+ async function appendJournal(journalPath, key, entry) {
58
+ const journal = await readJournal(journalPath);
59
+ // FIFO cap: delete-then-set reorders the key to newest before trimming.
60
+ delete journal.entries[key];
61
+ journal.entries[key] = entry;
62
+ const keys = Object.keys(journal.entries);
63
+ while (keys.length > JOURNAL_MAX) {
64
+ delete journal.entries[keys.shift()];
65
+ }
66
+ await writeJson(journalPath, journal, { fsync: true });
67
+ }
68
+
69
+ function payloadFingerprint(payload) {
70
+ if (!payload || typeof payload !== 'object') return undefined;
71
+ return payload.source_fingerprint ?? payload.sourceFingerprint
72
+ ?? (typeof payload.text === 'string' ? sha1(payload.text) : undefined);
73
+ }
74
+
75
+ function buildRecord(payload, idempotencyKey) {
76
+ const record = isRawRecord(payload) ? normalizeRecord(payload) : createRecord(payload);
77
+ if (!record) return null;
78
+ if (idempotencyKey) record.meta = { ...record.meta, idem: idempotencyKey };
79
+ return record;
80
+ }
81
+
82
+ function findRecord(records, payload) {
83
+ const id = payload?.id ?? payload?.recordId;
84
+ return id ? records.find((r) => r.id === id) : undefined;
85
+ }
86
+
87
+ /**
88
+ * mutateMemory(target, envelope, {classifyAdd, guard} = {}) → Promise<result>
89
+ * target = {kind:'project', projectRoot} | {kind:'user', homeDir}
90
+ * envelope = {op, payload?, idempotencyKey?, expectedRevision?,
91
+ * expectedGeneration?, actor?, confirmScope?}
92
+ * result = {status:'ok'|'conflict'|'rejected'|'duplicate'|'not-found'|
93
+ * 'corrupt'|'disabled'|'review-queue-full', record?, reason?, labels?}
94
+ */
95
+ export async function mutateMemory(target, envelope = {}, { classifyAdd, guard } = {}) {
96
+ const { recordsPath, paths } = resolveTarget(target);
97
+ const journalPath = path.join(paths.memoryV2Dir, 'mutation-journal.json');
98
+ const { op, payload, idempotencyKey, expectedRevision, expectedGeneration,
99
+ confirmScope } = envelope;
100
+ if (!OPS.has(op)) {
101
+ return { status: 'rejected', reason: `unknown-op:${String(op)}` };
102
+ }
103
+ let config = null;
104
+ let receiptStage = null;
105
+ let projectId = null;
106
+ if (target.kind === 'project') {
107
+ try {
108
+ config = await loadRuntimeConfig(target.projectRoot);
109
+ if (config?.memoryV2?.enabled === false) return { status: 'disabled' };
110
+ } catch {
111
+ // unreadable config → default enabled, keep going
112
+ }
113
+ // M06 rollout flag (SPEC §5 FR-003): writer stage 'off' (incl. killSwitch
114
+ // or an unlisted canary project) disables the write lane entirely —
115
+ // flags only reduce capability. 'shadow' runs the same write and emits
116
+ // a bounded content-free receipt; 'canary'/'default' are live.
117
+ const identity = await resolveProjectIdentity(target.projectRoot, {
118
+ homeDir: target.homeDir, mint: false,
119
+ }).catch(() => null);
120
+ projectId = identity?.projectId ?? null;
121
+ const writerStage = resolveMemoryStage(config, 'writer', { projectId });
122
+ if (writerStage === 'off') {
123
+ return { status: 'disabled', reason: 'rollout-stage' };
124
+ }
125
+ receiptStage = writerStage === 'shadow' ? 'shadow' : null;
126
+ }
127
+ const guardFn = guard ?? ((p) => guardWritePayload(p, { config, projectId }));
128
+
129
+ try {
130
+ const startedAt = receiptStage ? Date.now() : 0;
131
+ const result = await mutateRecordStore(recordsPath, async (records, prior) => {
132
+ // 2. Idempotency — journal hit or a record already stamped meta.idem.
133
+ if (idempotencyKey) {
134
+ const journal = await readJournal(journalPath);
135
+ const hit = journal.entries[idempotencyKey];
136
+ if (hit) {
137
+ return { result: { status: 'duplicate', record: records.find((r) => r.id === hit.recordId) } };
138
+ }
139
+ const stamped = records.find((r) => r.meta?.idem === idempotencyKey);
140
+ if (stamped) return { result: { status: 'duplicate', record: stamped } };
141
+ }
142
+
143
+ // 3. Tombstone check — add/restore must never resurrect purged data.
144
+ if (op === 'add' && isTombstoned(prior, {
145
+ id: payload?.id, fingerprint: payloadFingerprint(payload),
146
+ })) {
147
+ return { result: { status: 'rejected', reason: 'tombstoned' } };
148
+ }
149
+
150
+ // 4. Optimistic checks.
151
+ if (expectedGeneration !== undefined && expectedGeneration !== prior.generation) {
152
+ return { result: { status: 'conflict', reason: 'generation-mismatch' } };
153
+ }
154
+ const existing = op === 'add' || op === 'restore' ? undefined : findRecord(records, payload);
155
+ if (op !== 'add' && op !== 'restore' && !existing) {
156
+ return { result: { status: 'not-found' } };
157
+ }
158
+ if (expectedRevision !== undefined) {
159
+ if (!existing || existing.revision !== expectedRevision) {
160
+ return { result: { status: 'conflict', reason: 'revision-mismatch' } };
161
+ }
162
+ }
163
+
164
+ const postWrite = idempotencyKey
165
+ ? async () => appendJournal(journalPath, idempotencyKey, {
166
+ op, recordId: appliedRecordId, at: Date.now(),
167
+ })
168
+ : null;
169
+ let appliedRecordId;
170
+
171
+ // 5-7. Per-op apply.
172
+ if (op === 'add') {
173
+ const classify = classifyAdd ?? ((p, recs) => classifyWrite(p, recs));
174
+ const decision = await classify(payload, records);
175
+ if (decision?.action === 'NOOP') {
176
+ const target = decision.targetId
177
+ ? records.find((r) => r.id === decision.targetId)
178
+ : findRecord(records, payload);
179
+ return { result: { status: 'duplicate', record: target } };
180
+ }
181
+ if (decision?.action === 'REVIEW') {
182
+ const pending = records.filter((r) => r.meta?.review_status === 'pending').length;
183
+ if (pending >= REVIEW_QUEUE_MAX) {
184
+ return { result: { status: 'review-queue-full' } };
185
+ }
186
+ }
187
+ const guarded = guardFn(payload);
188
+ if (guarded && guarded.ok === false) {
189
+ return { result: { status: 'rejected', reason: guarded.reason ?? 'secret-detected', labels: guarded.labels } };
190
+ }
191
+ // REVIEW records must persist even when the candidate itself is
192
+ // malformed (e.g. self-supersede) — strip the offending field so the
193
+ // quarantined record validates instead of crashing the write.
194
+ const recordPayload = decision?.action === 'REVIEW'
195
+ ? { ...payload, supersedes: [] }
196
+ : payload;
197
+ let record;
198
+ try {
199
+ record = buildRecord(recordPayload, idempotencyKey);
200
+ } catch {
201
+ record = null;
202
+ }
203
+ if (!record) return { result: { status: 'rejected', reason: 'invalid-record' } };
204
+ if (decision?.action === 'REVIEW') {
205
+ record.status = 'archived';
206
+ record.meta = { ...record.meta, review_status: 'pending', review_reason: decision.reason };
207
+ appliedRecordId = record.id;
208
+ return { result: { status: 'ok', record, reason: 'review' }, records: [...records, record], postWrite };
209
+ }
210
+ if (decision?.action === 'ENRICH' && decision.targetId) {
211
+ const targetRec = records.find((r) => r.id === decision.targetId);
212
+ if (targetRec) {
213
+ const patched = applyRecordPatch(records, targetRec.id, {
214
+ evidence: mergeEvidence(targetRec.evidence, record.evidence),
215
+ revision: (targetRec.revision ?? 1) + 1,
216
+ });
217
+ appliedRecordId = patched.record.id;
218
+ return {
219
+ result: { status: 'ok', record: patched.record, reason: 'enriched' },
220
+ records: patched.records,
221
+ postWrite,
222
+ };
223
+ }
224
+ }
225
+ if (decision?.action === 'SUPERSEDE') {
226
+ const targetIds = decision.targetIds
227
+ ?? (decision.targetId ? [decision.targetId] : []);
228
+ let next = records;
229
+ for (const id of targetIds) {
230
+ const patched = applyRecordPatch(next, id, {
231
+ status: 'archived', superseded_by: record.id, reason: 'superseded',
232
+ });
233
+ if (patched) next = patched.records;
234
+ }
235
+ appliedRecordId = record.id;
236
+ return { result: { status: 'ok', record }, records: [...next, record], postWrite };
237
+ }
238
+ appliedRecordId = record.id;
239
+ return { result: { status: 'ok', record }, records: [...records, record], postWrite };
240
+ }
241
+
242
+ if (op === 'update' || op === 'archive' || op === 'forget') {
243
+ const patch = op === 'update'
244
+ ? { ...(payload?.patch ?? payload) }
245
+ : { status: 'archived' };
246
+ delete patch.id;
247
+ const guarded = op === 'update' ? guardFn(patch) : { ok: true };
248
+ if (guarded && guarded.ok === false) {
249
+ return { result: { status: 'rejected', reason: guarded.reason ?? 'secret-detected', labels: guarded.labels } };
250
+ }
251
+ const patched = applyRecordPatch(records, existing.id, {
252
+ ...patch, revision: (existing.revision ?? 1) + 1,
253
+ });
254
+ appliedRecordId = patched.record.id;
255
+ return { result: { status: 'ok', record: patched.record }, records: patched.records, postWrite };
256
+ }
257
+
258
+ if (op === 'purge') {
259
+ if (target.kind === 'user' && confirmScope !== 'user') {
260
+ return { result: { status: 'rejected', reason: 'scope-confirm-required' } };
261
+ }
262
+ const tombstone = {
263
+ id: existing.id,
264
+ fingerprint: existing.source_fingerprint ?? sha1(existing.text),
265
+ purged_at: Date.now(),
266
+ };
267
+ appliedRecordId = existing.id;
268
+ return {
269
+ result: { status: 'ok', record: existing },
270
+ records: records.filter((r) => r.id !== existing.id),
271
+ tombstones: [...(prior.tombstones ?? []), tombstone],
272
+ postWrite,
273
+ };
274
+ }
275
+
276
+ // op === 'restore' — explicit migrate/restore path: fingerprint dedupe,
277
+ // tombstone precedence, guard in redact-mode.
278
+ const incoming = Array.isArray(payload?.records) ? payload.records : [];
279
+ const fingerprints = new Set(records.map((r) => r.source_fingerprint).filter(Boolean));
280
+ const ids = new Set(records.map((r) => r.id));
281
+ const additions = [];
282
+ for (const raw of incoming) {
283
+ const redacted = redactWritePayload(raw, { config }).payload;
284
+ const record = normalizeRecord(redacted);
285
+ if (!record) continue;
286
+ const fp = record.source_fingerprint ?? sha1(record.text);
287
+ if (ids.has(record.id) || fingerprints.has(fp)) continue;
288
+ if (isTombstoned(prior, { id: record.id, fingerprint: fp })) continue;
289
+ ids.add(record.id);
290
+ fingerprints.add(fp);
291
+ additions.push(record);
292
+ }
293
+ appliedRecordId = additions[0]?.id;
294
+ // No-op restore: nothing to add → skip the write entirely so a
295
+ // re-run leaves the doc byte-identical (no generation bump).
296
+ if (additions.length === 0) {
297
+ return { result: { status: 'ok', added: 0, records: [] } };
298
+ }
299
+ return {
300
+ result: { status: 'ok', added: additions.length, records: additions },
301
+ records: [...records, ...additions],
302
+ postWrite,
303
+ };
304
+ }, { onCorrupt: op === 'restore' ? 'quarantine-empty' : undefined });
305
+ if (receiptStage) {
306
+ // Shadow receipt (FR-004): outcome/code/latency band only — never the
307
+ // record body. Swallowed internally; cannot break the write.
308
+ await appendRolloutReceipt(target.projectRoot, {
309
+ plane: 'writer',
310
+ stage: receiptStage,
311
+ outcome: result.status === 'ok' ? 'ok' : result.status === 'rejected' ? 'deny' : 'error',
312
+ code: result.status,
313
+ latencyBand: latencyBandForMs(Date.now() - startedAt),
314
+ });
315
+ }
316
+ return result;
317
+ } catch (error) {
318
+ if (error instanceof StoreCorruptError || error?.name === 'StoreCorruptError') {
319
+ return { status: 'corrupt', reason: error.code };
320
+ }
321
+ throw error;
322
+ }
323
+ }
@@ -0,0 +1,96 @@
1
+ // Memory eligibility policy — single source of truth (M01-02).
2
+ // Pure module: no I/O, no imports. Deny-by-default on every unbound or
3
+ // ambiguous path; the only escape hatch is an explicit policy override.
4
+
5
+ export const POLICY_VERSION = 'w1-2';
6
+
7
+ const REPO_BOUND_SCOPES = Object.freeze(['repo', 'project', 'task', 'session']);
8
+ const UNVERIFIED_PROVENANCE = Object.freeze([
9
+ 'learning-observation',
10
+ 'learning-candidate',
11
+ 'delta-overlay',
12
+ ]);
13
+ // Trust tiers that must never surface as a current authoritative fact
14
+ // (TASK-005 / FR-010): candidates and observations are unreviewed input —
15
+ // returning them as eligible is a false-authority breach. 'verified' and
16
+ // 'legacy-unverified' (migrated records) remain admissible.
17
+ const NON_AUTHORITY_TRUST_TIERS = Object.freeze(['candidate', 'observation']);
18
+ const REQUESTED_SCOPES = Object.freeze(['repo', 'user', 'session', 'task', 'all']);
19
+
20
+ function deny(reason) {
21
+ return { ok: false, reason };
22
+ }
23
+
24
+ // hostContext = {projectId: string|null, ambiguous?: boolean, sessionId?, taskId?, branch?}
25
+ // requestedScope ∈ 'repo'|'user'|'session'|'task'|'all' (default 'all').
26
+ // → {scope, projectId, includeUser, denied}
27
+ export function resolveEffectiveScope(hostContext = {}, requestedScope = 'all') {
28
+ const scope = REQUESTED_SCOPES.includes(requestedScope) ? requestedScope : 'all';
29
+ const projectId = typeof hostContext.projectId === 'string' ? hostContext.projectId : null;
30
+ const ambiguous = hostContext.ambiguous === true;
31
+
32
+ if (scope === 'user') {
33
+ return { scope, projectId: null, includeUser: true, denied: false };
34
+ }
35
+
36
+ if (scope === 'all') {
37
+ // 'all' keeps projectId but does not opt into user records
38
+ // (user-repo-precedence: user records need explicit opt-in).
39
+ return { scope, projectId, includeUser: false, denied: false };
40
+ }
41
+
42
+ // repo / task / session require a non-null, non-ambiguous project binding.
43
+ if (projectId == null || ambiguous) {
44
+ return { scope, projectId, includeUser: false, denied: true };
45
+ }
46
+ return { scope, projectId, includeUser: false, denied: false };
47
+ }
48
+
49
+ // record per src/core/memory/records.js
50
+ // (scope, status, project_id, valid_until, provenance, type).
51
+ // context = {projectId, projectAliases?: string[], includeUser, now?}
52
+ // policy = {legacyNullProjectId: 'deny'|'compat'} — default 'deny'.
53
+ // → {ok: boolean, reason: string}
54
+ export function eligible(record, context = {}, policy = {}) {
55
+ const now = typeof context.now === 'number' ? context.now : Date.now();
56
+ const legacyNull = policy.legacyNullProjectId === 'compat' ? 'compat' : 'deny';
57
+
58
+ if (record == null || typeof record !== 'object') {
59
+ return deny('status');
60
+ }
61
+ if (record.status !== 'active') {
62
+ return deny('status');
63
+ }
64
+ if (record.valid_until != null && record.valid_until <= now) {
65
+ return deny('expired');
66
+ }
67
+ if (UNVERIFIED_PROVENANCE.includes(record.provenance)) {
68
+ return deny('unverified-provenance');
69
+ }
70
+ if (NON_AUTHORITY_TRUST_TIERS.includes(record.trust_tier)) {
71
+ return deny('non-authority-trust-tier');
72
+ }
73
+
74
+ if (record.scope === 'user') {
75
+ if (context.includeUser !== true) {
76
+ return deny('user-scope-not-included');
77
+ }
78
+ return { ok: true, reason: 'ok' };
79
+ }
80
+
81
+ if (REPO_BOUND_SCOPES.includes(record.scope)) {
82
+ if (context.projectId == null) {
83
+ return deny('no-project-binding');
84
+ }
85
+ if (record.project_id != null && record.project_id !== context.projectId
86
+ && !(Array.isArray(context.projectAliases)
87
+ && context.projectAliases.includes(record.project_id))) {
88
+ return deny('project-mismatch');
89
+ }
90
+ if (record.project_id == null && legacyNull !== 'compat') {
91
+ return deny('null-project-id');
92
+ }
93
+ }
94
+
95
+ return { ok: true, reason: 'ok' };
96
+ }