@ngockhoale/ukit 3.0.3 → 3.0.4

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 (109) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/bin/ukit +12 -4
  3. package/manifests/engineConformance.yaml +29 -0
  4. package/manifests/platform.full.yaml +13 -0
  5. package/package.json +2 -1
  6. package/scripts/audit/decision-coverage.mjs +295 -0
  7. package/scripts/bench/outline-savings.mjs +19 -4
  8. package/scripts/bench/parallel-agents.mjs +15 -4
  9. package/scripts/bench/runGold.mjs +22 -4
  10. package/scripts/bench/v3-ceremony.mjs +7 -1
  11. package/scripts/bug/triage.mjs +56 -17
  12. package/scripts/index/build-index.mjs +94 -28
  13. package/scripts/index/query-index.mjs +48 -14
  14. package/scripts/index/refresh-index.mjs +142 -62
  15. package/scripts/perf/audit-perf.mjs +8 -2
  16. package/scripts/skill/audit-skill.mjs +54 -25
  17. package/src/bug/triageBug.js +9 -6
  18. package/src/cli/adapters.js +6 -0
  19. package/src/cli/commands/code.js +7 -1
  20. package/src/cli/commands/indexArgs.js +4 -2
  21. package/src/cli/commands/indexTools.js +8 -1
  22. package/src/cli/commands/install.js +13 -0
  23. package/src/cli/commands/memory.js +10 -3
  24. package/src/cli/commands/status.js +17 -1
  25. package/src/cli/commands/update.js +7 -0
  26. package/src/context/detectProjectContext.js +3 -1
  27. package/src/core/codeintel/analogy.js +1 -1
  28. package/src/core/codeintel/diagnostics.js +9 -6
  29. package/src/core/codeintel/graph.js +14 -8
  30. package/src/core/codeintel/impact.js +0 -1
  31. package/src/core/codeintel/invalidation.js +5 -3
  32. package/src/core/codeintel/packet.js +11 -0
  33. package/src/core/codeintel/router.js +17 -3
  34. package/src/core/codeintel/semanticProvider.js +1 -1
  35. package/src/core/codeintel/summaries.js +11 -7
  36. package/src/core/compact/contextBudget.js +26 -12
  37. package/src/core/compact/index.js +15 -8
  38. package/src/core/docContracts.js +10 -2
  39. package/src/core/experiments/deliberation.js +321 -0
  40. package/src/core/experiments/dynamicWorkflow.js +492 -0
  41. package/src/core/fileOps.js +8 -1
  42. package/src/core/gatewayProbe.js +29 -1
  43. package/src/core/gatewayResilienceEnv.js +44 -3
  44. package/src/core/handoffDocValidator.js +3 -1
  45. package/src/core/hookChainDoctor.js +16 -1
  46. package/src/core/memory/deltaOverlays.js +448 -0
  47. package/src/core/memory/learningCandidates.js +302 -0
  48. package/src/core/memory/migrate.js +59 -32
  49. package/src/core/memory/recordStore.js +24 -1
  50. package/src/core/memory/store.js +44 -8
  51. package/src/core/memory/storeV2.js +48 -33
  52. package/src/core/memory/userMemory.js +11 -10
  53. package/src/core/output/index.js +15 -10
  54. package/src/core/permissionPolicy.js +8 -0
  55. package/src/core/runtimeConfig.js +224 -4
  56. package/src/core/sensitiveValueScanner.js +10 -2
  57. package/src/core/taskBudgetValidator.js +12 -17
  58. package/src/core/taskProgressGuard.js +59 -9
  59. package/src/core/unattendedDoctor.js +5 -2
  60. package/src/core/uninstall.js +37 -8
  61. package/src/decision/client.js +371 -0
  62. package/src/decision/lease.js +198 -0
  63. package/src/decision/preflight.js +492 -0
  64. package/src/decision/protocol.js +308 -0
  65. package/src/decision/registry.js +384 -0
  66. package/src/decision/shadow.js +281 -0
  67. package/src/decision/statePacket.js +165 -0
  68. package/src/diagnostics/failurePatterns.js +2 -1
  69. package/src/diagnostics/ledgerFiles.js +3 -1
  70. package/src/diagnostics/routeOutcomes.js +1 -29
  71. package/src/index/buildIndex.js +23 -11
  72. package/src/index/impactContext.js +21 -2
  73. package/src/index/importResolution.js +13 -7
  74. package/src/index/queryIndex.js +11 -5
  75. package/src/index/resolveContext.js +16 -6
  76. package/src/index/taskRouting.js +63 -2
  77. package/src/index/verificationPlan.js +12 -1
  78. package/src/learning/patternProposals.js +6 -0
  79. package/src/render/renderTemplate.js +1 -1
  80. package/src/skill/auditSkill.js +3 -1
  81. package/src/stack/detectStack.js +3 -1
  82. package/template_project/.claude/agents/handoff-planner.md +2 -5
  83. package/template_project/.claude/hooks/auto-allow-bash.sh +5 -0
  84. package/template_project/.claude/hooks/block-dangerous.mjs +11 -4
  85. package/template_project/.claude/hooks/context-hardcap-gate.sh +10 -1
  86. package/template_project/.claude/hooks/handoff-model-guard.sh +14 -4
  87. package/template_project/.claude/hooks/handoff-resume.sh +10 -1
  88. package/template_project/.claude/hooks/protect-files.sh +0 -1
  89. package/template_project/.claude/hooks/record-execution.mjs +13 -1
  90. package/template_project/.claude/hooks/sensitive-data-guard.mjs +71 -5
  91. package/template_project/.claude/hooks/session-episode.sh +9 -2
  92. package/template_project/.claude/skills/pdf-processing-pro/SKILL.md +1 -1
  93. package/template_project/.claude/ukit/index/handoff-doc-validator.mjs +3 -1
  94. package/template_project/.claude/ukit/index/lib/index-core.mjs +123 -39
  95. package/template_project/.claude/ukit/index/route-task.mjs +444 -0
  96. package/template_project/.claude/ukit/index/task-budget-validator.mjs +12 -16
  97. package/template_project/.claude/ukit/index/unic-decision.mjs +786 -0
  98. package/template_project/.claude/ukit/index/verify-context.mjs +11 -0
  99. package/template_project/.claude/ukit/runtime/execution-ledger.mjs +116 -9
  100. package/template_project/.claude/ukit/runtime/project-important.mjs +9 -7
  101. package/template_project/.claude/ukit/runtime/reinject-context.mjs +48 -0
  102. package/template_project/.claude/ukit/runtime/resumable-run.mjs +596 -0
  103. package/template_project/.claude/ukit/runtime/sensitive-value-scanner.mjs +4 -7
  104. package/template_project/.claude/ukit/runtime/stop-coordinator.mjs +1 -1
  105. package/template_project/docs/AI_HANDOFF/PLAN.md +7 -7
  106. package/template_project/docs/AI_HANDOFF/RULES.md +1 -1
  107. package/template_project/ukit/storage/config.json +34 -1
  108. package/template_project/.claude/ukit/skill-router-state.json +0 -1
  109. package/template_project/.ukit/storage/cache/hook-latency/unknown.jsonl +0 -4
