diff --git a/npm/packages/ruvector/bin/cli.js b/npm/packages/ruvector/bin/cli.js index 833426c39..e2d66dbf9 100755 --- a/npm/packages/ruvector/bin/cli.js +++ b/npm/packages/ruvector/bin/cli.js @@ -3029,6 +3029,51 @@ function sanitizeDimension(value, fallback) { return (Number.isInteger(value) && value > 0 && value <= 65536) ? value : fallback; } +/** + * Write a file atomically: serialize to a unique temp file in the same + * directory, then rename() over the target. rename() is atomic on a POSIX + * filesystem, so a concurrent reader never observes a torn/half-written file. + * This is the root-cause fix for the intelligence.json wipe under Claude Code, + * where every hook is a separate short-lived process sharing the store: a + * plain writeFileSync lets one process read a half-written file mid-write. + * The temp name carries pid + timestamp so concurrent writers never collide on + * the temp file; the final rename is last-writer-wins. + */ +function atomicWriteFileSync(filePath, data) { + const dir = path.dirname(filePath); + const tmp = path.join(dir, `.${path.basename(filePath)}.tmp.${process.pid}.${Date.now()}`); + try { + fs.writeFileSync(tmp, data); + fs.renameSync(tmp, filePath); + } catch (err) { + try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort temp cleanup */ } + throw err; + } +} + +/** + * Read a JSON intelligence store for a standalone command. Returns `{}` for a + * missing store (legitimate fresh start), but on a corrupt/unparseable store + * quarantines the file (renames it aside) and throws instead of returning `{}`. + * The previous `try { parse } catch {}` → `{}` pattern let a corrupt read + * degrade to an empty object that the command then wrote back, wiping the + * store. Mirrors Intelligence.load()'s corrupt handling. + */ +function readIntelStoreSafe(dataPath) { + if (!fs.existsSync(dataPath)) return {}; + try { + return JSON.parse(fs.readFileSync(dataPath, 'utf-8')); + } catch (err) { + const quarantine = `${dataPath}.corrupt-${Date.now()}`; + try { fs.renameSync(dataPath, quarantine); } catch { /* still fail loud below */ } + throw new Error( + `ruvector: intelligence store at ${dataPath} is corrupt and was quarantined to ` + + `${quarantine} rather than overwritten with an empty store — ` + + `restore it or delete it to start fresh. (${err.message})` + ); + } +} + class Intelligence { constructor(options = {}) { this.intelPath = this.getIntelPath(); @@ -3156,39 +3201,56 @@ class Intelligence { edges: [], stats: { total_patterns: 0, total_memories: 0, total_trajectories: 0, total_errors: 0, session_count: 0, last_session: 0 } }; + // A missing store is a legitimate fresh start. + if (!fs.existsSync(this.intelPath)) return defaults; + + let data; try { - if (fs.existsSync(this.intelPath)) { - const data = JSON.parse(fs.readFileSync(this.intelPath, 'utf-8')); - // Merge with defaults to ensure all fields exist. The file is - // untrusted on-disk input (ADR-210 security pass): shape-check each - // field so a hand-edited/corrupted store cannot crash later code - // that iterates arrays or spreads objects. - const asArray = (v, dflt) => (Array.isArray(v) ? v : dflt); - const asObject = (v, dflt) => (v && typeof v === 'object' && !Array.isArray(v) ? v : dflt); - return { - patterns: asObject(data.patterns, defaults.patterns), - memories: asArray(data.memories, defaults.memories), - trajectories: asArray(data.trajectories, defaults.trajectories), - errors: asObject(data.errors, defaults.errors), - file_sequences: asArray(data.file_sequences, defaults.file_sequences), - agents: asObject(data.agents, defaults.agents), - edges: asArray(data.edges, defaults.edges), - stats: { ...defaults.stats, ...asObject(data.stats, {}) }, - // ADR-210 D0: embedding provenance of stored memory vectors - // (null = legacy store, read-only for vector writes until reembed). - // Malformed records are treated as absent (sanitized, never crash). - embeddingProvenance: sanitizeProvenanceSafe(data.embeddingProvenance), - // Preserve in-flight trajectories so trajectory-end (run in a later - // process) can find what trajectory-begin recorded (#517) - activeTrajectories: data.activeTrajectories || {}, - // Preserve auxiliary learned data if present - coEditPatterns: data.coEditPatterns || undefined, - sequences: data.sequences || undefined, - learning: data.learning || undefined - }; - } - } catch {} - return defaults; + data = JSON.parse(fs.readFileSync(this.intelPath, 'utf-8')); + } catch (err) { + // A corrupt/unreadable store must NOT silently degrade to empty defaults: + // the caller would then save() the emptiness back over the real data, + // destroying weeks of accumulated memories with no signal. Quarantine the + // file (rename it aside so it is preserved and the next save cannot clobber + // it) and fail loud. With atomicWriteFileSync() below, a concurrent writer + // can no longer produce a torn read, so reaching here means genuine + // corruption — a fresh store restarts cleanly on the next run. + const quarantine = `${this.intelPath}.corrupt-${Date.now()}`; + try { fs.renameSync(this.intelPath, quarantine); } catch { /* still fail loud below */ } + throw new Error( + `ruvector: intelligence store at ${this.intelPath} is corrupt and was quarantined ` + + `to ${quarantine} rather than overwritten with an empty store — ` + + `restore it or delete it to start fresh. (${err.message})` + ); + } + + // Merge with defaults to ensure all fields exist. The file is + // untrusted on-disk input (ADR-210 security pass): shape-check each + // field so a hand-edited/corrupted store cannot crash later code + // that iterates arrays or spreads objects. + const asArray = (v, dflt) => (Array.isArray(v) ? v : dflt); + const asObject = (v, dflt) => (v && typeof v === 'object' && !Array.isArray(v) ? v : dflt); + return { + patterns: asObject(data.patterns, defaults.patterns), + memories: asArray(data.memories, defaults.memories), + trajectories: asArray(data.trajectories, defaults.trajectories), + errors: asObject(data.errors, defaults.errors), + file_sequences: asArray(data.file_sequences, defaults.file_sequences), + agents: asObject(data.agents, defaults.agents), + edges: asArray(data.edges, defaults.edges), + stats: { ...defaults.stats, ...asObject(data.stats, {}) }, + // ADR-210 D0: embedding provenance of stored memory vectors + // (null = legacy store, read-only for vector writes until reembed). + // Malformed records are treated as absent (sanitized, never crash). + embeddingProvenance: sanitizeProvenanceSafe(data.embeddingProvenance), + // Preserve in-flight trajectories so trajectory-end (run in a later + // process) can find what trajectory-begin recorded (#517) + activeTrajectories: data.activeTrajectories || {}, + // Preserve auxiliary learned data if present + coEditPatterns: data.coEditPatterns || undefined, + sequences: data.sequences || undefined, + learning: data.learning || undefined + }; } save() { @@ -3217,7 +3279,7 @@ class Intelligence { } } - fs.writeFileSync(this.intelPath, JSON.stringify(this.data, null, 2)); + atomicWriteFileSync(this.intelPath, JSON.stringify(this.data, null, 2)); } now() { return Math.floor(Date.now() / 1000); } @@ -5614,12 +5676,7 @@ hooksCmd.command('learning-config') // Load existing intelligence data const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json'); - let data = {}; - try { - if (fs.existsSync(dataPath)) { - data = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); - } - } catch (e) {} + let data = readIntelStoreSafe(dataPath); const engine = new LearningEngineClass(); if (data.learning) { @@ -5653,7 +5710,7 @@ hooksCmd.command('learning-config') // Save data.learning = engine.export(); fs.mkdirSync(path.dirname(dataPath), { recursive: true }); - fs.writeFileSync(dataPath, JSON.stringify(data, null, 2)); + atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2)); console.log(JSON.stringify({ success: true, @@ -5673,12 +5730,7 @@ hooksCmd.command('learning-stats') } const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json'); - let data = {}; - try { - if (fs.existsSync(dataPath)) { - data = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); - } - } catch (e) {} + let data = readIntelStoreSafe(dataPath); const engine = new LearningEngineClass(); if (data.learning) { @@ -5721,12 +5773,7 @@ hooksCmd.command('learning-update') } const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json'); - let data = {}; - try { - if (fs.existsSync(dataPath)) { - data = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); - } - } catch (e) {} + let data = readIntelStoreSafe(dataPath); const engine = new LearningEngineClass(); if (data.learning) { @@ -5746,7 +5793,7 @@ hooksCmd.command('learning-update') // Save data.learning = engine.export(); - fs.writeFileSync(dataPath, JSON.stringify(data, null, 2)); + atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2)); console.log(JSON.stringify({ success: true, @@ -5769,12 +5816,7 @@ hooksCmd.command('compress') } const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json'); - let data = {}; - try { - if (fs.existsSync(dataPath)) { - data = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); - } - } catch (e) {} + let data = readIntelStoreSafe(dataPath); const compress = new TensorCompressClass({ autoCompress: false, @@ -5809,7 +5851,7 @@ hooksCmd.command('compress') // Save compressed data data.compressedPatterns = compress.export(); - fs.writeFileSync(dataPath, JSON.stringify(data, null, 2)); + atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2)); console.log(JSON.stringify({ success: true, @@ -5828,12 +5870,7 @@ hooksCmd.command('compress-stats') } const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json'); - let data = {}; - try { - if (fs.existsSync(dataPath)) { - data = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); - } - } catch (e) {} + let data = readIntelStoreSafe(dataPath); const compress = new TensorCompressClass({ autoCompress: false }); if (data.compressedPatterns) { @@ -5882,12 +5919,7 @@ hooksCmd.command('compress-store') } const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json'); - let data = {}; - try { - if (fs.existsSync(dataPath)) { - data = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); - } - } catch (e) {} + let data = readIntelStoreSafe(dataPath); const compress = new TensorCompressClass({ autoCompress: false }); if (data.compressedPatterns) { @@ -5898,7 +5930,7 @@ hooksCmd.command('compress-store') data.compressedPatterns = compress.export(); fs.mkdirSync(path.dirname(dataPath), { recursive: true }); - fs.writeFileSync(dataPath, JSON.stringify(data, null, 2)); + atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2)); const stats = compress.getStats(); console.log(JSON.stringify({ @@ -5921,12 +5953,7 @@ hooksCmd.command('compress-get') } const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json'); - let data = {}; - try { - if (fs.existsSync(dataPath)) { - data = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); - } - } catch (e) {} + let data = readIntelStoreSafe(dataPath); const compress = new TensorCompressClass({ autoCompress: false }); if (data.compressedPatterns) { @@ -5962,12 +5989,7 @@ hooksCmd.command('learn') } const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json'); - let data = {}; - try { - if (fs.existsSync(dataPath)) { - data = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); - } - } catch (e) {} + let data = readIntelStoreSafe(dataPath); const engine = new LearningEngineClass(); if (data.learning) { @@ -6001,7 +6023,7 @@ hooksCmd.command('learn') // Save data.learning = engine.export(); fs.mkdirSync(path.dirname(dataPath), { recursive: true }); - fs.writeFileSync(dataPath, JSON.stringify(data, null, 2)); + atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2)); console.log(JSON.stringify(result)); }); @@ -6046,12 +6068,7 @@ hooksCmd.command('batch-learn') } const dataPath = path.join(process.cwd(), '.ruvector', 'intelligence.json'); - let data = {}; - try { - if (fs.existsSync(dataPath)) { - data = JSON.parse(fs.readFileSync(dataPath, 'utf-8')); - } - } catch (e) {} + let data = readIntelStoreSafe(dataPath); const engine = new LearningEngineClass(); if (data.learning) { @@ -6079,7 +6096,7 @@ hooksCmd.command('batch-learn') // Save data.learning = engine.export(); fs.mkdirSync(path.dirname(dataPath), { recursive: true }); - fs.writeFileSync(dataPath, JSON.stringify(data, null, 2)); + atomicWriteFileSync(dataPath, JSON.stringify(data, null, 2)); const stats = engine.getStatsSummary(); console.log(JSON.stringify({ @@ -10252,6 +10269,12 @@ harnessCmd // Bare `ruvector harness` defaults to status harnessCmd.action(() => printHarnessStatus({})); -program.parse(); +// Only drive the CLI when executed directly; when required (e.g. by tests) +// expose the store-durability internals instead of parsing argv. +if (require.main === module) { + program.parse(); +} else { + module.exports = { atomicWriteFileSync, readIntelStoreSafe, Intelligence }; +} diff --git a/npm/packages/ruvector/test/smoke-intelligence-durability.mjs b/npm/packages/ruvector/test/smoke-intelligence-durability.mjs new file mode 100644 index 000000000..c50e0ccea --- /dev/null +++ b/npm/packages/ruvector/test/smoke-intelligence-durability.mjs @@ -0,0 +1,145 @@ +// Runtime smoke test for #634: the .ruvector/intelligence.json store must be +// written atomically and must fail loud (not silently wipe) on a corrupt read. +// Drives the REAL functions exported from bin/cli.js (commander/chalk are +// stubbed only so the module can be required without the full CLI install). +// +// Run: node test/smoke-intelligence-durability.mjs +import assert from 'node:assert'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const require = createRequire(import.meta.url); +const here = path.dirname(fileURLToPath(import.meta.url)); +const cliPath = path.join(here, '..', 'bin', 'cli.js'); + +// --- stub the two module-scope deps so cli.js can be required as a library --- +const stubRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-stubs-')); +function writeStub(name, indexJs) { + const dir = path.join(stubRoot, 'node_modules', name); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, version: '0.0.0', main: 'index.js' })); + fs.writeFileSync(path.join(dir, 'index.js'), indexJs); +} +// commander: chainable no-op Command — any method returns a chainable, and +// command()/createCommand() return fresh chainables. +writeStub('commander', ` +function makeCmd() { + const cmd = new Proxy(function () {}, { + get(_t, prop) { + if (prop === 'command' || prop === 'createCommand' || prop === 'addCommand') return () => makeCmd(); + if (prop === 'opts' || prop === 'optsWithGlobals') return () => ({}); + if (prop === 'args') return []; + return () => cmd; + }, + apply() { return makeCmd(); }, + }); + return cmd; +} +class Command { constructor() { return makeCmd(); } } +module.exports = { Command, program: makeCmd() }; +`); +// chalk: identity color functions +writeStub('chalk', ` +const id = (s) => s; +const handler = { get: () => new Proxy(id, handler), apply: (t, _th, a) => a[0] }; +const chalk = new Proxy(id, handler); +module.exports = chalk; module.exports.default = chalk; +`); + +// Make the stubs resolvable from cli.js by pointing NODE_PATH — but require() +// resolves relative to cli.js, so instead patch module resolution via a temp +// symlinked node_modules next to the package. +const pkgNodeModules = path.join(here, '..', 'node_modules'); +let createdSymlink = false; +if (!fs.existsSync(pkgNodeModules)) { + fs.symlinkSync(path.join(stubRoot, 'node_modules'), pkgNodeModules, 'dir'); + createdSymlink = true; +} + +let passed = 0; +const ok = (name) => { console.log(` ok - ${name}`); passed++; }; + +try { + const { atomicWriteFileSync, readIntelStoreSafe, Intelligence } = require(cliPath); + + // ---- helper: atomicWriteFileSync leaves no temp + produces the exact bytes + { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-aw-')); + const f = path.join(dir, 'x.json'); + atomicWriteFileSync(f, '{"a":1}'); + assert.strictEqual(fs.readFileSync(f, 'utf-8'), '{"a":1}'); + assert.deepStrictEqual(fs.readdirSync(dir), ['x.json'], 'no .tmp file left behind'); + ok('#634 atomicWriteFileSync writes exact bytes, no temp leftover'); + fs.rmSync(dir, { recursive: true, force: true }); + } + + // ---- helper: readIntelStoreSafe absent -> {}, valid -> parsed, corrupt -> throw+quarantine + { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-rd-')); + const f = path.join(dir, 'intelligence.json'); + assert.deepStrictEqual(readIntelStoreSafe(f), {}, 'absent store -> {}'); + fs.writeFileSync(f, JSON.stringify({ memories: [1, 2, 3] })); + assert.deepStrictEqual(readIntelStoreSafe(f).memories, [1, 2, 3], 'valid store parsed'); + fs.writeFileSync(f, '{ torn '); + assert.throws(() => readIntelStoreSafe(f), /corrupt and was quarantined/, 'corrupt store throws'); + assert.ok(!fs.existsSync(f), 'corrupt store renamed away (not left to be overwritten)'); + assert.strictEqual(fs.readdirSync(dir).filter((x) => x.includes('.corrupt-')).length, 1, 'one quarantine file'); + ok('#634 readIntelStoreSafe: absent->{}, valid->parsed, corrupt->throw+quarantine'); + fs.rmSync(dir, { recursive: true, force: true }); + } + + // ---- the reported wipe scenario, through the real Intelligence class ---- + { + const proj = fs.mkdtempSync(path.join(os.tmpdir(), 'ruv634-proj-')); + const store = path.join(proj, '.ruvector', 'intelligence.json'); + fs.mkdirSync(path.dirname(store), { recursive: true }); + // A store with 3 memories that must survive. + fs.writeFileSync(store, JSON.stringify({ + patterns: {}, memories: ['m1', 'm2', 'm3'], trajectories: [], errors: {}, + file_sequences: [], agents: {}, edges: [], + stats: { total_patterns: 0, total_memories: 3, total_trajectories: 0, total_errors: 0, session_count: 1, last_session: 1 }, + }, null, 2)); + + const prevCwd = process.cwd(); + process.chdir(proj); + try { + // Sanity: a valid store loads its 3 memories. + const okIntel = new Intelligence({ skipEngine: true }); + assert.strictEqual(okIntel.data.memories.length, 3, 'valid store loads 3 memories'); + + // Corrupt the store exactly as the issue's repro does (truncated file). + const full = fs.readFileSync(store, 'utf-8'); + fs.writeFileSync(store, full.slice(0, Math.floor(full.length / 2))); + + // OLD behavior: construct succeeds, load() returns empty defaults, next + // save() persists the emptiness (wipe). NEW behavior: construct throws. + assert.throws(() => new Intelligence({ skipEngine: true }), /corrupt and was quarantined/, + 'corrupt store -> throw, not silent empty-defaults'); + assert.ok(!fs.existsSync(store), 'corrupt store quarantined (not left in place to be overwritten)'); + const quarantined = fs.readdirSync(path.dirname(store)).filter((x) => x.includes('.corrupt-')); + assert.strictEqual(quarantined.length, 1, 'store quarantined once (data preserved for recovery)'); + // The quarantined file still holds the original (now-truncated) bytes, not an empty store. + ok('#634 corrupt store is quarantined + fails loud instead of silently wiping memories'); + + // Atomic save roundtrip: fresh store, save leaves no temp, reload matches. + const fresh = new Intelligence({ skipEngine: true }); + fresh.data.memories.push('kept'); + fresh.save(); + assert.ok(!fs.readdirSync(path.dirname(store)).some((x) => x.includes('.tmp.')), 'save() leaves no temp file'); + const reloaded = new Intelligence({ skipEngine: true }); + assert.ok(reloaded.data.memories.includes('kept'), 'saved memory survives reload'); + ok('#634 save() is atomic (no temp leftover) and roundtrips'); + } finally { + process.chdir(prevCwd); + } + fs.rmSync(proj, { recursive: true, force: true }); + } + + console.log(`\nAll ${passed} checks passed.`); +} finally { + if (createdSymlink) { try { fs.unlinkSync(pkgNodeModules); } catch { /* ignore */ } } + fs.rmSync(stubRoot, { recursive: true, force: true }); +}