@letterblack/lbe-core 1.3.4 → 1.3.6

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 (78) hide show
  1. package/.githooks/pre-commit +2 -0
  2. package/.githooks/pre-push +2 -0
  3. package/CHANGELOG.md +81 -0
  4. package/LICENSE +1 -1
  5. package/README.md +158 -170
  6. package/RELEASE_WORKSPACE_RULES.md +179 -0
  7. package/Release-README.md +67 -0
  8. package/WORKSPACE.md +422 -0
  9. package/_proof.mjs +246 -0
  10. package/assets/runtime-boundary.svg +36 -36
  11. package/bin/lbe.js +12 -0
  12. package/config/identity.config.json +3 -0
  13. package/config/policy.default.json +24 -0
  14. package/dist/cli/lbe.js +4431 -0
  15. package/dist/hooks/register.cjs +505 -0
  16. package/dist/state/appendCentral.cjs +87 -0
  17. package/dist/state/index.cjs +101 -0
  18. package/exec/cli.js +472 -0
  19. package/exec/index.js +2 -0
  20. package/index.js +24 -0
  21. package/npm-pack.json +0 -0
  22. package/package.json +77 -45
  23. package/release/README.md +216 -0
  24. package/release/TRUST.md +90 -0
  25. package/release/exec-README.md +215 -0
  26. package/release/exec-types.d.ts +50 -0
  27. package/release-exec/LICENSE +1 -0
  28. package/release-exec/README.md +215 -0
  29. package/release-exec/assets/lbe-gates.jpg +0 -0
  30. package/release-exec/assets/lbe-gates.png +0 -0
  31. package/release-exec/assets/runtime-boundary.svg +36 -0
  32. package/release-exec/assets/story-allow.jpg +0 -0
  33. package/release-exec/assets/story-allow.png +0 -0
  34. package/release-exec/assets/story-deny.jpg +0 -0
  35. package/release-exec/assets/story-deny.png +0 -0
  36. package/release-exec/dist/cli.js +2841 -0
  37. package/release-exec/dist/index.js +1835 -0
  38. package/release-exec/dist/lbe_engine.wasm +0 -0
  39. package/{dist → release-exec/dist}/wasm.lock.json +4 -5
  40. package/release-exec/hooks/register.cjs +473 -0
  41. package/release-exec/package.json +35 -0
  42. package/release-exec/types.d.ts +50 -0
  43. package/runtime/engine.js +322 -0
  44. package/runtime/lbe_engine.wasm +0 -0
  45. package/src/cli/commands/assertConsumer.js +198 -0
  46. package/src/cli/commands/auditVerify.js +36 -0
  47. package/src/cli/commands/dryrun.js +175 -0
  48. package/src/cli/commands/health.js +153 -0
  49. package/src/cli/commands/init.js +306 -0
  50. package/src/cli/commands/integrityCheck.js +57 -0
  51. package/src/cli/commands/logs.js +53 -0
  52. package/src/cli/commands/openState.js +44 -0
  53. package/src/cli/commands/policyAdd.js +8 -0
  54. package/src/cli/commands/policyMode.js +7 -0
  55. package/src/cli/commands/policySign.js +72 -0
  56. package/src/cli/commands/proof.js +102 -0
  57. package/src/cli/commands/run.js +342 -0
  58. package/src/cli/commands/status.js +73 -0
  59. package/src/cli/commands/verify.js +144 -0
  60. package/src/cli/main.js +181 -0
  61. package/src/cli/parseArgs.js +115 -0
  62. package/src/exec/localExecutor.js +289 -0
  63. package/src/hooks/register.cjs +505 -0
  64. package/src/state/appendCentral.cjs +87 -0
  65. package/src/state/fileIndex.js +140 -0
  66. package/src/state/index.cjs +101 -0
  67. package/src/state/index.js +65 -0
  68. package/src/state/intentRegistry.js +84 -0
  69. package/src/state/migration.js +112 -0
  70. package/src/state/proofRunner.js +246 -0
  71. package/src/state/stateRoot.js +40 -0
  72. package/src/state/targetRegistry.js +109 -0
  73. package/src/state/workspaceId.js +40 -0
  74. package/src/state/workspaceRegistry.js +65 -0
  75. package/types.d.ts +175 -2
  76. package/dist/cli.js +0 -141
  77. package/dist/index.js +0 -52
  78. /package/dist/{lbe_engine.wasm → cli/lbe_engine.wasm} +0 -0
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ const FORMAT = 1;
6
+
7
+ // Directories always excluded from indexing
8
+ const IGNORE_DIRS = new Set([
9
+ '.git', 'node_modules', 'dist', 'coverage', '.lbe',
10
+ ]);
11
+
12
+ // ── Helpers ───────────────────────────────────────────────────────────────────
13
+
14
+ /**
15
+ * Computes the SHA-256 hash of a file's contents.
16
+ *
17
+ * @param {string} filePath Absolute path to the file.
18
+ * @returns {string} Hex digest.
19
+ */
20
+ export function hashFile(filePath) {
21
+ const buf = fs.readFileSync(filePath);
22
+ return crypto.createHash('sha256').update(buf).digest('hex');
23
+ }
24
+
25
+ /**
26
+ * Recursively walks a directory, yielding relative posix paths and stats.
27
+ * Skips IGNORE_DIRS entries and any symlinks.
28
+ */
29
+ function* walk(root, rel = '') {
30
+ const entries = fs.readdirSync(path.join(root, rel), { withFileTypes: true });
31
+ for (const entry of entries) {
32
+ if (entry.isSymbolicLink()) continue;
33
+ const entryRel = rel ? rel + '/' + entry.name : entry.name;
34
+ if (entry.isDirectory()) {
35
+ if (IGNORE_DIRS.has(entry.name)) continue;
36
+ yield* walk(root, entryRel);
37
+ } else if (entry.isFile()) {
38
+ yield entryRel;
39
+ }
40
+ }
41
+ }
42
+
43
+ // ── Public API ────────────────────────────────────────────────────────────────
44
+
45
+ /**
46
+ * Indexes all files under workspaceRoot and writes the result to outputPath.
47
+ *
48
+ * @param {string} workspaceRoot Absolute workspace root to index.
49
+ * @param {string} outputPath Where to write the JSON index file.
50
+ * @param {object} [opts] Reserved for future use.
51
+ * @returns {object} The index object that was written.
52
+ */
53
+ export function indexFiles(workspaceRoot, outputPath, _opts = {}) {
54
+ if (!workspaceRoot || typeof workspaceRoot !== 'string') {
55
+ throw new TypeError('workspaceRoot must be a non-empty string');
56
+ }
57
+ if (!outputPath || typeof outputPath !== 'string') {
58
+ throw new TypeError('outputPath must be a non-empty string');
59
+ }
60
+
61
+ const absRoot = path.resolve(workspaceRoot);
62
+ const absOutput = path.resolve(outputPath);
63
+ const outputNorm = absOutput.replace(/\\/g, '/');
64
+ const rootNorm = absRoot.replace(/\\/g, '/');
65
+
66
+ // Reject output paths inside the indexed tree — the output file itself
67
+ // would be included in the next index, creating a self-referential loop.
68
+ if (outputNorm.startsWith(rootNorm + '/') || outputNorm === rootNorm) {
69
+ // Allow if output is under an ignored directory (e.g. .lbe/)
70
+ const relOutput = path.relative(absRoot, absOutput).replace(/\\/g, '/');
71
+ const firstSeg = relOutput.split('/')[0];
72
+ if (!IGNORE_DIRS.has(firstSeg)) {
73
+ throw new Error(
74
+ `outputPath "${outputPath}" is inside the indexed tree. ` +
75
+ 'Place the output in an excluded directory (e.g. .lbe/) or outside the root.'
76
+ );
77
+ }
78
+ }
79
+
80
+ const files = {};
81
+ for (const relPath of walk(absRoot)) {
82
+ const absPath = path.join(absRoot, relPath);
83
+ const stat = fs.statSync(absPath);
84
+ files[relPath] = {
85
+ sha256: hashFile(absPath),
86
+ size: stat.size,
87
+ mtimeMs: stat.mtimeMs,
88
+ };
89
+ }
90
+
91
+ const index = {
92
+ format: FORMAT,
93
+ ts: new Date().toISOString(),
94
+ workspace: absRoot.replace(/\\/g, '/'),
95
+ files,
96
+ };
97
+
98
+ const outDir = path.dirname(absOutput);
99
+ if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
100
+ fs.writeFileSync(absOutput, JSON.stringify(index, null, 2) + '\n', 'utf8');
101
+ return index;
102
+ }
103
+
104
+ /**
105
+ * Computes the diff between two file index snapshots.
106
+ *
107
+ * @param {string} beforePath Path to the before JSON index.
108
+ * @param {string} afterPath Path to the after JSON index.
109
+ * @returns {object} { added, removed, changed, unchanged }
110
+ */
111
+ export function diffIndex(beforePath, afterPath) {
112
+ function loadIndex(p) {
113
+ if (!p || !fs.existsSync(p)) return { files: {} };
114
+ try { return JSON.parse(fs.readFileSync(p, 'utf8')); }
115
+ catch (_) { return { files: {} }; }
116
+ }
117
+
118
+ const before = loadIndex(beforePath);
119
+ const after = loadIndex(afterPath);
120
+
121
+ const beforeFiles = before.files || {};
122
+ const afterFiles = after.files || {};
123
+
124
+ const added = [];
125
+ const removed = [];
126
+ const changed = [];
127
+ const unchanged = [];
128
+
129
+ const allKeys = new Set([...Object.keys(beforeFiles), ...Object.keys(afterFiles)]);
130
+ for (const k of allKeys) {
131
+ const b = beforeFiles[k];
132
+ const a = afterFiles[k];
133
+ if (!b) { added.push(k); }
134
+ else if (!a) { removed.push(k); }
135
+ else if (b.sha256 !== a.sha256){ changed.push(k); }
136
+ else { unchanged.push(k); }
137
+ }
138
+
139
+ return { added, removed, changed, unchanged };
140
+ }
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+ // CJS mirror of src/state/index.js — minimal subset for register.cjs preload use.
3
+ //
4
+ // ESM modules cannot be require()'d synchronously. This file duplicates the
5
+ // pure path-computation functions inline so register.cjs has no ESM dependency.
6
+ //
7
+ // Rule: this file must remain pure CJS. No import(), no top-level await.
8
+ // Policy authority: .lbe/policy.json remains authoritative. This file resolves
9
+ // central state paths only — it does not read or write policy.
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+ const crypto = require('crypto');
15
+
16
+ // ── Inline mirrors of ESM functions (pure, no side effects) ─────────────────
17
+
18
+ function canonicalWorkspacePath(workspaceRoot) {
19
+ var resolved;
20
+ try {
21
+ resolved = fs.realpathSync.native(workspaceRoot);
22
+ } catch (_) {
23
+ resolved = path.resolve(workspaceRoot);
24
+ }
25
+ var normalised = path.normalize(resolved);
26
+ return process.platform === 'win32' ? normalised.toLowerCase() : normalised;
27
+ }
28
+
29
+ function workspaceId(workspaceRoot) {
30
+ return crypto.createHash('sha256').update(canonicalWorkspacePath(workspaceRoot)).digest('hex');
31
+ }
32
+
33
+ function stateRoot() {
34
+ var home = os.homedir();
35
+
36
+ if (process.platform === 'win32') {
37
+ var localAppData = process.env.LOCALAPPDATA || path.join(home, 'AppData', 'Local');
38
+ return path.join(localAppData, 'LetterBlack', 'Sentinel');
39
+ }
40
+
41
+ if (process.platform === 'darwin') {
42
+ var appSupport = process.env.HOME
43
+ ? path.join(process.env.HOME, 'Library', 'Application Support')
44
+ : path.join(home, 'Library', 'Application Support');
45
+ return path.join(appSupport, 'LetterBlack', 'Sentinel');
46
+ }
47
+
48
+ // Linux / other POSIX
49
+ var xdgData = process.env.XDG_DATA_HOME || path.join(home, '.local', 'share');
50
+ return path.join(xdgData, 'LetterBlack', 'Sentinel');
51
+ }
52
+
53
+ function workspaceStateDir(root, id) {
54
+ return path.join(root, 'workspaces', id.slice(0, 2), id.slice(2, 4), id.slice(4, 6), id);
55
+ }
56
+
57
+ function buildPaths(dir) {
58
+ return {
59
+ workspace: path.join(dir, 'workspace.json'),
60
+ events: path.join(dir, 'lbe-events.jsonl'),
61
+ intent: path.join(dir, 'intent.jsonl'),
62
+ targetRegistry: path.join(dir, 'target_registry.jsonl'),
63
+ fileIndexDir: path.join(dir, 'file-index'),
64
+ fileIndexBefore: path.join(dir, 'file-index', 'before.json'),
65
+ fileIndexAfter: path.join(dir, 'file-index', 'after.json'),
66
+ proofDir: path.join(dir, 'proof'),
67
+ proofLatest: path.join(dir, 'proof', 'latest.json'),
68
+ };
69
+ }
70
+
71
+ // ── Public API ───────────────────────────────────────────────────────────────
72
+
73
+ /**
74
+ * Resolves (and creates) the central state directory for a workspace.
75
+ * Safe to call at preload time — only mkdirSync, no registry or audit writes.
76
+ *
77
+ * @param {string} workspaceRoot Absolute path to the workspace root.
78
+ * @returns {{ stateDir: string, workspaceId: string, paths: object }}
79
+ */
80
+ function resolveWorkspaceStateSyncCjs(workspaceRoot) {
81
+ var root = stateRoot();
82
+ var id = workspaceId(workspaceRoot);
83
+ var dir = workspaceStateDir(root, id);
84
+
85
+ fs.mkdirSync(dir, { recursive: true });
86
+ fs.mkdirSync(path.join(dir, 'file-index'), { recursive: true });
87
+ fs.mkdirSync(path.join(dir, 'proof'), { recursive: true });
88
+
89
+ return {
90
+ stateDir: dir,
91
+ workspaceId: id,
92
+ paths: buildPaths(dir),
93
+ };
94
+ }
95
+
96
+ module.exports = {
97
+ resolveWorkspaceStateSyncCjs,
98
+ stateRoot,
99
+ workspaceId,
100
+ workspaceStateDir,
101
+ };
@@ -0,0 +1,65 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { workspaceId, workspaceStateDir } from './workspaceId.js';
4
+ import { stateRoot } from './stateRoot.js';
5
+ import { registerWorkspace } from './workspaceRegistry.js';
6
+ import { migrateLegacyEvents } from './migration.js';
7
+
8
+ export { workspaceId, workspaceStateDir, stateRoot };
9
+
10
+ /**
11
+ * Resolves (and creates) the central state directory for a workspace.
12
+ *
13
+ * Returned paths object is the contract for all subsequent state operations.
14
+ * The ESM resolver also refreshes the central workspace registry. The hook CJS
15
+ * resolver remains separate and deliberately does not perform this write.
16
+ *
17
+ * Safe to call multiple times — mkdirSync with recursive:true is idempotent.
18
+ *
19
+ * @param {string} workspaceRoot Absolute path to the workspace root.
20
+ * @returns {{ stateDir: string, workspaceId: string, paths: WorkspacePaths }}
21
+ */
22
+ export function resolveWorkspaceState(workspaceRoot) {
23
+ const root = stateRoot();
24
+ const id = workspaceId(workspaceRoot);
25
+ const dir = workspaceStateDir(root, id);
26
+
27
+ // Ensure directory tree exists — idempotent.
28
+ fs.mkdirSync(dir, { recursive: true });
29
+ fs.mkdirSync(path.join(dir, 'file-index'), { recursive: true });
30
+ fs.mkdirSync(path.join(dir, 'proof'), { recursive: true });
31
+ registerWorkspace(path.join(root, 'registry.json'), id, workspaceRoot);
32
+ migrateLegacyEvents(workspaceRoot, dir);
33
+
34
+ return {
35
+ stateDir: dir,
36
+ workspaceId: id,
37
+ paths: buildPaths(dir),
38
+ };
39
+ }
40
+
41
+ /**
42
+ * @typedef {Object} WorkspacePaths
43
+ * @property {string} workspace workspace.json
44
+ * @property {string} events lbe-events.jsonl (central audit mirror)
45
+ * @property {string} intent intent.jsonl
46
+ * @property {string} targetRegistry target_registry.jsonl
47
+ * @property {string} fileIndexDir file-index/
48
+ * @property {string} fileIndexBefore file-index/before.json
49
+ * @property {string} fileIndexAfter file-index/after.json
50
+ * @property {string} proofDir proof/
51
+ * @property {string} proofLatest proof/latest.json
52
+ */
53
+ function buildPaths(dir) {
54
+ return {
55
+ workspace: path.join(dir, 'workspace.json'),
56
+ events: path.join(dir, 'lbe-events.jsonl'),
57
+ intent: path.join(dir, 'intent.jsonl'),
58
+ targetRegistry: path.join(dir, 'target_registry.jsonl'),
59
+ fileIndexDir: path.join(dir, 'file-index'),
60
+ fileIndexBefore: path.join(dir, 'file-index', 'before.json'),
61
+ fileIndexAfter: path.join(dir, 'file-index', 'after.json'),
62
+ proofDir: path.join(dir, 'proof'),
63
+ proofLatest: path.join(dir, 'proof', 'latest.json'),
64
+ };
65
+ }
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ const INTENT_FILE = 'intent.jsonl';
6
+
7
+ // ── Validation ────────────────────────────────────────────────────────────────
8
+
9
+ function validateFilePaths(paths, fieldName) {
10
+ if (!Array.isArray(paths)) return;
11
+ for (const p of paths) {
12
+ if (typeof p !== 'string') continue;
13
+ const normalized = p.replace(/\\/g, '/');
14
+ const isAbsolute = path.isAbsolute(p) || path.win32.isAbsolute(p) || path.win32.isAbsolute(normalized);
15
+ if (isAbsolute) {
16
+ throw new Error(`${fieldName}: absolute paths are not allowed: "${p}"`);
17
+ }
18
+ if (normalized.split('/').some(seg => seg === '..')) {
19
+ throw new Error(`${fieldName}: ../ traversal is not allowed: "${p}"`);
20
+ }
21
+ }
22
+ }
23
+
24
+ function normalizeFilePaths(paths) {
25
+ if (!Array.isArray(paths)) return paths;
26
+ return paths.map(p => (typeof p === 'string' ? p.replace(/\\/g, '/') : p));
27
+ }
28
+
29
+ // ── Public API ────────────────────────────────────────────────────────────────
30
+
31
+ /**
32
+ * Registers an intent record, appending it to stateDir/intent.jsonl.
33
+ *
34
+ * @param {string} stateDir Central workspace state directory.
35
+ * @param {object} intent Intent record (task, reason, allowed_files, …).
36
+ * @returns {object} The stored record (with generated intent_id and ts).
37
+ */
38
+ export function registerIntent(stateDir, intent) {
39
+ if (!stateDir || typeof stateDir !== 'string') {
40
+ throw new TypeError('stateDir must be a non-empty string');
41
+ }
42
+ if (!intent || typeof intent !== 'object') {
43
+ throw new TypeError('intent must be a plain object');
44
+ }
45
+
46
+ validateFilePaths(intent.allowed_files, 'allowed_files');
47
+ validateFilePaths(intent.forbidden_files, 'forbidden_files');
48
+
49
+ const record = {
50
+ intent_id: intent.intent_id || ('i_' + crypto.randomBytes(8).toString('hex')),
51
+ ts: intent.ts || new Date().toISOString(),
52
+ task: intent.task ?? '',
53
+ reason: intent.reason ?? '',
54
+ allowed_files: normalizeFilePaths(intent.allowed_files ?? []),
55
+ forbidden_files: normalizeFilePaths(intent.forbidden_files ?? []),
56
+ declared_targets: intent.declared_targets ?? [],
57
+ risk: intent.risk ?? 'unknown',
58
+ };
59
+
60
+ const filePath = path.join(stateDir, INTENT_FILE);
61
+ const dir = path.dirname(filePath);
62
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
63
+
64
+ fs.appendFileSync(filePath, JSON.stringify(record) + '\n', 'utf8');
65
+ return record;
66
+ }
67
+
68
+ /**
69
+ * Loads all intent records from stateDir/intent.jsonl.
70
+ * Malformed lines are silently skipped.
71
+ *
72
+ * @param {string} stateDir Central workspace state directory.
73
+ * @returns {object[]} Array of parsed intent records (may be empty).
74
+ */
75
+ export function loadIntents(stateDir) {
76
+ const filePath = path.join(stateDir, INTENT_FILE);
77
+ if (!fs.existsSync(filePath)) return [];
78
+ const raw = fs.readFileSync(filePath, 'utf8').trim();
79
+ if (!raw) return [];
80
+ return raw.split('\n').reduce((acc, line) => {
81
+ try { acc.push(JSON.parse(line)); } catch (_) { /* ignore */ }
82
+ return acc;
83
+ }, []);
84
+ }
@@ -0,0 +1,112 @@
1
+ import crypto from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { atomicAppendFileSync, atomicWriteFileSync, withFileLock } from '../core/atomicWrite.js';
5
+
6
+ const SOURCE = '.lbe/events.jsonl';
7
+
8
+ function sha256(data) {
9
+ return crypto.createHash('sha256').update(data).digest('hex');
10
+ }
11
+
12
+ function readMarker(markerPath) {
13
+ try {
14
+ return JSON.parse(fs.readFileSync(markerPath, 'utf8'));
15
+ } catch (_) {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function readCentralLines(eventsPath) {
21
+ try {
22
+ return new Set(fs.readFileSync(eventsPath, 'utf8').split(/\r?\n/).filter(Boolean));
23
+ } catch (_) {
24
+ return new Set();
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Imports a legacy project-local event log into the central log once per source
30
+ * content hash. The legacy source is read-only throughout this process.
31
+ */
32
+ export function migrateLegacyEvents(workspaceRoot, stateDir) {
33
+ const sourcePath = path.join(workspaceRoot, '.lbe', 'events.jsonl');
34
+ const migrationDir = path.join(stateDir, 'migration');
35
+ const markerPath = path.join(migrationDir, 'events-v1.json');
36
+ const invalidPath = path.join(migrationDir, 'migration-invalid.jsonl');
37
+ const result = {
38
+ attempted: false,
39
+ imported_count: 0,
40
+ skipped_duplicate_count: 0,
41
+ invalid_count: 0,
42
+ markerPath,
43
+ invalidPath,
44
+ };
45
+
46
+ let source;
47
+ try {
48
+ if (!fs.existsSync(sourcePath)) return result;
49
+ source = fs.readFileSync(sourcePath, 'utf8');
50
+ } catch (_) {
51
+ return result;
52
+ }
53
+
54
+ result.attempted = true;
55
+ const sourceSha256 = sha256(source);
56
+ const marker = readMarker(markerPath);
57
+ if (marker?.format === 1 && marker.source_sha256 === sourceSha256) return result;
58
+
59
+ const validLines = [];
60
+ const invalidLines = [];
61
+ for (const [index, rawLine] of source.split(/\r?\n/).entries()) {
62
+ const line = rawLine.trim();
63
+ if (!line) continue;
64
+ try {
65
+ JSON.parse(line);
66
+ validLines.push(line);
67
+ } catch (_) {
68
+ invalidLines.push({ source: SOURCE, line_number: index + 1, line, source_sha256: sourceSha256 });
69
+ }
70
+ }
71
+
72
+ const eventsPath = path.join(stateDir, 'lbe-events.jsonl');
73
+ try {
74
+ withFileLock(eventsPath, () => {
75
+ const centralLines = readCentralLines(eventsPath);
76
+ const imported = [];
77
+ for (const line of validLines) {
78
+ if (centralLines.has(line)) {
79
+ result.skipped_duplicate_count++;
80
+ continue;
81
+ }
82
+ centralLines.add(line);
83
+ imported.push(line);
84
+ result.imported_count++;
85
+ }
86
+ if (imported.length > 0) {
87
+ const existing = fs.existsSync(eventsPath) ? fs.readFileSync(eventsPath, 'utf8') : '';
88
+ atomicWriteFileSync(eventsPath, existing + (existing && !existing.endsWith('\n') ? '\n' : '') + imported.join('\n') + '\n', 'utf8');
89
+ }
90
+ });
91
+
92
+ if (invalidLines.length > 0) {
93
+ atomicAppendFileSync(invalidPath, invalidLines.map(line => JSON.stringify(line) + '\n').join(''), { encoding: 'utf8' });
94
+ result.invalid_count = invalidLines.length;
95
+ }
96
+
97
+ fs.mkdirSync(migrationDir, { recursive: true });
98
+ atomicWriteFileSync(markerPath, JSON.stringify({
99
+ format: 1,
100
+ source: SOURCE,
101
+ source_sha256: sourceSha256,
102
+ migrated_at: new Date().toISOString(),
103
+ imported_count: result.imported_count,
104
+ skipped_duplicate_count: result.skipped_duplicate_count,
105
+ invalid_count: result.invalid_count,
106
+ }, null, 2) + '\n', 'utf8');
107
+ } catch (_) {
108
+ // Central-state migration is opportunistic; resolution must remain safe.
109
+ }
110
+
111
+ return result;
112
+ }