@@ -0,0 +1,448 @@
1
+ // deltaOverlays.js — M04.2 delta overlays (SPEC §5 FR-017, FR-018; C11).
2
+ //
3
+ // A DeltaOverlay is a narrow, versioned delta over a base contract/workflow
4
+ // version — never a full copy:
5
+ // { overlayVersion:1, id, scope, baseContract, baseVersion, condition,
6
+ // operations[], evidenceRefs[], enabled, status }
7
+ // operations ∈ insert|suppress|replace-field on narrow policy fields only.
8
+ //
9
+ // Contracts:
10
+ // * Authority: only status 'approved' AND enabled overlays apply. Proposed,
11
+ // rejected, expired, conflict, or disabled overlays are skipped — the
12
+ // stable policy NEVER reads them (FR-018; promotion stays manual).
13
+ // * Versioning: overlay.baseVersion must equal the base's current version
14
+ // (and baseContract must match when the base declares one). A mismatch
15
+ // yields status 'conflict' + a rebase-review descriptor — never a silent
16
+ // apply. With { projectRoot } the conflict is persisted on the stored
17
+ // overlay record so rebase review can list it.
18
+ // * Delta-only: applyOverlay returns a patched VIEW; the base object is
19
+ // never mutated. Whole-copy attempts (root targets, the base contract
20
+ // name, wildcard/empty paths, oversized payloads) fail validation.
21
+ // * Rollback: rollbackOverlay expires the overlay record (status 'expired',
22
+ // enabled false) — the stable view is the base again, exactly.
23
+ // * Stage gating: store-backed ops (registerOverlay, listOverlays) are
24
+ // inert when learning.overlays.stage is 'off' (default). rollbackOverlay
25
+ // stays live at any stage — rollback is a safety path, not a feature.
26
+ // * Persistence: overlays are memoryV2 records (provenance 'delta-overlay'),
27
+ // full overlay shape in meta. No second store.
28
+ // * NEVER THROWS on malformed input — validateOverlay reports errors;
29
+ // applyOverlay returns a typed result.
30
+
31
+ import crypto from 'node:crypto';
32
+ import { loadRuntimeConfig, resolveConfigStage } from '../runtimeConfig.js';
33
+ import { addRecord, queryRecords, updateRecord } from './storeV2.js';
34
+
35
+ export const OVERLAY_PROVENANCE = 'delta-overlay';
36
+ export const OVERLAY_OPS = Object.freeze(['insert', 'suppress', 'replace-field']);
37
+ export const OVERLAY_STATUSES = Object.freeze([
38
+ 'proposed', 'approved', 'rejected', 'expired', 'conflict',
39
+ ]);
40
+ export const OVERLAY_SCOPES = Object.freeze(['session', 'project', 'user']);
41
+
42
+ // C11 overlay scope → memoryV2 record scope (same mapping as candidates).
43
+ const OVERLAY_TO_RECORD_SCOPE = {
44
+ session: 'session',
45
+ project: 'repo',
46
+ user: 'user',
47
+ };
48
+ const RECORD_TO_OVERLAY_SCOPE = {
49
+ session: 'session',
50
+ repo: 'project',
51
+ user: 'user',
52
+ };
53
+
54
+ const MAX_OPERATIONS = 16;
55
+ const MAX_OPERATION_BYTES = 8 * 1024;
56
+ const MAX_EVIDENCE_REFS = 24;
57
+ const MAX_STRING_LENGTH = 512;
58
+ const MAX_FIELD_DEPTH = 8;
59
+
60
+ // Field paths that would replace the whole document — a full copy wearing an
61
+ // overlay costume. `workflow`/`policy` are rejected as a FIRST segment too:
62
+ // `workflow.blocks` rewrites the workflow document, not a narrow field. The
63
+ // overlay's own baseContract name is rejected separately.
64
+ const WHOLE_COPY_ROOTS = new Set(['workflow', 'policy']);
65
+ const WHOLE_COPY_FIELDS = new Set(['*', '.']);
66
+ const FORBIDDEN_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']);
67
+
68
+ function isPlainObject(value) {
69
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
70
+ }
71
+
72
+ function isNonEmptyString(value) {
73
+ return typeof value === 'string' && value.trim().length > 0;
74
+ }
75
+
76
+ function fieldSegments(field) {
77
+ if (!isNonEmptyString(field)) return null;
78
+ const trimmed = field.trim();
79
+ const segments = trimmed.split('.');
80
+ if (segments.some((s) => s.length === 0)) return null;
81
+ if (segments.length > MAX_FIELD_DEPTH) return null;
82
+ if (segments.some((s) => FORBIDDEN_PATH_SEGMENTS.has(s))) return null;
83
+ return segments;
84
+ }
85
+
86
+ function isWholeCopyField(field, baseContract) {
87
+ const trimmed = String(field ?? '').trim();
88
+ if (!trimmed) return true;
89
+ if (WHOLE_COPY_FIELDS.has(trimmed)) return true;
90
+ const firstSegment = trimmed.split('.')[0];
91
+ if (WHOLE_COPY_ROOTS.has(firstSegment)) return true;
92
+ // Targeting the base contract itself (or its root) is a full replacement.
93
+ if (isNonEmptyString(baseContract) && trimmed === baseContract.trim()) return true;
94
+ return false;
95
+ }
96
+
97
+ function validateOperation(op, index, baseContract, errors) {
98
+ const label = `operations[${index}]`;
99
+ if (!isPlainObject(op)) {
100
+ errors.push(`${label} must be an object.`);
101
+ return;
102
+ }
103
+ if (!OVERLAY_OPS.includes(op.op)) {
104
+ errors.push(`${label}.op '${String(op.op)}' is not one of ${OVERLAY_OPS.join('|')}.`);
105
+ }
106
+ const segments = fieldSegments(op.field);
107
+ if (segments === null) {
108
+ errors.push(`${label}.field must be a non-empty dotted path (depth ≤ ${MAX_FIELD_DEPTH}, no prototype keys).`);
109
+ } else if (isWholeCopyField(op.field, baseContract)) {
110
+ errors.push(`${label}.field '${op.field}' targets the whole document — overlays are deltas, never full copies.`);
111
+ }
112
+ if ((op.op === 'insert' || op.op === 'replace-field')
113
+ && !Object.prototype.hasOwnProperty.call(op, 'value')) {
114
+ errors.push(`${label} (${op.op}) requires a value.`);
115
+ }
116
+ try {
117
+ if (JSON.stringify(op).length > MAX_OPERATION_BYTES) {
118
+ errors.push(`${label} exceeds ${MAX_OPERATION_BYTES} bytes — a delta op must stay narrow.`);
119
+ }
120
+ } catch {
121
+ errors.push(`${label} is not serializable.`);
122
+ }
123
+ }
124
+
125
+ /**
126
+ * validateOverlay(overlay) → { valid: boolean, errors: string[] }
127
+ * Pure shape check — no store access, never throws.
128
+ */
129
+ export function validateOverlay(overlay) {
130
+ const errors = [];
131
+ if (!isPlainObject(overlay)) {
132
+ return { valid: false, errors: ['overlay must be an object.'] };
133
+ }
134
+
135
+ if (!Number.isInteger(overlay.overlayVersion) || overlay.overlayVersion < 1) {
136
+ errors.push('overlayVersion must be an integer ≥ 1.');
137
+ }
138
+ if (!isNonEmptyString(overlay.id)) errors.push('id must be a non-empty string.');
139
+ if (!OVERLAY_SCOPES.includes(overlay.scope)) {
140
+ errors.push(`scope must be one of ${OVERLAY_SCOPES.join('|')}.`);
141
+ }
142
+ if (!isNonEmptyString(overlay.baseContract)) {
143
+ errors.push('baseContract must be a non-empty string.');
144
+ }
145
+ if (!isNonEmptyString(overlay.baseVersion)) {
146
+ errors.push('baseVersion must be a non-empty string.');
147
+ }
148
+ if (overlay.condition !== undefined && overlay.condition !== null
149
+ && typeof overlay.condition !== 'string') {
150
+ errors.push('condition must be a string when present.');
151
+ }
152
+ if (overlay.enabled !== undefined && typeof overlay.enabled !== 'boolean') {
153
+ errors.push('enabled must be a boolean when present.');
154
+ }
155
+ if (overlay.status !== undefined && !OVERLAY_STATUSES.includes(overlay.status)) {
156
+ errors.push(`status must be one of ${OVERLAY_STATUSES.join('|')}.`);
157
+ }
158
+ if (overlay.evidenceRefs !== undefined) {
159
+ if (!Array.isArray(overlay.evidenceRefs)
160
+ || overlay.evidenceRefs.length > MAX_EVIDENCE_REFS
161
+ || overlay.evidenceRefs.some((r) => !isNonEmptyString(r))) {
162
+ errors.push(`evidenceRefs must be an array of ≤ ${MAX_EVIDENCE_REFS} non-empty strings.`);
163
+ }
164
+ }
165
+
166
+ if (!Array.isArray(overlay.operations) || overlay.operations.length === 0) {
167
+ errors.push('operations must be a non-empty array.');
168
+ } else if (overlay.operations.length > MAX_OPERATIONS) {
169
+ errors.push(`operations must contain ≤ ${MAX_OPERATIONS} entries — overlays are deltas, never full copies.`);
170
+ } else {
171
+ overlay.operations.forEach((op, index) => {
172
+ validateOperation(op, index, overlay.baseContract, errors);
173
+ });
174
+ }
175
+
176
+ return { valid: errors.length === 0, errors };
177
+ }
178
+
179
+ function cloneBase(base) {
180
+ try {
181
+ return structuredClone(base);
182
+ } catch {
183
+ return JSON.parse(JSON.stringify(base));
184
+ }
185
+ }
186
+
187
+ // Numeric segments index into arrays — a path like `rules.0` must patch the
188
+ // element, not clobber the array into a plain object (the old isPlainObject
189
+ // check destroyed every array it traversed).
190
+ function isIndexSegment(segment) {
191
+ return /^\d+$/.test(segment);
192
+ }
193
+
194
+ function setPath(root, segments, value) {
195
+ let node = root;
196
+ for (let i = 0; i < segments.length - 1; i += 1) {
197
+ const key = segments[i];
198
+ const wantArray = isIndexSegment(segments[i + 1]);
199
+ const child = node[key];
200
+ if (wantArray ? !Array.isArray(child) : !isPlainObject(child)) {
201
+ node[key] = wantArray ? [] : {};
202
+ }
203
+ node = node[key];
204
+ }
205
+ node[segments[segments.length - 1]] = value;
206
+ }
207
+
208
+ function deletePath(root, segments) {
209
+ let node = root;
210
+ for (let i = 0; i < segments.length - 1; i += 1) {
211
+ node = node?.[segments[i]];
212
+ if (!isPlainObject(node) && !Array.isArray(node)) return;
213
+ }
214
+ const last = segments[segments.length - 1];
215
+ if (Array.isArray(node) && isIndexSegment(last)) {
216
+ node.splice(Number(last), 1);
217
+ } else {
218
+ delete node[last];
219
+ }
220
+ }
221
+
222
+ function applyOperation(patched, op) {
223
+ const segments = fieldSegments(op.field);
224
+ if (!segments) return;
225
+ if (op.op === 'suppress') {
226
+ deletePath(patched, segments);
227
+ } else {
228
+ setPath(patched, segments, op.value);
229
+ }
230
+ }
231
+
232
+ async function overlaysStage(projectRoot) {
233
+ const config = await loadRuntimeConfig(projectRoot);
234
+ return resolveConfigStage(config, 'learning.overlays.stage');
235
+ }
236
+
237
+ function overlayFromRecord(record) {
238
+ const meta = record?.meta ?? {};
239
+ return {
240
+ overlayVersion: meta.overlayVersion ?? 1,
241
+ id: meta.id ?? record.id,
242
+ scope: meta.scope ?? RECORD_TO_OVERLAY_SCOPE[record.scope] ?? 'session',
243
+ baseContract: meta.baseContract ?? null,
244
+ baseVersion: meta.baseVersion ?? null,
245
+ condition: meta.condition ?? null,
246
+ operations: Array.isArray(meta.operations) ? meta.operations : [],
247
+ evidenceRefs: Array.isArray(meta.evidenceRefs) ? meta.evidenceRefs : [],
248
+ enabled: meta.enabled !== false,
249
+ status: meta.status ?? 'proposed',
250
+ recordId: record.id,
251
+ projectId: record.project_id ?? null,
252
+ };
253
+ }
254
+
255
+ async function findOverlayRecord(projectRoot, overlayId) {
256
+ const records = await queryRecords(projectRoot, {});
257
+ return records.find(
258
+ (r) => r.provenance === OVERLAY_PROVENANCE
259
+ && r.status !== 'archived'
260
+ && (r.meta?.id === overlayId || r.id === overlayId),
261
+ ) ?? null;
262
+ }
263
+
264
+ async function persistOverlayStatus(projectRoot, overlayId, status, extraMeta = {}) {
265
+ try {
266
+ const record = await findOverlayRecord(projectRoot, overlayId);
267
+ if (!record) return null;
268
+ return await updateRecord(projectRoot, record.id, {
269
+ meta: { ...record.meta, status, ...extraMeta },
270
+ });
271
+ } catch {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ /**
277
+ * applyOverlay(base, overlay, { projectRoot } = {}) → result
278
+ * { status:'applied', patched } — delta applied to a cloned view
279
+ * { status:'conflict', review } — base version/contract mismatch;
280
+ * never silently applied
281
+ * { status:'skipped', reason } — unapproved/disabled/invalid
282
+ * The base object is never mutated. When projectRoot is given and the overlay
283
+ * is stored, a conflict is persisted on the record for rebase review.
284
+ */
285
+ export async function applyOverlay(base, overlay, { projectRoot } = {}) {
286
+ if (!isPlainObject(base)) {
287
+ return { status: 'skipped', reason: 'base-not-an-object' };
288
+ }
289
+ const { valid, errors } = validateOverlay(overlay);
290
+ if (!valid) {
291
+ return { status: 'skipped', reason: 'invalid-overlay', errors };
292
+ }
293
+ if (overlay.status !== 'approved') {
294
+ return { status: 'skipped', reason: `status-${overlay.status ?? 'missing'}` };
295
+ }
296
+ if (overlay.enabled === false) {
297
+ return { status: 'skipped', reason: 'disabled' };
298
+ }
299
+
300
+ const actualContract = base.contract ?? base.baseContract ?? null;
301
+ if (actualContract !== null && actualContract !== overlay.baseContract) {
302
+ const review = {
303
+ reason: 'base-contract-mismatch',
304
+ expectedBaseContract: overlay.baseContract,
305
+ actualBaseContract: actualContract,
306
+ overlayId: overlay.id,
307
+ };
308
+ if (projectRoot) {
309
+ await persistOverlayStatus(projectRoot, overlay.id, 'conflict', { conflict: review });
310
+ }
311
+ return { status: 'conflict', review };
312
+ }
313
+
314
+ const actualVersion = base.version ?? base.baseVersion ?? null;
315
+ if (actualVersion !== overlay.baseVersion) {
316
+ const review = {
317
+ reason: 'base-version-mismatch',
318
+ expectedBaseVersion: overlay.baseVersion,
319
+ actualBaseVersion: actualVersion,
320
+ overlayId: overlay.id,
321
+ };
322
+ if (projectRoot) {
323
+ await persistOverlayStatus(projectRoot, overlay.id, 'conflict', { conflict: review });
324
+ }
325
+ return { status: 'conflict', review };
326
+ }
327
+
328
+ const patched = cloneBase(base);
329
+ for (const op of overlay.operations) applyOperation(patched, op);
330
+ return { status: 'applied', patched };
331
+ }
332
+
333
+ /**
334
+ * registerOverlay(projectRoot, overlay) → record | null
335
+ * Validates and persists an overlay as a memoryV2 record. Idempotent on
336
+ * overlay.id — re-registering updates the existing record's meta. Returns
337
+ * null when the stage is 'off' or the overlay fails validation.
338
+ */
339
+ export async function registerOverlay(projectRoot, overlay) {
340
+ if (await overlaysStage(projectRoot) === 'off') return null;
341
+ const { valid } = validateOverlay(overlay);
342
+ if (!valid) return null;
343
+
344
+ const meta = {
345
+ id: overlay.id,
346
+ overlayVersion: overlay.overlayVersion,
347
+ schemaVersion: 1,
348
+ scope: overlay.scope,
349
+ baseContract: overlay.baseContract,
350
+ baseVersion: overlay.baseVersion,
351
+ condition: overlay.condition ?? null,
352
+ operations: overlay.operations,
353
+ evidenceRefs: overlay.evidenceRefs ?? [],
354
+ enabled: overlay.enabled !== false,
355
+ status: overlay.status ?? 'proposed',
356
+ registeredAt: Date.now(),
357
+ };
358
+
359
+ 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
+ }
365
+
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
+ });
376
+ }
377
+
378
+ /**
379
+ * rollbackOverlay(projectRoot, overlayId) → record | null
380
+ * Expires the overlay (status 'expired', enabled false) so the stable view is
381
+ * the base again. Live at every stage — rollback is a safety path.
382
+ */
383
+ export async function rollbackOverlay(projectRoot, overlayId) {
384
+ 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
+ });
395
+ }
396
+
397
+ /**
398
+ * listOverlays(projectRoot, { scope, enabled, status } = {}) → overlay[]
399
+ * Returns [] when the stage is 'off'. Filters are exact-match on the overlay
400
+ * fields; omit a filter to match everything.
401
+ */
402
+ export async function listOverlays(projectRoot, { scope, enabled, status } = {}) {
403
+ if (await overlaysStage(projectRoot) === 'off') return [];
404
+ const records = await queryRecords(projectRoot, {});
405
+ return records
406
+ .filter((r) => r.provenance === OVERLAY_PROVENANCE && r.status !== 'archived')
407
+ .map(overlayFromRecord)
408
+ .filter((o) => (scope === undefined || o.scope === scope)
409
+ && (enabled === undefined || o.enabled === enabled)
410
+ && (status === undefined || o.status === status));
411
+ }
412
+
413
+ /**
414
+ * overlayFromCandidate(candidate, { baseContract, baseVersion }) → overlay | null
415
+ * Bridges a TASK-008 LearningCandidate record into an overlay draft. Only
416
+ * approved candidates produce overlays — promotion stays manual (FR-018).
417
+ * proposalType 'constraint' maps to a suppress op; everything else maps to
418
+ * replace-field on proposedDelta.field.
419
+ */
420
+ export function overlayFromCandidate(candidate, { baseContract, baseVersion } = {}) {
421
+ const meta = candidate?.meta ?? {};
422
+ if (candidate?.provenance !== 'learning-candidate' && meta.status === undefined) {
423
+ return null;
424
+ }
425
+ if (meta.status !== 'approved') return null;
426
+ const delta = meta.proposedDelta;
427
+ if (!isPlainObject(delta) || !isNonEmptyString(delta.field)) return null;
428
+ if (!isNonEmptyString(baseContract) || !isNonEmptyString(baseVersion)) return null;
429
+
430
+ const isSuppress = meta.proposalType === 'constraint' || !Object.prototype.hasOwnProperty.call(delta, 'value');
431
+ const operation = isSuppress
432
+ ? { op: 'suppress', field: delta.field }
433
+ : { op: 'replace-field', field: delta.field, value: delta.value };
434
+
435
+ return {
436
+ overlayVersion: 1,
437
+ id: `ov_${crypto.randomBytes(6).toString('hex')}`,
438
+ scope: OVERLAY_SCOPES.includes(meta.scope) ? meta.scope : 'session',
439
+ baseContract,
440
+ baseVersion,
441
+ condition: meta.condition ?? null,
442
+ operations: [operation],
443
+ evidenceRefs: Array.isArray(meta.evidenceRefs) ? meta.evidenceRefs.slice(0, MAX_EVIDENCE_REFS) : [],
444
+ enabled: true,
445
+ status: 'approved',
446
+ candidateId: meta.candidateId ?? meta.id ?? candidate.id ?? null,
447
+ };
448
+ }