@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.
Files changed (105) hide show
  1. package/CHANGELOG.md +18 -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,204 @@
1
+ #!/usr/bin/env node
2
+ // recorder-overhead.mjs (TASK-001, SPEC §2 G2-FR03) — paired baseline vs
3
+ // instrumented overhead measurement for the observer seam. REAL measurement
4
+ // on this host; no fabricated targets.
5
+ //
6
+ // Workload: hook-telemetry-shaped events derived deterministically from the
7
+ // seeded corpus (tests/fixtures/observability/corpus.json, seed=42) — one
8
+ // event per corpus record, timings taken from the record's own duration so
9
+ // the fixture stays the single source of truth.
10
+ //
11
+ // baseline arm: appendFileSync(JSON.stringify(row)) to a jsonl file —
12
+ // the status-quo owner write path (hook-telemetry append).
13
+ // instrumented arm: adaptHookTelemetryEvent(row) → recorder.emit() → flush()
14
+ // per event into a real temp segment store — the seam a
15
+ // hook would run inside its deadline.
16
+ //
17
+ // Both arms repeat the same event corpus --reps times, interleaved per rep
18
+ // (baseline pass, then instrumented pass) so drift hits both arms equally.
19
+ //
20
+ // node scripts/bench/recorder-overhead.mjs [--json] [--reps N]
21
+
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { execSync } from 'node:child_process';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import { adaptHookTelemetryEvent } from '../../src/core/observability/adapters/hookTelemetryAdapter.js';
29
+ import { createAdapterContext } from '../../src/core/observability/adapters/common.js';
30
+ import { createRecorder } from '../../src/core/observability/emit/recorder.js';
31
+
32
+ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
33
+ const corpusPath = path.join(repoRoot, 'tests/fixtures/observability/corpus.json');
34
+
35
+ const args = process.argv.slice(2);
36
+ const json = args.includes('--json');
37
+ const repsFlag = args.indexOf('--reps');
38
+ const REPS = repsFlag !== -1 ? Number.parseInt(args[repsFlag + 1], 10) : 30;
39
+ if (!Number.isInteger(REPS) || REPS <= 0) {
40
+ console.error('--reps must be a positive integer');
41
+ process.exit(2);
42
+ }
43
+
44
+ function percentile(sorted, p) {
45
+ if (sorted.length === 0) return null;
46
+ const idx = Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1);
47
+ return sorted[Math.max(0, idx)];
48
+ }
49
+
50
+ function stats(samples) {
51
+ const sorted = [...samples].sort((a, b) => a - b);
52
+ const n = sorted.length;
53
+ const mean = n ? sorted.reduce((s, v) => s + v, 0) / n : 0;
54
+ const variance = n ? sorted.reduce((s, v) => s + (v - mean) ** 2, 0) / n : 0;
55
+ return {
56
+ n,
57
+ min: n ? sorted[0] : null,
58
+ max: n ? sorted[n - 1] : null,
59
+ mean: Number(mean.toFixed(4)),
60
+ stdev: Number(Math.sqrt(variance).toFixed(4)),
61
+ p50: percentile(sorted, 50),
62
+ p95: percentile(sorted, 95),
63
+ p99: percentile(sorted, 99),
64
+ };
65
+ }
66
+
67
+ function dirBytes(dir) {
68
+ let total = 0;
69
+ if (!fs.existsSync(dir)) return 0;
70
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
71
+ const p = path.join(dir, entry.name);
72
+ if (entry.isDirectory()) total += dirBytes(p);
73
+ else total += fs.statSync(p).size;
74
+ }
75
+ return total;
76
+ }
77
+
78
+ function gitRevision() {
79
+ try {
80
+ return execSync('git rev-parse --short HEAD', { cwd: repoRoot, encoding: 'utf8' }).trim();
81
+ } catch {
82
+ return 'unknown';
83
+ }
84
+ }
85
+
86
+ // --- workload: corpus records → hook-telemetry-shaped rows ------------------
87
+ let corpus;
88
+ let sourceRecords;
89
+ try {
90
+ corpus = JSON.parse(fs.readFileSync(corpusPath, 'utf8'));
91
+ sourceRecords = corpus.traces.flatMap((t) => t.records);
92
+ } catch {
93
+ console.error(
94
+ `recorder-overhead: cannot load ${corpusPath} — run 'node scripts/bench/data-foundation.mjs --fixture' first`,
95
+ );
96
+ process.exit(1);
97
+ }
98
+ const events = sourceRecords.map((r, i) => ({
99
+ v: 1,
100
+ ts: Date.parse(r.wall_time_utc),
101
+ hookEvent: 'PostToolUse',
102
+ toolName: typeof r.payload?.tool_name === 'string' ? r.payload.tool_name : 'BenchTool',
103
+ toolUseId: `bench-${r.record_id ?? i}`,
104
+ hook: 'post-tool-use/bench-hook',
105
+ elapsedMs: Number.isFinite(r.payload?.duration_ms) ? r.payload.duration_ms : (i % 53),
106
+ outcome: 'ok',
107
+ ...(i % 3 === 0 ? { stageMs: i % 7 } : {}),
108
+ }));
109
+
110
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ukit-bench-'));
111
+ const baselineFile = path.join(tmpRoot, 'baseline.jsonl');
112
+ const segmentRoot = path.join(tmpRoot, 'segments');
113
+
114
+ const baselineMs = [];
115
+ const emitMs = [];
116
+ const flushMs = [];
117
+
118
+ const heapBefore = process.memoryUsage().heapUsed;
119
+ let recordsWritten = 0;
120
+ let recordsEmitted = 0;
121
+ let lastHealth = null;
122
+ const wallStart = Date.now();
123
+
124
+ const recorder = createRecorder({
125
+ root: segmentRoot,
126
+ config: { observability: { stage: 'default' } },
127
+ });
128
+
129
+ try {
130
+ for (let rep = 0; rep < REPS; rep += 1) {
131
+ // baseline arm — status-quo owner append path
132
+ for (const event of events) {
133
+ const line = JSON.stringify(event) + '\n';
134
+ const t0 = process.hrtime.bigint();
135
+ fs.appendFileSync(baselineFile, line);
136
+ baselineMs.push(Number(process.hrtime.bigint() - t0) / 1e6);
137
+ }
138
+ // instrumented arm — adapt → emit → flush per event (hook-deadline seam)
139
+ const ctx = createAdapterContext({ writerId: 'adapter-hook-bench' });
140
+ for (const event of events) {
141
+ const t0 = process.hrtime.bigint();
142
+ const record = adaptHookTelemetryEvent(event, ctx);
143
+ if (record) recorder.emit(record);
144
+ emitMs.push(Number(process.hrtime.bigint() - t0) / 1e6);
145
+
146
+ const f0 = process.hrtime.bigint();
147
+ await recorder.flush({ deadlineMs: 100 });
148
+ flushMs.push(Number(process.hrtime.bigint() - f0) / 1e6);
149
+ }
150
+ const h = recorder.health();
151
+ lastHealth = h;
152
+ recordsEmitted += events.length;
153
+ }
154
+ } finally {
155
+ const heapAfter = process.memoryUsage().heapUsed;
156
+ const segmentBytes = dirBytes(segmentRoot);
157
+ recordsWritten = lastHealth ? lastHealth.written : 0;
158
+
159
+ const report = {
160
+ bench: 'recorder-overhead',
161
+ host: {
162
+ platform: os.platform(),
163
+ arch: os.arch(),
164
+ release: os.release(),
165
+ cpu: os.cpus()?.[0]?.model ?? 'unknown',
166
+ node: process.version,
167
+ },
168
+ revision: gitRevision(),
169
+ measured_at: new Date().toISOString(),
170
+ workload: {
171
+ corpus: path.relative(repoRoot, corpusPath),
172
+ seed: corpus.seed,
173
+ events_per_rep: events.length,
174
+ reps: REPS,
175
+ },
176
+ reps: REPS,
177
+ baseline: stats(baselineMs),
178
+ emit: stats(emitMs),
179
+ flush: stats(flushMs),
180
+ bytesPerRecord: recordsWritten > 0 ? Math.round(segmentBytes / recordsWritten) : null,
181
+ heapDeltaBytes: heapAfter - heapBefore,
182
+ wall_clock_ms: Date.now() - wallStart,
183
+ instrumented: {
184
+ events: recordsEmitted,
185
+ recordsWritten,
186
+ segmentBytes,
187
+ health: lastHealth,
188
+ },
189
+ baselineBytes: fs.existsSync(baselineFile) ? fs.statSync(baselineFile).size : 0,
190
+ };
191
+
192
+ if (json) {
193
+ process.stdout.write(JSON.stringify(report, null, 2) + '\n');
194
+ } else {
195
+ const f = (v) => (v === null ? 'null' : v.toFixed(3));
196
+ console.log(`reps=${report.reps} events/rep=${events.length} node=${report.host.node} rev=${report.revision}`);
197
+ console.log(`baseline append ms p50=${f(report.baseline.p50)} p95=${f(report.baseline.p95)} p99=${f(report.baseline.p99)}`);
198
+ console.log(`instrumented emit ms p50=${f(report.emit.p50)} p95=${f(report.emit.p95)} p99=${f(report.emit.p99)} stdev=${report.emit.stdev}`);
199
+ console.log(`instrumented flush ms p50=${f(report.flush.p50)} p95=${f(report.flush.p95)} p99=${f(report.flush.p99)}`);
200
+ console.log(`bytes/record=${report.bytesPerRecord} heapDelta=${report.heapDeltaBytes}B written=${recordsWritten}`);
201
+ }
202
+
203
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
204
+ }
@@ -0,0 +1,451 @@
1
+ #!/usr/bin/env node
2
+ // TASK-005 — SQLite viability spike (SPEC §5 FR-009/FR-010, M03-03).
3
+ //
4
+ // Prototype-only probe matrix answering the runtime/packaging questions for
5
+ // gate G3: node:sqlite availability across Node >=20 (20.x needs
6
+ // --experimental-sqlite; >=22.5 is unflagged), WAL behavior, busy-timeout
7
+ // under a second writer, concurrent-writer burst, backup(), schema migration
8
+ // roundtrip, file footprint vs JSON, license. Every cell gets an explicit
9
+ // verdict `tested|unsupported|failed` + detail string — verdicts are data.
10
+ //
11
+ // Isolation (FR-010): writes only under a mkdtemp dir or an explicit
12
+ // --wal-dir target; never touches repo stores or package.json. Temp dirs are
13
+ // cleaned up; files created inside --wal-dir are prefixed `ukit-spike-` and
14
+ // removed after the run.
15
+ //
16
+ // Exit codes: 0 = ran to completion (verdicts are data), 1 = usage error,
17
+ // 2 = harness crash.
18
+ //
19
+ // Test hook: UKIT_SPIKE_SQLITE_IMPORT=fail forces the node:sqlite import to
20
+ // fail so the unsupported path is exercisable on Node >=22.5.
21
+
22
+ import { execSync, spawn } from 'node:child_process';
23
+ import fs from 'node:fs';
24
+ import os from 'node:os';
25
+ import path from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
29
+ const NODE = process.execPath;
30
+ const SPIKE_PREFIX = 'ukit-spike-';
31
+ const BURST_PROCESSES = 2;
32
+ const BURST_ROWS_PER_PROCESS = 25;
33
+ const FOOTPRINT_ROWS = 500;
34
+
35
+ const ALL_CELLS = [
36
+ 'node-sqlite-availability',
37
+ 'open-create',
38
+ 'wal-mode',
39
+ 'busy-timeout',
40
+ 'concurrent-writers',
41
+ 'backup',
42
+ 'migration-roundtrip',
43
+ 'footprint',
44
+ 'license',
45
+ ];
46
+
47
+ const USAGE = `Usage: node scripts/bench/sqlite-spike.mjs --out <file.json> [--wal-dir <dir>]
48
+
49
+ Options:
50
+ --out <file.json> Report output path; <file>.md is written beside it (required)
51
+ --wal-dir <dir> Target dir for the WAL/busy-timeout probes (default: mkdtemp
52
+ local dir). Point at a network-FS mount to probe WAL there.
53
+
54
+ Exit codes: 0 = completed (verdicts are data), 1 = usage error, 2 = crash.`;
55
+
56
+ // ---------- small helpers ----------
57
+
58
+ function parseArgs(argv) {
59
+ const opts = { out: null, walDir: null };
60
+ for (let i = 0; i < argv.length; i += 1) {
61
+ const arg = argv[i];
62
+ if (arg === '--out') { opts.out = argv[++i]; }
63
+ else if (arg === '--wal-dir') { opts.walDir = argv[++i]; }
64
+ else if (arg === '--help' || arg === '-h') { console.log(USAGE); process.exit(0); }
65
+ else { throw new Error(`unknown argument: ${arg}\n${USAGE}`); }
66
+ }
67
+ if (!opts.out) throw new Error(`--out is required\n${USAGE}`);
68
+ return opts;
69
+ }
70
+
71
+ function cell(verdict, detail) {
72
+ return { verdict, detail: String(detail) };
73
+ }
74
+
75
+ function spawnNode(argv, { timeoutMs = 30_000 } = {}) {
76
+ return new Promise((resolve) => {
77
+ const child = spawn(NODE, argv, { stdio: ['ignore', 'pipe', 'pipe'] });
78
+ let stdout = '';
79
+ let stderr = '';
80
+ const timer = setTimeout(() => {
81
+ child.kill('SIGKILL');
82
+ resolve({ exitCode: null, signal: 'SIGKILL', stdout, stderr, timedOut: true });
83
+ }, timeoutMs);
84
+ child.stdout.on('data', (d) => { stdout += d; });
85
+ child.stderr.on('data', (d) => { stderr += d; });
86
+ child.on('error', (error) => {
87
+ clearTimeout(timer);
88
+ resolve({ exitCode: null, signal: null, stdout, stderr: stderr + String(error), timedOut: false });
89
+ });
90
+ child.on('close', (exitCode, signal) => {
91
+ clearTimeout(timer);
92
+ resolve({ exitCode, signal, stdout, stderr, timedOut: false });
93
+ });
94
+ });
95
+ }
96
+
97
+ /** Child helper source: open db, BEGIN EXCLUSIVE, print 'locked', hold, commit. */
98
+ const LOCK_HOLDER_SOURCE = `
99
+ import('node:sqlite').then(({ DatabaseSync }) => {
100
+ const db = new DatabaseSync(process.argv[1]);
101
+ db.exec('CREATE TABLE IF NOT EXISTS spike_lock (id INTEGER PRIMARY KEY, v TEXT)');
102
+ db.exec('BEGIN EXCLUSIVE');
103
+ process.stdout.write('locked\\n');
104
+ setTimeout(() => { db.exec('COMMIT'); db.close(); process.exit(0); }, 1500);
105
+ }).catch((e) => { process.stderr.write(String(e)); process.exit(1); });
106
+ `;
107
+
108
+ /** Child helper source: insert N rows with busy_timeout, print inserted count. */
109
+ const BURST_WRITER_SOURCE = `
110
+ import('node:sqlite').then(({ DatabaseSync }) => {
111
+ const [dbPath, tag, n] = [process.argv[1], process.argv[2], Number(process.argv[3])];
112
+ const db = new DatabaseSync(dbPath);
113
+ db.exec('PRAGMA busy_timeout = 10000');
114
+ const ins = db.prepare('INSERT INTO spike_burst (writer, seq) VALUES (?, ?)');
115
+ for (let i = 0; i < n; i += 1) ins.run(tag, i);
116
+ db.close();
117
+ process.stdout.write(String(n));
118
+ process.exit(0);
119
+ }).catch((e) => { process.stderr.write(String(e)); process.exit(1); });
120
+ `;
121
+
122
+ // ---------- probes ----------
123
+
124
+ async function probeAvailability() {
125
+ const major = Number(process.versions.node.split('.')[0]);
126
+ const minor = Number(process.versions.node.split('.')[1]);
127
+ const flagNote = major === 20
128
+ ? 'Node 20.x requires --experimental-sqlite'
129
+ : (major === 22 && minor < 5)
130
+ ? 'node:sqlite lands in Node 22.5; earlier 22.x unavailable'
131
+ : 'unflagged on this Node';
132
+ if (process.env.UKIT_SPIKE_SQLITE_IMPORT === 'fail') {
133
+ return cell('unsupported', `node:sqlite import failed (injected); ${flagNote}`);
134
+ }
135
+ try {
136
+ const mod = await import('node:sqlite');
137
+ if (typeof mod.DatabaseSync !== 'function') {
138
+ return cell('unsupported', `node:sqlite imported but DatabaseSync missing; ${flagNote}`);
139
+ }
140
+ const warned = major < 22 || (major === 22 && minor < 5)
141
+ ? 'experimental flag required'
142
+ : 'experimental-warning emitted on stderr (still experimental)';
143
+ return cell('tested', `node ${process.version}: DatabaseSync present; ${flagNote}; ${warned}`);
144
+ } catch (error) {
145
+ return cell('unsupported', `node:sqlite import failed: ${error?.message ?? error}; ${flagNote}`);
146
+ }
147
+ }
148
+
149
+ async function withDb(dir, name, fn) {
150
+ const { DatabaseSync } = await import('node:sqlite');
151
+ const dbPath = path.join(dir, `${SPIKE_PREFIX}${name}.db`);
152
+ const db = new DatabaseSync(dbPath);
153
+ try {
154
+ return await fn(db, dbPath);
155
+ } finally {
156
+ try { db.close(); } catch { /* already closed */ }
157
+ }
158
+ }
159
+
160
+ async function probeOpenCreate(dir) {
161
+ try {
162
+ return await withDb(dir, 'open', async (db, dbPath) => {
163
+ db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)');
164
+ db.prepare('INSERT INTO t (v) VALUES (?)').run('x');
165
+ const row = db.prepare('SELECT COUNT(*) AS n FROM t').get();
166
+ const bytes = fs.statSync(dbPath).size;
167
+ return cell('tested', `open+create+insert ok; rows=${row.n}; fileBytes=${bytes}`);
168
+ });
169
+ } catch (error) {
170
+ return cell('failed', `open-create threw: ${error?.message ?? error}`);
171
+ }
172
+ }
173
+
174
+ async function probeWalMode(dir) {
175
+ try {
176
+ return await withDb(dir, 'wal', async (db, dbPath) => {
177
+ const mode = db.prepare('PRAGMA journal_mode = WAL').get()?.journal_mode;
178
+ db.exec('CREATE TABLE t (id INTEGER PRIMARY KEY)');
179
+ db.prepare('INSERT INTO t DEFAULT VALUES').run();
180
+ const walExists = fs.existsSync(`${dbPath}-wal`) || mode === 'wal';
181
+ if (mode !== 'wal') {
182
+ return cell('failed', `journal_mode=${mode} (expected wal); dir=${path.basename(dir)}`);
183
+ }
184
+ return cell('tested', `journal_mode=wal verified; walFileObserved=${walExists}`);
185
+ });
186
+ } catch (error) {
187
+ return cell('failed', `wal-mode threw: ${error?.message ?? error}`);
188
+ }
189
+ }
190
+
191
+ async function probeBusyTimeout(dir) {
192
+ try {
193
+ const dbPath = path.join(dir, `${SPIKE_PREFIX}busy.db`);
194
+ const holder = spawn(NODE, ['--input-type=module', '-e', LOCK_HOLDER_SOURCE, dbPath],
195
+ { stdio: ['ignore', 'pipe', 'pipe'] });
196
+ let locked = false;
197
+ await new Promise((resolve) => {
198
+ const timer = setTimeout(resolve, 10_000);
199
+ holder.stdout.on('data', (d) => {
200
+ if (String(d).includes('locked')) { locked = true; clearTimeout(timer); resolve(); }
201
+ });
202
+ holder.on('exit', () => { clearTimeout(timer); resolve(); });
203
+ });
204
+ if (!locked) {
205
+ holder.kill('SIGKILL');
206
+ return cell('failed', 'lock-holder child never acquired the write lock');
207
+ }
208
+ const { DatabaseSync } = await import('node:sqlite');
209
+ const db = new DatabaseSync(dbPath);
210
+ try {
211
+ db.exec('PRAGMA busy_timeout = 5000');
212
+ const started = Date.now();
213
+ db.exec('INSERT INTO spike_lock (v) VALUES (\'waited\')');
214
+ const waitedMs = Date.now() - started;
215
+ const row = db.prepare('SELECT COUNT(*) AS n FROM spike_lock').get();
216
+ await new Promise((resolve) => holder.on('close', resolve));
217
+ return cell('tested', `busy_timeout=5000 waited ${waitedMs}ms for second writer; rows=${row.n}`);
218
+ } finally {
219
+ try { db.close(); } catch { /* noop */ }
220
+ }
221
+ } catch (error) {
222
+ return cell('failed', `busy-timeout threw: ${error?.message ?? error}`);
223
+ }
224
+ }
225
+
226
+ async function probeConcurrentWriters(dir) {
227
+ try {
228
+ const dbPath = path.join(dir, `${SPIKE_PREFIX}burst.db`);
229
+ // Parent creates the schema + WAL first: concurrent CREATE TABLE /
230
+ // journal_mode changes are not covered by busy_timeout and race.
231
+ {
232
+ const { DatabaseSync } = await import('node:sqlite');
233
+ const setup = new DatabaseSync(dbPath);
234
+ setup.exec('PRAGMA journal_mode = WAL');
235
+ setup.exec('CREATE TABLE IF NOT EXISTS spike_burst (id INTEGER PRIMARY KEY, writer TEXT, seq INTEGER)');
236
+ setup.close();
237
+ }
238
+ const expected = BURST_PROCESSES * BURST_ROWS_PER_PROCESS;
239
+ const runs = [];
240
+ for (let i = 0; i < BURST_PROCESSES; i += 1) {
241
+ runs.push(spawnNode(
242
+ ['--input-type=module', '-e', BURST_WRITER_SOURCE, dbPath, `w${i}`, String(BURST_ROWS_PER_PROCESS)],
243
+ { timeoutMs: 30_000 },
244
+ ));
245
+ }
246
+ const results = await Promise.all(runs);
247
+ const spawnFailures = results.filter((r) => r.exitCode !== 0).length;
248
+ const { DatabaseSync } = await import('node:sqlite');
249
+ const db = new DatabaseSync(dbPath);
250
+ let persisted = 0;
251
+ try {
252
+ persisted = db.prepare('SELECT COUNT(*) AS n FROM spike_burst').get().n;
253
+ } finally {
254
+ db.close();
255
+ }
256
+ const lost = expected - persisted;
257
+ if (spawnFailures > 0) {
258
+ const err = results.find((r) => r.exitCode !== 0)?.stderr?.trim() ?? 'unknown';
259
+ return cell('failed', `${spawnFailures}/${BURST_PROCESSES} writer processes failed: ${err}; persisted=${persisted}/${expected}`);
260
+ }
261
+ if (lost !== 0) {
262
+ return cell('failed', `lost updates: persisted=${persisted}/${expected} lost=${lost}`);
263
+ }
264
+ return cell('tested', `${BURST_PROCESSES} processes x ${BURST_ROWS_PER_PROCESS} inserts; persisted=${persisted}/${expected}; lost=0`);
265
+ } catch (error) {
266
+ return cell('failed', `concurrent-writers threw: ${error?.message ?? error}`);
267
+ }
268
+ }
269
+
270
+ async function probeBackup(dir) {
271
+ try {
272
+ const { DatabaseSync } = await import('node:sqlite');
273
+ const srcPath = path.join(dir, `${SPIKE_PREFIX}backup-src.db`);
274
+ const dstPath = path.join(dir, `${SPIKE_PREFIX}backup-dst.db`);
275
+ const src = new DatabaseSync(srcPath);
276
+ src.exec('CREATE TABLE t (id INTEGER PRIMARY KEY, v TEXT)');
277
+ const ins = src.prepare('INSERT INTO t (v) VALUES (?)');
278
+ for (let i = 0; i < 10; i += 1) ins.run(`row-${i}`);
279
+ if (typeof src.backup !== 'function') {
280
+ src.close();
281
+ return cell('unsupported', 'DatabaseSync.backup() not present on this Node');
282
+ }
283
+ await src.backup(dstPath);
284
+ src.close();
285
+ const dst = new DatabaseSync(dstPath);
286
+ const row = dst.prepare('SELECT COUNT(*) AS n FROM t').get();
287
+ dst.close();
288
+ if (row.n !== 10) return cell('failed', `backup copy row-count=${row.n} (expected 10)`);
289
+ return cell('tested', `backup() snapshot reopened; row-count=${row.n} equal`);
290
+ } catch (error) {
291
+ return cell('failed', `backup threw: ${error?.message ?? error}`);
292
+ }
293
+ }
294
+
295
+ async function probeMigration(dir) {
296
+ try {
297
+ return await withDb(dir, 'migrate', async (db) => {
298
+ db.exec('CREATE TABLE m (id INTEGER PRIMARY KEY, a TEXT)');
299
+ db.prepare('INSERT INTO m (a) VALUES (?)').run('before');
300
+ db.exec('ALTER TABLE m ADD COLUMN b TEXT DEFAULT \'d\'');
301
+ db.prepare('UPDATE m SET b = ? WHERE id = 1').run('after');
302
+ const row = db.prepare('SELECT a, b FROM m WHERE id = 1').get();
303
+ if (row.a !== 'before' || row.b !== 'after') {
304
+ return cell('failed', `roundtrip mismatch: ${JSON.stringify(row)}`);
305
+ }
306
+ return cell('tested', 'create→alter→read roundtrip ok; added column readable');
307
+ });
308
+ } catch (error) {
309
+ return cell('failed', `migration-roundtrip threw: ${error?.message ?? error}`);
310
+ }
311
+ }
312
+
313
+ async function probeFootprint(dir) {
314
+ try {
315
+ const rows = [];
316
+ for (let i = 0; i < FOOTPRINT_ROWS; i += 1) {
317
+ rows.push({ id: `rec-${i}`, text: `synthetic memory record ${i} lorem ipsum dolor sit amet`, kind: 'fact' });
318
+ }
319
+ const jsonPath = path.join(dir, `${SPIKE_PREFIX}footprint.json`);
320
+ fs.writeFileSync(jsonPath, JSON.stringify({ schemaVersion: 2, records: rows }));
321
+ const jsonBytes = fs.statSync(jsonPath).size;
322
+ return await withDb(dir, 'footprint', async (db, dbPath) => {
323
+ db.exec('CREATE TABLE r (id TEXT PRIMARY KEY, text TEXT, kind TEXT)');
324
+ const ins = db.prepare('INSERT INTO r (id, text, kind) VALUES (?, ?, ?)');
325
+ for (const r of rows) ins.run(r.id, r.text, r.kind);
326
+ db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
327
+ const dbBytes = fs.statSync(dbPath).size;
328
+ const ratio = jsonBytes > 0 ? (dbBytes / jsonBytes).toFixed(2) : 'n/a';
329
+ return cell('tested', `${FOOTPRINT_ROWS} rows: sqlite=${dbBytes}B json=${jsonBytes}B ratio=${ratio}`);
330
+ });
331
+ } catch (error) {
332
+ return cell('failed', `footprint threw: ${error?.message ?? error}`);
333
+ }
334
+ }
335
+
336
+ function probeLicense() {
337
+ return cell('tested', 'SQLite is public domain; node:sqlite ships with Node — no new runtime dependency, no license obligation');
338
+ }
339
+
340
+ // ---------- report ----------
341
+
342
+ function renderMarkdown(report) {
343
+ const lines = [];
344
+ lines.push('# SQLite viability spike report');
345
+ lines.push('');
346
+ lines.push(`- sha: \`${report.env.sha}\` · package: \`${report.env.package}\``);
347
+ lines.push(`- node: \`${report.env.node}\` · os: \`${report.env.os}\` · arch: \`${report.env.arch}\``);
348
+ lines.push(`- date: ${report.env.date} · wal-dir: \`${report.env.walDir}\``);
349
+ lines.push('');
350
+ lines.push('| cell | verdict | detail |');
351
+ lines.push('|------|---------|--------|');
352
+ for (const name of ALL_CELLS) {
353
+ const c = report.cells[name] ?? { verdict: 'failed', detail: 'cell missing' };
354
+ lines.push(`| ${name} | ${c.verdict} | ${String(c.detail).replaceAll('|', '\\|')} |`);
355
+ }
356
+ lines.push('');
357
+ const s = report.summary;
358
+ lines.push(`Summary: ${s.tested} tested · ${s.unsupported} unsupported · ${s.failed} failed (${s.total} cells)`);
359
+ lines.push('');
360
+ return lines.join('\n');
361
+ }
362
+
363
+ function summarize(cells) {
364
+ const s = { tested: 0, unsupported: 0, failed: 0, total: ALL_CELLS.length };
365
+ for (const name of ALL_CELLS) {
366
+ const v = cells[name]?.verdict ?? 'failed';
367
+ if (v === 'tested') s.tested += 1;
368
+ else if (v === 'unsupported') s.unsupported += 1;
369
+ else s.failed += 1;
370
+ }
371
+ return s;
372
+ }
373
+
374
+ // ---------- main ----------
375
+
376
+ async function main() {
377
+ const opts = parseArgs(process.argv.slice(2));
378
+
379
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ukit-sqlite-spike-'));
380
+ const walDir = opts.walDir ? path.resolve(opts.walDir) : tmpRoot;
381
+ const createdInWalDir = [];
382
+
383
+ const env = {
384
+ sha: 'unknown',
385
+ package: 'unknown',
386
+ node: process.version,
387
+ os: process.platform,
388
+ arch: process.arch,
389
+ date: new Date().toISOString(),
390
+ fs: opts.walDir ? 'user-provided (--wal-dir)' : 'local (mkdtemp)',
391
+ walDir,
392
+ };
393
+ try {
394
+ env.sha = execSync('git rev-parse HEAD', { cwd: REPO_ROOT, encoding: 'utf8' }).trim();
395
+ } catch { /* leave 'unknown' */ }
396
+ try {
397
+ env.package = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'package.json'), 'utf8')).version ?? 'unknown';
398
+ } catch { /* leave 'unknown' */ }
399
+
400
+ const cells = {};
401
+ try {
402
+ cells['node-sqlite-availability'] = await probeAvailability();
403
+
404
+ if (cells['node-sqlite-availability'].verdict === 'unsupported') {
405
+ for (const name of ALL_CELLS) {
406
+ if (!cells[name]) cells[name] = cell('unsupported', 'node:sqlite unavailable — probe not runnable');
407
+ }
408
+ } else {
409
+ cells['open-create'] = await probeOpenCreate(tmpRoot);
410
+ cells['wal-mode'] = await probeWalMode(walDir);
411
+ cells['busy-timeout'] = await probeBusyTimeout(walDir);
412
+ cells['concurrent-writers'] = await probeConcurrentWriters(tmpRoot);
413
+ cells.backup = await probeBackup(tmpRoot);
414
+ cells['migration-roundtrip'] = await probeMigration(tmpRoot);
415
+ cells.footprint = await probeFootprint(tmpRoot);
416
+ cells.license = probeLicense();
417
+ }
418
+ } finally {
419
+ // FR-010 cleanup: remove mkdtemp tree and any spike files left in --wal-dir.
420
+ try { fs.rmSync(tmpRoot, { recursive: true, force: true }); } catch { /* best effort */ }
421
+ if (opts.walDir) {
422
+ try {
423
+ for (const entry of fs.readdirSync(walDir)) {
424
+ if (entry.startsWith(SPIKE_PREFIX)) {
425
+ createdInWalDir.push(entry);
426
+ fs.rmSync(path.join(walDir, entry), { recursive: true, force: true });
427
+ }
428
+ }
429
+ } catch { /* unreadable wal-dir is already recorded as cell data */ }
430
+ }
431
+ }
432
+
433
+ const report = { env, cells, summary: summarize(cells) };
434
+
435
+ const outPath = path.resolve(opts.out);
436
+ fs.mkdirSync(path.dirname(outPath), { recursive: true });
437
+ fs.writeFileSync(outPath, `${JSON.stringify(report, null, 2)}\n`);
438
+ const mdPath = outPath.replace(/\.json$/i, '') + '.md';
439
+ fs.writeFileSync(mdPath, renderMarkdown(report));
440
+
441
+ const s = report.summary;
442
+ console.log(`[sqlite-spike] report written: ${outPath}`);
443
+ console.log(`[sqlite-spike] markdown: ${mdPath}`);
444
+ console.log(`[sqlite-spike] cells=${s.total} tested=${s.tested} unsupported=${s.unsupported} failed=${s.failed}`);
445
+ process.exit(0);
446
+ }
447
+
448
+ main().catch((error) => {
449
+ console.error(`[sqlite-spike] harness crash: ${error?.stack ?? error}`);
450
+ process.exit(2);
451
+ });