diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8726a18..f283f0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,10 @@ permissions: jobs: test: - runs-on: ubuntu-latest + strategy: + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout @@ -26,20 +29,14 @@ jobs: - name: Install run: npm ci - - name: Source tests - run: npm run test:src:readonly - - - name: Markdown links - run: npm run check:links + - name: Source, compiled, links and installed-package tests + run: npm test - name: Memory lint - run: npm run lint:memory + run: node scripts/dejavu-lint-memory.mjs && node scripts/dejavu-lint-memory.mjs --memory-root starter-kit/memory && node scripts/dejavu-lint-memory.mjs --memory-root examples/protocol-project/memory && node scripts/dejavu-lint-memory.mjs --memory-root docs/examples/settings-project/memory + + - name: Project readiness + run: node scripts/deja-vu.mjs doctor --json - name: Feedback report run: npm run report:feedback - - - name: Build - run: npm run build - - - name: Pack verification - run: npm run test:pack diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e98c3d..6204bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ All notable changes to this project will be documented in this file. +## Unreleased + +- Use the same validated rules block for readiness and scope checks; reject ambiguous scopes and omit raw JSONL parse messages from diagnostics. + +- Prepare engine writes before mutation; compensate failed writes, expose recovery errors, preserve update metadata, reject duplicate IDs, and serialize mutations. +- Preflight initialization, back up overwritten/appended files, stage replacements, and report rollback failures as JSON. +- Share schema and route diagnostics across CLI tools; reject invalid input, inactive routes, missing successors, cycles, and scope mismatches. +- Recognize actual recall rules instead of filename mentions; validate all three setup files in project scans. +- Add fault-injection regression tests, executable settings-demo fixtures, installed-package smoke checks, and full-suite CI coverage. +- Clarify lexical recall limits, cue-first manual fallback, and non-transactional recovery boundaries. + ## [0.7.0] - 2026-08-09 - Rebuilt the README around a one-command start, concrete before/after proof, agent compatibility, safety boundaries, and a shareable product visual. diff --git a/README.md b/README.md index a883241..9df7a17 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,9 @@ npx @focaxisdev/deja-vu doctor --json npx @focaxisdev/deja-vu explain ``` -`init` creates missing files only. It never replaces existing files unless `--force` is explicitly passed. When `AGENTS.md` already exists without Deja Vu rules, the command reports that setup is incomplete; `--merge-agents` appends a marked block without deleting existing rules. +`init` creates missing files only. It never replaces existing files unless `--force` is explicitly passed. When `AGENTS.md` already exists without Deja Vu rules, the command reports that setup is incomplete; `--merge-agents` appends a marked block to unrelated rules and leaves conflicting or partial blocks for manual review. + +Initialization backs up files before overwriting or appending and reports the backup directory. Check `ready` and run `doctor` before use; see [reliability and recovery](docs/reliability.md) for restoration instructions and transaction limits. Focused tools are also available: diff --git a/docs/demo-walkthrough.md b/docs/demo-walkthrough.md index f5087c1..573d970 100644 --- a/docs/demo-walkthrough.md +++ b/docs/demo-walkthrough.md @@ -1,6 +1,11 @@ # Demo Walkthrough -This walkthrough shows the behavior Deja Vu is designed to create. +This walkthrough uses fictional, sanitized data. The checked-in +[summary](examples/settings-project/memory/summary.md), +[cue index](examples/settings-project/memory/impressions.jsonl), +[decision](examples/settings-project/memory/decisions/settings-sync.md), and +[follow-up](examples/settings-project/memory/open-loops/settings-migration.md) +make the example reproducible. ## Before Deja Vu @@ -44,9 +49,26 @@ User: Continue the settings refactor. Agent: I will follow AGENTS.md and scan memory/impressions.jsonl before planning. ``` -The cue scan finds a strong match for `settings` and `sync`. +That exact request does **not** produce a strong scripted match: it returns +`level: "none"` and a top score of `0.2437`. The lexical scanner does not infer +the word "sync" from "refactor". No detail should be loaded on that result. -The agent reads only: +When the task supplies the explicit cues `settings sync`, the scanner returns +`strong`, score `0.975`, with the settings decision first. The second route is +the migration follow-up (score `0.475`). + +Run both from the repository root: + +```bash +node scripts/dejavu-scan-memory.mjs --memory-root docs/examples/settings-project/memory "Continue the settings refactor." +node scripts/dejavu-scan-memory.mjs --memory-root docs/examples/settings-project/memory "settings sync" +``` + +These exact outcomes are regression-tested. This demonstrates cue-based +retrieval, not measured improvements in agent task success. + +For the explicit-cue task, the agent can read the decision and its relevant +migration link, within the one-to-three-record budget: - `memory/decisions/settings-sync.md` - `memory/open-loops/settings-migration.md` @@ -54,7 +76,7 @@ The agent reads only: Then it plans with the right constraints: - do not move synced preferences to localStorage -- preserve the existing IME workaround +- preserve the IME composition guard documented in the settings decision - continue the legacy setting-name migration ## Post-Task Writeback diff --git a/docs/engine/semantic-engine.md b/docs/engine/semantic-engine.md index cb4daa2..0e6315f 100644 --- a/docs/engine/semantic-engine.md +++ b/docs/engine/semantic-engine.md @@ -48,6 +48,31 @@ await engine.updateMemory(id, input); await engine.deleteMemory(id); ``` +## Write failure contract + +Adds and updates prepare summaries, chunks, and all embeddings before changing +storage. Updates retain importance, tags, and creation time unless explicitly +changed where supported. Adding an existing ID is rejected; use `updateMemory`. +Mutations are serialized within one engine instance. + +If a storage or vector write fails, the engine attempts compensating cleanup +and restoration. `MemoryWriteError.memoryId` identifies the affected memory, +including auto-generated IDs on failed adds. `recoveryErrors` is empty when compensation +succeeds. Otherwise, repair the adapter using `recoverySnapshot`; it may contain +private raw content and must not be logged or included in telemetry. + +This is **not a durable transaction**. Process termination, persistent adapter +outages, concurrent readers, multiple engine instances, and direct adapter +writes are not isolated by this queue. Deletion is serialized but does not +provide compensating rollback. Production hosts must supply transactional +persistence/coordination where those guarantees are required. + +Storage adapters may implement optional `getMemorySnapshot(id)` to return a +complete detached snapshot, including raw creation/update timestamps. The +legacy getter fallback reconstructs those timestamps from chunk or access +metadata and cannot guarantee their exact preservation. The built-in in-memory +adapter implements complete snapshots; it is still not persistent storage. + ## Optional npm install ```bash diff --git a/docs/examples/settings-project/AGENTS.md b/docs/examples/settings-project/AGENTS.md new file mode 100644 index 0000000..b47960f --- /dev/null +++ b/docs/examples/settings-project/AGENTS.md @@ -0,0 +1,53 @@ +# Deja Vu Project Memory Rules + +## Memory Identity + +- Protocol: Deja Vu Protocol v0.4 +- Scope: `project:demo` +- Memory root: `memory/` + +## Recall Before Substantial Work + +Before substantial planning, coding, refactoring, debugging, or architecture discussion: + +1. Inspect `memory/impressions.jsonl` for familiar cues. +2. If there is no familiarity, do not load memory by default. +3. If familiarity is weak, read `memory/summary.md`. +4. If familiarity is strong, read only the 1-3 linked detailed records needed for the task. +5. Do not load the whole memory tree unless the user explicitly asks. + +Default budget: + +- impression scan: always allowed +- summary: at most one file +- detailed records: one to three files +- full memory tree: forbidden unless explicitly requested + +## Durable Writeback Only + +After meaningful work, write back only memory that should change future agent behavior: + +- accepted decisions +- architecture intent +- stable preferences +- unresolved follow-ups +- milestone summaries +- recall feedback that should improve future cue quality + +Route durable writeback: + +- project-level truth -> `memory/summary.md` plus `memory/impressions.jsonl` +- accepted decision -> `memory/decisions/` plus `memory/impressions.jsonl` +- unresolved follow-up -> `memory/open-loops/` plus `memory/impressions.jsonl` +- recall quality signal -> `memory/recall-feedback.jsonl` + +## Never Store + +- secrets, tokens, credentials, or private keys +- full chat transcripts +- customer or user PII +- low-signal chatter +- disposable exploration noise +- raw debug logs or build output + +Memory should feel like recognition, not replay. diff --git a/docs/examples/settings-project/memory/decisions/settings-sync.md b/docs/examples/settings-project/memory/decisions/settings-sync.md new file mode 100644 index 0000000..5a6e442 --- /dev/null +++ b/docs/examples/settings-project/memory/decisions/settings-sync.md @@ -0,0 +1,25 @@ +--- +id: decision-settings-sync +title: Settings sync stays server-backed +status: active +scope: project:demo +updated: 2026-05-16 +--- + +# Settings sync + +## Decision + +Keep synced preferences on the server; do not move them to localStorage. +Preserve the input-method editor (IME) composition guard: do not submit +unfinished text while composition is active. + +## Rationale + +The fictional application shares settings across devices. Premature submission +during IME composition can persist incomplete text. + +## Consequences + +The refactor must retain server-backed persistence and composition handling. +Track old setting names in the [migration follow-up](../open-loops/settings-migration.md). diff --git a/docs/examples/settings-project/memory/impressions.jsonl b/docs/examples/settings-project/memory/impressions.jsonl new file mode 100644 index 0000000..a205f39 --- /dev/null +++ b/docs/examples/settings-project/memory/impressions.jsonl @@ -0,0 +1,2 @@ +{"schema_version":1,"id":"decision-settings-sync","scope":"project:demo","title":"Settings sync stays server-backed","keywords":["settings","sync","localstorage","preferences"],"record_path":"memory/decisions/settings-sync.md","updated":"2026-05-16","weight":0.9,"status":"active"} +{"schema_version":1,"id":"open-loop-settings-migration","scope":"project:demo","title":"Migrate old setting names","keywords":["settings","migration","legacy","names"],"record_path":"memory/open-loops/settings-migration.md","updated":"2026-05-16","weight":0.8,"status":"active"} diff --git a/docs/examples/settings-project/memory/open-loops/settings-migration.md b/docs/examples/settings-project/memory/open-loops/settings-migration.md new file mode 100644 index 0000000..8915a62 --- /dev/null +++ b/docs/examples/settings-project/memory/open-loops/settings-migration.md @@ -0,0 +1,26 @@ +--- +id: open-loop-settings-migration +title: Migrate old setting names +status: active +scope: project:demo +updated: 2026-05-16 +--- + +# Settings migration + +## Owner + +Demo maintainer. + +## Opened + +2026-05-16. + +## Next trigger + +Before removing legacy setting names, implement and test their migration. + +## Why it matters + +Existing preferences must survive the refactor without losing server sync. +The [settings decision](../decisions/settings-sync.md) remains authoritative. diff --git a/docs/examples/settings-project/memory/summary.md b/docs/examples/settings-project/memory/summary.md new file mode 100644 index 0000000..836bd03 --- /dev/null +++ b/docs/examples/settings-project/memory/summary.md @@ -0,0 +1,16 @@ +--- +id: settings-summary +title: Settings demo +status: active +scope: project:demo +updated: 2026-05-16 +--- + +# Settings demo + +This is fictional, sanitized test data, not a claim about a real application. +Preferences remain server-backed. The settings refactor must preserve the IME +composition guard. Legacy setting-name migration is still open. + +See the [decision](decisions/settings-sync.md) and +[migration follow-up](open-loops/settings-migration.md). diff --git a/docs/protocol.md b/docs/protocol.md index 087200a..52267f7 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -101,7 +101,7 @@ Before substantial planning, coding, or answering: 3. If the scan is `none`, do not load project memory by default. 4. If the scan is `weak`, read `memory/summary.md`. 5. If the scan is `strong`, open only the detailed records needed for the current task. -6. If no script is available, fall back to `memory/summary.md` and only then to `memory/index.md` when present. +6. If no script is available, inspect `memory/impressions.jsonl` directly for familiar cues and apply the same none/weak/strong loading rules. Do not unconditionally load the summary or index. 7. Track the recall budget used for the task: impression scan, summary count, detailed records, and why anything was loaded. Default recall budget: diff --git a/docs/reliability.md b/docs/reliability.md new file mode 100644 index 0000000..0279152 --- /dev/null +++ b/docs/reliability.md @@ -0,0 +1,49 @@ +# Reliability and recovery + +## Initialization + +`deja-vu init` preflights all target files before writing. It stages replacements +beside their destinations and restores completed writes if a later operation +throws. Existing files overwritten by `--force` or appended by `--merge-agents` +are backed up under `.deja-vu-backups/init-*/`; JSON and text output report the +directory. `manifest.json` maps original paths to byte-for-byte backups. + +Backups may contain private rules or memory. Their directory contains a +`*` ignore rule, but Git ignore is not encryption or an access-control boundary. +Keep backups local, inspect them before sharing, and manage retention yourself. + +`--dry-run` creates neither files nor backups; readiness is a projection, not +a full validation of the proposed final tree. A successful command can return +`ok: true, ready: false` when existing rules or memory need manual review. +Run `deja-vu doctor --json` after initialization. + +Known shipped rules and version-1 generated blocks are recognized structurally. +Mentions of memory filenames alone are not proof of readiness. Partial blocks, +unknown versions, differing scopes, and conflicting existing rules require +manual review. Recognition is not natural-language contradiction detection. + +Readiness and scope checks use the same validated block. Scope mentions outside +a marked block do not override its identity; duplicate scopes inside it require +manual review. JSONL parse failures report a fixed code, path and line number, +without echoing the malformed record in the diagnostic. + +## Recovering interrupted initialization + +Caught write failures return `ok: false`, the backup directory when available, +and any `recovery_errors` in JSON mode. Successful compensation restores original +bytes. The operation does not promise an all-files transaction across a process +crash or concurrent external edits. + +After a crash or recovery error, stop writers, inspect the backup manifest, +compare each current file against its backup, and restore only the intended +targets. Do not blindly overwrite newer user edits. Run doctor before resuming. + +## Validation boundaries + +Doctor and lint reject broken routes, cross-project targets, active cues pointing +to inactive records, missing successors, and supersession cycles. Legacy Markdown +without frontmatter still produces a warning: add metadata to enable complete +lifecycle validation. These checks do not prove the truth of a memory's content. + +See [scripted recall](scripted-recall.md) for scan exit codes and cue-only mode, +and the [engine contract](engine/semantic-engine.md) for adapter failure recovery. diff --git a/docs/scripted-recall.md b/docs/scripted-recall.md index 14dcf45..f2e9f51 100644 --- a/docs/scripted-recall.md +++ b/docs/scripted-recall.md @@ -39,11 +39,21 @@ The linter also warns about low-quality cue routes that make future recall more ## Inputs -The script reads: +In project mode (default or `--memory-root`), the script requires all three +setup files to exist as files: - `memory/impressions.jsonl` +- `memory/summary.md` +- `AGENTS.md` -Use `--memory-root` or `--file` when the memory directory is not under the current working directory: +For weak/strong results it also checks returned route targets and their lifecycle +metadata, including successor links. These local validation reads do not inject +record bodies into the agent context: the reported loading budget measures +context loading, not filesystem I/O. Run `doctor` for a full health check. + +Use `--memory-root` for another project's memory directory. Use `--file` +only for a standalone cue-index scan: it validates JSONL schema but does not +validate setup files or target documents; the host owns those checks. ```bash node scripts/dejavu-scan-memory.mjs --memory-root ./examples/protocol-project/memory "protocol memory" @@ -84,7 +94,12 @@ The script prints JSON: } ``` -If `memory/impressions.jsonl` is missing, the script returns `level: "not_initialized"` with a bootstrap hint instead of pretending the task had no familiarity match. +If any required setup file is missing, project mode returns +`ok: false, level: "not_initialized"` and exit code 1 with a bootstrap hint. +Malformed JSONL, invalid schema, and unusable matched routes return +`ok: false, level: "error"` and exit code 1. Healthy scans (including `none`) +return `ok: true` and exit code 0. Never interpret an error as no familiarity. +The linter, doctor, and feedback report also return nonzero on validation errors. ## Host Workflow @@ -97,6 +112,8 @@ If `memory/impressions.jsonl` is missing, the script returns `level: "not_initia 7. Record recall feedback only when the result should tune future cue quality. 8. Continue to apply normal writeback and compaction rules after work completes. +Stop recall on `error`; repair the indicated record or route first. + ## Bootstrap Rule When installing Deja Vu into a project, copy or create the three required files: diff --git a/package.json b/package.json index 7ee8033..710b920 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,9 @@ "clean": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "prepack": "npm run clean && npm run build", "prepublishOnly": "npm test", - "test": "npm run test:src:readonly && npm run check:links && npm run build && node --test dist/tests/semantic-recall-engine.test.js dist/tests/memory-cli.test.js && npm run test:pack", + "test": "npm run test:src:readonly && npm run check:links && npm run build && node --test dist/tests/semantic-recall-engine.test.js dist/tests/memory-cli.test.js dist/tests/reliability.test.js && npm run test:pack", "test:src": "npm run test:src:readonly", - "test:src:readonly": "node --import \"data:text/javascript,import { register } from 'node:module'; import { pathToFileURL } from 'node:url'; register('ts-node/esm', pathToFileURL('./'));\" --test tests/semantic-recall-engine.test.ts tests/memory-cli.test.ts", + "test:src:readonly": "node --import \"data:text/javascript,import { register } from 'node:module'; import { pathToFileURL } from 'node:url'; register('ts-node/esm', pathToFileURL('./'));\" --test tests/semantic-recall-engine.test.ts tests/memory-cli.test.ts tests/reliability.test.ts", "test:pack": "node --import \"data:text/javascript,import { register } from 'node:module'; import { pathToFileURL } from 'node:url'; register('ts-node/esm', pathToFileURL('./'));\" --test tests/package-pack.test.ts", "check:links": "node scripts/check-markdown-links.mjs", "scan:memory": "node scripts/dejavu-scan-memory.mjs", diff --git a/scripts/deja-vu.mjs b/scripts/deja-vu.mjs index 4c70bb5..7587830 100644 --- a/scripts/deja-vu.mjs +++ b/scripts/deja-vu.mjs @@ -1,14 +1,16 @@ #!/usr/bin/env node import { existsSync, - mkdirSync, readFileSync, readdirSync, + lstatSync, statSync, - writeFileSync, } from "node:fs"; import { basename, dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; +import { parseJsonl as readJsonl, parseFrontmatter, parseRules } from "./lib/memory-validation.mjs"; +import { executePlan, plannedFile } from "./lib/init-files.mjs"; +import { lintMemory } from "./dejavu-lint-memory.mjs"; const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const starterRoot = resolve(packageRoot, "starter-kit"); @@ -79,10 +81,6 @@ function materializeRulesTemplate(projectId) { ); } -function hasDejaVuRules(text) { - return text.includes("memory/impressions.jsonl") && text.includes("memory/summary.md"); -} - function readUtf8ForMerge(path) { try { return new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path)); @@ -97,7 +95,7 @@ function rulesBlock(projectId, eol = "\n") { .replace(/^## /gm, "### ") .replace(/^# Deja Vu Project Memory Rules/m, "## Deja Vu Project Memory Rules") .trim(); - return [rulesStart, rules, rulesEnd, ""].join(eol); + return [rulesStart, "", rules.replace(/\n/g, eol), rulesEnd, ""].join(eol); } function getAgents(value) { @@ -126,45 +124,32 @@ function print(data, json) { console.log(JSON.stringify(data, null, 2)); } -function writePlannedFile(plan, targetPath, content, { force, dryRun }) { - const exists = existsSync(targetPath); - const operation = exists ? (force ? "overwrite" : "skip") : "create"; - plan.push({ operation, path: targetPath }); - if (dryRun || operation === "skip") return; - mkdirSync(dirname(targetPath), { recursive: true }); - writeFileSync(targetPath, content, "utf8"); +function writePlannedFile(plan, targetPath, content, { force }) { + const operation = existsSync(targetPath) ? (force ? "overwrite" : "skip") : "create"; + plannedFile(plan, operation, targetPath, content); } -function writeAgentsFile(plan, targetPath, content, { force, dryRun, mergeAgents, projectId }) { - if (!existsSync(targetPath)) { - plan.push({ operation: "create", path: targetPath }); - if (!dryRun) writeFileSync(targetPath, content, "utf8"); +function writeAgentsFile(plan, targetPath, content, { force, mergeAgents, projectId }) { + if (!existsSync(targetPath) || force) { + plannedFile(plan, existsSync(targetPath) ? "overwrite" : "create", targetPath, content); return true; } - - if (force) { - plan.push({ operation: "overwrite", path: targetPath }); - if (!dryRun) writeFileSync(targetPath, content, "utf8"); - return true; - } - const existing = readUtf8ForMerge(targetPath); - if (hasDejaVuRules(existing)) { - plan.push({ operation: "skip", path: targetPath, reason: "Deja Vu rules already present" }); - return true; + const rules = parseRules(existing); + if (rules) { + const ready = rules.scope === projectId; + plannedFile(plan, ready ? "skip" : "manual_merge", targetPath, null, + ready ? "Deja Vu rules already present" : "Existing rules use a different project scope"); + return ready; } - - if (!mergeAgents) { - plan.push({ operation: "manual_merge", path: targetPath, reason: "existing AGENTS.md has no Deja Vu rules" }); + if (!mergeAgents || existing.includes(rulesStart) || existing.includes(rulesEnd) + || /memory\/(?:impressions\.jsonl|summary\.md)/.test(existing)) { + plannedFile(plan, "manual_merge", targetPath, null, "existing AGENTS.md requires rule review"); return false; } - - plan.push({ operation: "append", path: targetPath }); - if (!dryRun) { - const eol = existing.includes("\r\n") ? "\r\n" : "\n"; - const separator = existing.endsWith(`${eol}${eol}`) ? "" : existing.endsWith(eol) ? eol : `${eol}${eol}`; - writeFileSync(targetPath, `${existing}${separator}${rulesBlock(projectId, eol)}`, "utf8"); - } + const eol = existing.includes("\r\n") ? "\r\n" : "\n"; + const separator = existing.endsWith(eol + eol) ? "" : existing.endsWith(eol) ? eol : eol + eol; + plannedFile(plan, "append", targetPath, existing + separator + rulesBlock(projectId, eol)); return true; } @@ -175,7 +160,10 @@ function initCommand(values) { const force = asBoolean(options.force); const mergeAgents = asBoolean(options.mergeAgents); const json = asBoolean(options.json); - const projectId = slugifyProjectId(String(options.projectId ?? options.scope ?? basename(cwd))); + const existingSummaryPath = resolve(cwd, "memory", "summary.md"); + const existingScope = existsSync(existingSummaryPath) && statSync(existingSummaryPath).isFile() + ? parseFrontmatter(readUtf8ForMerge(existingSummaryPath))?.scope : null; + const projectId = slugifyProjectId(String(options.projectId ?? options.scope ?? existingScope ?? basename(cwd))); const agents = getAgents(options.agents ?? options.agent); const plan = []; @@ -186,7 +174,7 @@ function initCommand(values) { const rulesReady = writeAgentsFile( plan, resolve(cwd, "AGENTS.md"), - materializeRulesTemplate(projectId), + rulesBlock(projectId), { dryRun, force, mergeAgents, projectId }, ); writePlannedFile( @@ -211,6 +199,14 @@ function initCommand(values) { ); } + const execution = executePlan(plan, { cwd, dryRun }); + const health = dryRun ? null : lintMemory(resolve(cwd, "memory")); + if (health && !force && existingScope && existingScope !== projectId) { + health.ok = false; + health.diagnostics.push({ level: "error", message: "Existing memory uses a different project scope" }); + } + const ready = rulesReady && (health?.ok ?? true) && (force || !existingScope || existingScope === projectId); + const prompt = [ "Follow AGENTS.md.", "Before substantial planning or code changes, scan memory/impressions.jsonl for familiar cues.", @@ -224,12 +220,14 @@ function initCommand(values) { print( { ok: true, - ready: rulesReady, + ready, dry_run: dryRun, project_id: projectId, agents, operations: plan, - required_action: rulesReady ? null : "Run init again with --merge-agents or merge the starter rules manually.", + ...execution, + required_action: ready ? null : "Review rules and memory diagnostics; use --merge-agents for an unrelated rules file or merge conflicting rules manually.", + diagnostics: health?.diagnostics ?? [], next_prompt: prompt, }, true, @@ -241,14 +239,15 @@ function initCommand(values) { dryRun ? "Deja Vu init dry run:" : "Deja Vu init complete:", ...plan.map((item) => `- ${item.operation}: ${relative(cwd, item.path)}`), ]; - if (!rulesReady) { + if (!ready) { lines.push( "", "Action required:", - "- AGENTS.md already exists but does not include Deja Vu rules.", - "- Run `deja-vu init --merge-agents` to append a marked, idempotent rules block, or merge the starter rules manually.", + "- Rules or memory require review before this setup is ready.", + "- Review rules and run doctor; use --merge-agents for unrelated rules, or merge conflicting rules manually.", ); } + if (execution.backup_directory) lines.push("", `Original files backed up to: ${execution.backup_directory}`); lines.push("", "Next agent prompt:", prompt); print(lines.join("\n"), false); } @@ -257,14 +256,22 @@ function addDiagnostic(diagnostics, level, message, details = {}) { diagnostics.push({ level, message, ...details }); } -function listFiles(root) { +function listFiles(root, diagnostics) { if (!existsSync(root)) return []; + if (lstatSync(root).isSymbolicLink() || !lstatSync(root).isDirectory()) { + addDiagnostic(diagnostics, "error", "Memory root must be a real directory", { path: root }); + return []; + } const files = []; for (const entry of readdirSync(root)) { const fullPath = resolve(root, entry); - const stat = statSync(fullPath); + const stat = lstatSync(fullPath); + if (stat.isSymbolicLink()) { + addDiagnostic(diagnostics, "error", "Refusing symlink in memory tree", { path: fullPath }); + continue; + } if (stat.isDirectory()) { - files.push(...listFiles(fullPath)); + files.push(...listFiles(fullPath, diagnostics)); } else { files.push(fullPath); } @@ -273,22 +280,7 @@ function listFiles(root) { } function parseJsonl(filePath, diagnostics, kind) { - const records = []; - if (!existsSync(filePath)) return records; - for (const [index, line] of readFileSync(filePath, "utf8").split(/\r?\n/).entries()) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - records.push({ record: JSON.parse(trimmed), line: index + 1 }); - } catch (error) { - addDiagnostic(diagnostics, "error", `Invalid ${kind} JSONL record`, { - path: filePath, - line: index + 1, - error: error instanceof Error ? error.message : String(error), - }); - } - } - return records; + return readJsonl(filePath, diagnostics, kind); } function scanTextSafety(filePath, text, diagnostics) { @@ -327,44 +319,27 @@ function doctorCommand(values) { const cwd = resolve(process.cwd(), String(options.cwd ?? ".")); const memoryRoot = resolve(cwd, String(options.memoryRoot ?? "memory")); const json = asBoolean(options.json); - const diagnostics = []; + const diagnostics = [...lintMemory(memoryRoot).diagnostics]; const agentsPath = resolve(cwd, "AGENTS.md"); const summaryPath = resolve(memoryRoot, "summary.md"); const impressionsPath = resolve(memoryRoot, "impressions.jsonl"); const feedbackPath = resolve(memoryRoot, "recall-feedback.jsonl"); + const rules = existsSync(agentsPath) ? parseRules(readFileSync(agentsPath, "utf8")) : null; if (!existsSync(agentsPath)) { addDiagnostic(diagnostics, "error", "Missing AGENTS.md", { path: agentsPath }); - } else if (!hasDejaVuRules(readFileSync(agentsPath, "utf8"))) { + } else if (!rules) { addDiagnostic(diagnostics, "error", "AGENTS.md does not include Deja Vu recall rules", { path: agentsPath }); } - if (!existsSync(summaryPath)) { - addDiagnostic(diagnostics, "error", "Missing memory/summary.md", { path: summaryPath }); - } - if (!existsSync(impressionsPath)) { - addDiagnostic(diagnostics, "error", "Missing memory/impressions.jsonl", { path: impressionsPath }); + const impressionEntries = parseJsonl(impressionsPath, [], "impression"); + const impressionIds = new Set(impressionEntries.filter(({record}) => !record.status || record.status === "active").map(({record}) => record.id)); + const ruleScope = rules?.scope; + if (ruleScope && impressionEntries.some(({record}) => record.scope !== ruleScope)) { + addDiagnostic(diagnostics, "error", "Rules and impressions use different project scopes", { path: agentsPath }); } - const impressionEntries = parseJsonl(impressionsPath, diagnostics, "impression"); - const impressionIds = new Set(); - for (const { record, line } of impressionEntries) { - for (const field of ["id", "scope", "title", "record_path", "updated"]) { - if (typeof record[field] !== "string" || record[field].trim() === "") { - addDiagnostic(diagnostics, "error", `Impression missing ${field}`, { path: impressionsPath, line }); - } - } - if (!Array.isArray(record.keywords) || record.keywords.length === 0) { - addDiagnostic(diagnostics, "error", "Impression keywords must be a non-empty array", { - path: impressionsPath, - line, - id: record.id, - }); - } - if (record.id && (!record.status || record.status === "active")) impressionIds.add(record.id); - } - - const feedbackEntries = parseJsonl(feedbackPath, diagnostics, "feedback"); + const feedbackEntries = parseJsonl(feedbackPath, [], "feedback"); for (const { record, line } of feedbackEntries) { if (record.matched_id && record.matched_id !== "unmatched" && !impressionIds.has(record.matched_id)) { addDiagnostic(diagnostics, "warning", "Feedback matched_id does not resolve to an active impression", { @@ -385,7 +360,7 @@ function doctorCommand(values) { addDiagnostic(diagnostics, "warning", "memory/summary.md is large; keep summary compact", { path: summaryPath }); } - for (const filePath of [agentsPath, ...listFiles(memoryRoot)].filter((path) => existsSync(path))) { + for (const filePath of [agentsPath, ...listFiles(memoryRoot, diagnostics)].filter((path) => existsSync(path))) { if (/\.(md|jsonl|txt)$/i.test(filePath)) { scanTextSafety(filePath, readFileSync(filePath, "utf8"), diagnostics); } @@ -472,6 +447,18 @@ try { process.exitCode = 2; } } catch (error) { - console.error(error instanceof Error ? error.message : String(error)); + if (args.includes("--json") || args.includes("--json=true")) { + print({ + ok: false, + ready: false, + error_count: 1, + diagnostics: [{ level: "error", message: error instanceof Error ? error.message : String(error) }], + backup_directory: error.backup_directory ?? null, + recovery_errors: error.recovery_errors ?? [], + }, true); + } else { + console.error(error instanceof Error ? error.message : String(error)); + if (error.backup_directory) console.error(`Recovery backups: ${error.backup_directory}`); + } process.exitCode = 1; } diff --git a/scripts/dejavu-feedback-report.mjs b/scripts/dejavu-feedback-report.mjs index e9f71a5..32810b0 100644 --- a/scripts/dejavu-feedback-report.mjs +++ b/scripts/dejavu-feedback-report.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node -import { existsSync, readFileSync } from "node:fs"; +import { existsSync } from "node:fs"; import { resolve } from "node:path"; +import { parseJsonl, validateFeedback } from "./lib/memory-validation.mjs"; const args = process.argv.slice(2); let memoryRoot = "memory"; @@ -33,25 +34,12 @@ function addRoute(id, outcome) { byMatchedId.set(key, entry); } -if (existsSync(feedbackPath)) { - for (const [index, line] of readFileSync(feedbackPath, "utf8").split(/\r?\n/).entries()) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const record = JSON.parse(trimmed); - if (!outcomes.includes(record.outcome)) continue; - byOutcome[record.outcome] += 1; - addRoute(record.matched_id, record.outcome); - } catch (error) { - diagnostics.push({ - level: "error", - message: "Invalid recall feedback record", - path: feedbackPath, - line: index + 1, - error: error instanceof Error ? error.message : String(error), - }); - } - } +const entries = parseJsonl(feedbackPath, diagnostics, "recall feedback"); +validateFeedback(entries, diagnostics, feedbackPath); +for (const { record } of entries) { + if (!outcomes.includes(record.outcome)) continue; + byOutcome[record.outcome] += 1; + addRoute(record.matched_id, record.outcome); } for (const entry of byMatchedId.values()) { @@ -84,6 +72,7 @@ console.log( JSON.stringify( { feedback_file_found: existsSync(feedbackPath), + ok: !diagnostics.some((item) => item.level === "error"), totals: byOutcome, routes, diagnostics, @@ -92,3 +81,4 @@ console.log( 2, ), ); +process.exitCode = diagnostics.some((item) => item.level === "error") ? 1 : 0; diff --git a/scripts/dejavu-lint-memory.mjs b/scripts/dejavu-lint-memory.mjs index 7df096b..35ea44e 100644 --- a/scripts/dejavu-lint-memory.mjs +++ b/scripts/dejavu-lint-memory.mjs @@ -1,376 +1,236 @@ #!/usr/bin/env node -import { existsSync, readFileSync, readdirSync, statSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync, lstatSync } from "node:fs"; import { dirname, resolve } from "node:path"; - -const args = process.argv.slice(2); -let memoryRoot = "memory"; - -for (let index = 0; index < args.length; index += 1) { - if (args[index] === "--memory-root") { - memoryRoot = args[index + 1] ?? memoryRoot; - index += 1; - } -} - -const rootPath = resolve(process.cwd(), memoryRoot); -const impressionsPath = resolve(rootPath, "impressions.jsonl"); -const feedbackPath = resolve(rootPath, "recall-feedback.jsonl"); -const summaryPath = resolve(rootPath, "summary.md"); -const diagnostics = []; -const seenIds = new Set(); -const keywordSignatures = new Map(); -const validStatuses = new Set(["active", "superseded", "archived"]); -const validOutcomes = new Set(["helpful", "irrelevant", "missed", "overloaded"]); -const genericKeywords = new Set([ - "about", - "agent", - "change", - "context", - "data", - "detail", - "file", - "general", - "info", - "memory", - "note", - "project", - "record", - "summary", - "task", - "thing", - "update", - "work", -]); - -function addDiagnostic(level, message, details = {}) { - diagnostics.push({ level, message, ...details }); -} - -function isStringArray(value) { - return Array.isArray(value) && value.every((item) => typeof item === "string" && item.length > 0); -} - -function normalizedKeywords(keywords) { - return keywords.map((keyword) => keyword.trim().toLowerCase()).filter(Boolean); -} - -function isIsoDate(value) { - return typeof value === "string" && /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?)?$/.test(value); -} - -function staysInsideRoot(path) { - const resolved = resolve(dirname(rootPath), path); - const projectRoot = dirname(rootPath); - return resolved === projectRoot || resolved.startsWith(`${projectRoot}\\`) || resolved.startsWith(`${projectRoot}/`); -} - -function parseFrontmatter(text) { - if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) { - return null; - } - const endMatch = text.match(/\r?\n---\r?\n/); - if (!endMatch || endMatch.index === undefined) { - return null; - } - const raw = text.slice(text.indexOf("\n") + 1, endMatch.index); - const data = {}; - for (const line of raw.split(/\r?\n/)) { - const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); - if (!match) continue; - data[match[1]] = match[2].trim().replace(/^["']|["']$/g, ""); - } - return data; -} - -function listMarkdownFiles(dir) { - if (!existsSync(dir)) return []; - const files = []; - for (const entry of readdirSync(dir)) { - const fullPath = resolve(dir, entry); - const stat = statSync(fullPath); - if (stat.isDirectory()) { - files.push(...listMarkdownFiles(fullPath)); - } else if (entry.endsWith(".md")) { - files.push(fullPath); - } - } - return files; -} - -function lintMarkdownRecord(filePath, kind) { - const text = readFileSync(filePath, "utf8"); - const frontmatter = parseFrontmatter(text); - if (!frontmatter) { - addDiagnostic("warning", "Markdown memory record should include YAML frontmatter", { path: filePath }); - return; - } - - for (const field of ["id", "title", "status", "scope", "updated"]) { - if (typeof frontmatter[field] !== "string" || frontmatter[field].length === 0) { - addDiagnostic("warning", `Markdown memory record missing ${field} frontmatter`, { path: filePath }); - } - } - - if (frontmatter.scope && !frontmatter.scope.startsWith("project:")) { - addDiagnostic("warning", "Markdown memory scope should use project:", { path: filePath }); - } - - if (frontmatter.status && !validStatuses.has(frontmatter.status)) { - addDiagnostic("error", "Markdown memory status must be active, superseded, or archived", { path: filePath }); - } - - if (frontmatter.updated && !isIsoDate(frontmatter.updated)) { - addDiagnostic("warning", "Markdown memory updated should use YYYY-MM-DD or ISO timestamp", { path: filePath }); - } - - if (frontmatter.status === "superseded" && !frontmatter.superseded_by) { - addDiagnostic("warning", "superseded records should include superseded_by", { path: filePath }); - } - - if (kind === "decision") { - for (const heading of ["## Decision", "## Rationale", "## Consequences"]) { - if (!text.includes(heading)) { - addDiagnostic("warning", `decision record missing ${heading}`, { path: filePath }); +import { fileURLToPath } from "node:url"; +import { parseJsonl, validateImpressions, validateRoutes, validateFeedback, isStringArray, isIsoDate, parseFrontmatter, validStatuses } from "./lib/memory-validation.mjs"; + +export function lintMemory(memoryRoot = "memory") { + const rootPath = resolve(process.cwd(), memoryRoot); + const impressionsPath = resolve(rootPath, "impressions.jsonl"); + const feedbackPath = resolve(rootPath, "recall-feedback.jsonl"); + const summaryPath = resolve(rootPath, "summary.md"); + const diagnostics = []; + const keywordSignatures = new Map(); + const genericKeywords = new Set([ + "about", + "agent", + "change", + "context", + "data", + "detail", + "file", + "general", + "info", + "memory", + "note", + "project", + "record", + "summary", + "task", + "thing", + "update", + "work", + ]); + + function addDiagnostic(level, message, details = {}) { + diagnostics.push({ level, message, ...details }); + } + + function normalizedKeywords(keywords) { + return keywords.map((keyword) => keyword.trim().toLowerCase()).filter(Boolean); + } + + function listMarkdownFiles(dir) { + if (!existsSync(dir)) return []; + if (lstatSync(dir).isSymbolicLink() || !lstatSync(dir).isDirectory()) { + addDiagnostic("error", "Memory directory must be a real directory", { path: dir }); + return []; + } + const files = []; + for (const entry of readdirSync(dir)) { + const fullPath = resolve(dir, entry); + const stat = lstatSync(fullPath); + if (stat.isSymbolicLink()) { + addDiagnostic("error", "Refusing symlink in memory tree", { path: fullPath }); + continue; } - } - } - - if (kind === "open-loop") { - for (const heading of ["## Owner", "## Opened", "## Next trigger", "## Why it matters"]) { - if (!text.includes(heading)) { - addDiagnostic("warning", `open-loop record missing ${heading}`, { path: filePath }); + if (stat.isDirectory()) { + files.push(...listMarkdownFiles(fullPath)); + } else if (entry.endsWith(".md")) { + files.push(fullPath); } } + return files; } - if (/^(user|assistant|system):/im.test(text) || text.includes("")) { - addDiagnostic("warning", "Markdown memory record looks like a transcript; durable memory should be summarized", { - path: filePath, - }); - } -} - -if (!existsSync(impressionsPath)) { - addDiagnostic("error", "Missing memory/impressions.jsonl", { path: impressionsPath }); -} else { - for (const [index, line] of readFileSync(impressionsPath, "utf8").split(/\r?\n/).entries()) { - const lineNumber = index + 1; - const trimmed = line.trim(); - if (!trimmed) continue; - - let record; - try { - record = JSON.parse(trimmed); - } catch (error) { - addDiagnostic("error", "Invalid JSONL record", { - path: impressionsPath, - line: lineNumber, - error: error instanceof Error ? error.message : String(error), - }); - continue; + function lintMarkdownRecord(filePath, kind) { + const text = readFileSync(filePath, "utf8"); + const frontmatter = parseFrontmatter(text); + if (!frontmatter) { + addDiagnostic("warning", "Markdown memory record should include YAML frontmatter", { path: filePath }); + return; } - for (const field of ["id", "scope", "title", "record_path", "updated"]) { - if (typeof record[field] !== "string" || record[field].length === 0) { - addDiagnostic("error", `Missing or invalid ${field}`, { path: impressionsPath, line: lineNumber }); + for (const field of ["id", "title", "status", "scope", "updated"]) { + if (typeof frontmatter[field] !== "string" || frontmatter[field].length === 0) { + addDiagnostic("warning", `Markdown memory record missing ${field} frontmatter`, { path: filePath }); } } - if (record.schema_version !== 1) { - addDiagnostic("warning", "Unexpected or missing schema_version", { path: impressionsPath, line: lineNumber }); + if (frontmatter.scope && !frontmatter.scope.startsWith("project:")) { + addDiagnostic("warning", "Markdown memory scope should use project:", { path: filePath }); } - if (typeof record.scope === "string" && !record.scope.startsWith("project:")) { - addDiagnostic("warning", "scope should use project:", { path: impressionsPath, line: lineNumber }); + if (frontmatter.status && !validStatuses.has(frontmatter.status)) { + addDiagnostic("error", "Markdown memory status must be active, superseded, or archived", { path: filePath }); } - if (record.status !== undefined && !validStatuses.has(record.status)) { - addDiagnostic("error", "status must be active, superseded, or archived", { - path: impressionsPath, - line: lineNumber, - id: record.id, - }); + if (frontmatter.updated && !isIsoDate(frontmatter.updated)) { + addDiagnostic("warning", "Markdown memory updated should use YYYY-MM-DD or ISO timestamp", { path: filePath }); } - if (record.weight !== undefined && (typeof record.weight !== "number" || record.weight < 0 || record.weight > 1)) { - addDiagnostic("error", "weight must be a number between 0 and 1", { - path: impressionsPath, - line: lineNumber, - id: record.id, - }); + if (frontmatter.status === "superseded" && !frontmatter.superseded_by) { + addDiagnostic("warning", "superseded records should include superseded_by", { path: filePath }); } - if (typeof record.updated === "string" && !isIsoDate(record.updated)) { - addDiagnostic("warning", "updated should use YYYY-MM-DD or ISO timestamp", { - path: impressionsPath, - line: lineNumber, - id: record.id, - }); + if (kind === "decision") { + for (const heading of ["## Decision", "## Rationale", "## Consequences"]) { + if (!text.includes(heading)) { + addDiagnostic("warning", `decision record missing ${heading}`, { path: filePath }); + } + } } - if (!isStringArray(record.keywords)) { - addDiagnostic("error", "keywords must be a non-empty string array", { path: impressionsPath, line: lineNumber }); - } else { - const keywords = normalizedKeywords(record.keywords); - const uniqueKeywords = new Set(keywords); - - if (keywords.length < 3) { - addDiagnostic("warning", "keywords should include at least 3 cue terms", { - path: impressionsPath, - line: lineNumber, - id: record.id, - }); + if (kind === "open-loop") { + for (const heading of ["## Owner", "## Opened", "## Next trigger", "## Why it matters"]) { + if (!text.includes(heading)) { + addDiagnostic("warning", `open-loop record missing ${heading}`, { path: filePath }); + } } + } - if (keywords.length > 12) { - addDiagnostic("warning", "keywords should stay at or below 12 cue terms", { - path: impressionsPath, - line: lineNumber, - id: record.id, - count: keywords.length, - }); - } + if (/^(user|assistant|system):/im.test(text) || text.includes("")) { + addDiagnostic("warning", "Markdown memory record looks like a transcript; durable memory should be summarized", { + path: filePath, + }); + } + } - if (uniqueKeywords.size !== keywords.length) { - addDiagnostic("warning", "keywords contain duplicate cue terms", { + if (!existsSync(impressionsPath)) { + addDiagnostic("error", "Missing memory/impressions.jsonl", { path: impressionsPath }); + } else { + const entries = parseJsonl(impressionsPath, diagnostics, "impression"); + validateImpressions(entries, diagnostics, impressionsPath); + for (const { record, line: lineNumber } of entries) { + if (typeof record.updated === "string" && !isIsoDate(record.updated)) { + addDiagnostic("warning", "updated should use YYYY-MM-DD or ISO timestamp", { path: impressionsPath, line: lineNumber, id: record.id, }); } - const genericMatches = keywords.filter((keyword) => genericKeywords.has(keyword)); - if (genericMatches.length >= 3) { - addDiagnostic("warning", "keywords rely on too many generic cue terms", { - path: impressionsPath, - line: lineNumber, - id: record.id, - keywords: genericMatches, - }); - } + if (isStringArray(record.keywords)) { + const keywords = normalizedKeywords(record.keywords); + const uniqueKeywords = new Set(keywords); - const signature = [...uniqueKeywords].sort().join("|"); - if (signature) { - const previous = keywordSignatures.get(signature); - if (previous) { - addDiagnostic("warning", "duplicate keyword set across impression records", { + if (keywords.length < 3) { + addDiagnostic("warning", "keywords should include at least 3 cue terms", { path: impressionsPath, line: lineNumber, id: record.id, - duplicate_of: previous.id, - duplicate_line: previous.line, }); - } else { - keywordSignatures.set(signature, { id: record.id, line: lineNumber }); } - } - } - if (record.aliases !== undefined && !isStringArray(record.aliases)) { - addDiagnostic("error", "aliases must be a string array when present", { path: impressionsPath, line: lineNumber }); - } + if (keywords.length > 12) { + addDiagnostic("warning", "keywords should stay at or below 12 cue terms", { + path: impressionsPath, + line: lineNumber, + id: record.id, + count: keywords.length, + }); + } - if (seenIds.has(record.id)) { - addDiagnostic("error", "Duplicate impression id", { path: impressionsPath, line: lineNumber, id: record.id }); - } - seenIds.add(record.id); + if (uniqueKeywords.size !== keywords.length) { + addDiagnostic("warning", "keywords contain duplicate cue terms", { + path: impressionsPath, + line: lineNumber, + id: record.id, + }); + } - if (typeof record.record_path === "string") { - if (!staysInsideRoot(record.record_path)) { - addDiagnostic("error", "record_path must stay inside the project", { - path: impressionsPath, - line: lineNumber, - record_path: record.record_path, - }); - continue; - } + const genericMatches = keywords.filter((keyword) => genericKeywords.has(keyword)); + if (genericMatches.length >= 3) { + addDiagnostic("warning", "keywords rely on too many generic cue terms", { + path: impressionsPath, + line: lineNumber, + id: record.id, + keywords: genericMatches, + }); + } - const linkedPath = resolve(dirname(rootPath), record.record_path); - if (!existsSync(linkedPath)) { - addDiagnostic("warning", "record_path does not exist", { - path: impressionsPath, - line: lineNumber, - record_path: record.record_path, - }); + const signature = [...uniqueKeywords].sort().join("|"); + if (signature) { + const previous = keywordSignatures.get(signature); + if (previous) { + addDiagnostic("warning", "duplicate keyword set across impression records", { + path: impressionsPath, + line: lineNumber, + id: record.id, + duplicate_of: previous.id, + duplicate_line: previous.line, + }); + } else { + keywordSignatures.set(signature, { id: record.id, line: lineNumber }); + } + } } } } -} - -if (existsSync(feedbackPath)) { - for (const [index, line] of readFileSync(feedbackPath, "utf8").split(/\r?\n/).entries()) { - const lineNumber = index + 1; - const trimmed = line.trim(); - if (!trimmed) continue; - - let record; - try { - record = JSON.parse(trimmed); - } catch (error) { - addDiagnostic("error", "Invalid recall feedback record", { - path: feedbackPath, - line: lineNumber, - error: error instanceof Error ? error.message : String(error), - }); - continue; - } - - if (record.schema_version !== 1) { - addDiagnostic("warning", "Unexpected or missing feedback schema_version", { path: feedbackPath, line: lineNumber }); - } - if (!validOutcomes.has(record.outcome)) { - addDiagnostic("error", "feedback outcome must be helpful, irrelevant, missed, or overloaded", { - path: feedbackPath, - line: lineNumber, - }); - } + validateFeedback(parseJsonl(feedbackPath, diagnostics, "recall feedback"), diagnostics, feedbackPath); - if (typeof record.query !== "string" || record.query.trim().length === 0) { - addDiagnostic("error", "feedback query must be a non-empty string", { path: feedbackPath, line: lineNumber }); - } + if (!existsSync(summaryPath)) { + addDiagnostic("error", "Missing memory/summary.md", { path: summaryPath }); + } else { + lintMarkdownRecord(summaryPath, "summary"); + } - if (record.matched_id !== undefined && typeof record.matched_id !== "string") { - addDiagnostic("error", "feedback matched_id must be a string when present", { path: feedbackPath, line: lineNumber }); - } + const decisionFiles = listMarkdownFiles(resolve(rootPath, "decisions")); + const openLoopFiles = listMarkdownFiles(resolve(rootPath, "open-loops")); + for (const filePath of decisionFiles) { + lintMarkdownRecord(filePath, "decision"); + } - if (!isIsoDate(record.created)) { - addDiagnostic("warning", "feedback created should use YYYY-MM-DD or ISO timestamp", { - path: feedbackPath, - line: lineNumber, - }); - } + for (const filePath of openLoopFiles) { + lintMarkdownRecord(filePath, "open-loop"); } -} -if (!existsSync(summaryPath)) { - addDiagnostic("warning", "Missing memory/summary.md", { path: summaryPath }); -} else { - lintMarkdownRecord(summaryPath, "summary"); -} + validateRoutes(parseJsonl(impressionsPath, [], "impression"), dirname(rootPath), diagnostics, [ + ...(existsSync(summaryPath) ? [summaryPath] : []), + ...decisionFiles, + ...openLoopFiles, + ]); + const errorCount = diagnostics.filter((item) => item.level === "error").length; + const warningCount = diagnostics.filter((item) => item.level === "warning").length; -for (const filePath of listMarkdownFiles(resolve(rootPath, "decisions"))) { - lintMarkdownRecord(filePath, "decision"); + return { + ok: errorCount === 0, + error_count: errorCount, + warning_count: warningCount, + diagnostics, + }; } -for (const filePath of listMarkdownFiles(resolve(rootPath, "open-loops"))) { - lintMarkdownRecord(filePath, "open-loop"); +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + const args = process.argv.slice(2); + const index = args.indexOf("--memory-root"); + try { + const result = lintMemory(index >= 0 ? args[index + 1] : "memory"); + console.log(JSON.stringify(result, null, 2)); + process.exitCode = result.ok ? 0 : 1; + } catch (error) { + console.log(JSON.stringify({ ok: false, error_count: 1, warning_count: 0, diagnostics: [{ level: "error", message: error.message }] }, null, 2)); + process.exitCode = 1; + } } - -const errorCount = diagnostics.filter((item) => item.level === "error").length; -const warningCount = diagnostics.filter((item) => item.level === "warning").length; - -console.log( - JSON.stringify( - { - ok: errorCount === 0, - error_count: errorCount, - warning_count: warningCount, - diagnostics, - }, - null, - 2, - ), -); - -process.exit(errorCount === 0 ? 0 : 1); diff --git a/scripts/dejavu-scan-memory.mjs b/scripts/dejavu-scan-memory.mjs index ced4ea4..546cd5b 100644 --- a/scripts/dejavu-scan-memory.mjs +++ b/scripts/dejavu-scan-memory.mjs @@ -1,163 +1,89 @@ #!/usr/bin/env node -import { existsSync, readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const args = process.argv.slice(2); -let fileArg = null; -let memoryRootArg = null; -const queryParts = []; - -for (let index = 0; index < args.length; index += 1) { - const arg = args[index]; - if (arg === "--file") { - fileArg = args[index + 1] ?? null; - index += 1; - } else if (arg === "--memory-root") { - memoryRootArg = args[index + 1] ?? null; - index += 1; - } else { - queryParts.push(arg); - } -} - -const query = queryParts.join(" ").trim(); -const memoryPath = fileArg - ? resolve(process.cwd(), fileArg) - : resolve(process.cwd(), memoryRootArg ?? "memory", "impressions.jsonl"); +import { existsSync, statSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { parseJsonl, validateImpressions, validateRoutes } from "./lib/memory-validation.mjs"; function tokenize(text) { return new Set((text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []).filter((token) => token.length > 1)); } - function scoreRecord(queryTokens, record) { - const keywords = [...(record.keywords ?? []), ...(record.aliases ?? []), record.title ?? ""]; - const recordTokens = tokenize(keywords.join(" ")); + const recordTokens = tokenize([...(record.keywords ?? []), ...(record.aliases ?? []), record.title ?? ""].join(" ")); const matched = [...queryTokens].filter((token) => recordTokens.has(token)); - if (queryTokens.size === 0 || recordTokens.size === 0) { - return { score: 0, matchedKeywords: [] }; - } - const overlap = matched.length / Math.min(queryTokens.size, recordTokens.size); - const weight = typeof record.weight === "number" ? record.weight : 0.5; + const overlap = queryTokens.size && recordTokens.size ? matched.length / Math.min(queryTokens.size, recordTokens.size) : 0; + return { score: Math.min(1, overlap * (0.75 + (record.weight ?? 0.5) * 0.25)), matched_keywords: matched }; +} +function result(level, diagnostics, matches = [], scanned = 0) { + const ok = !diagnostics.some((item) => item.level === "error"); return { - score: Math.min(1, overlap * (0.75 + weight * 0.25)), - matchedKeywords: matched, + ok, matched: ok && (level === "weak" || level === "strong"), level, + score: matches[0]?.score ?? 0, matches, + budget: { impression_scan: scanned, summaries_loaded: 0, detail_records_loaded: 0, + why_loaded: [level === "none" ? "cue scan found no match; no memory loaded" : ok ? `cue scan found a ${level} familiarity match` : "memory setup or validation failed; do not treat as no match"] }, + feedback_hint: { outcomes: ["helpful", "irrelevant", "missed", "overloaded"], write_to: "memory/recall-feedback.jsonl" }, + writeback_hint: { after_work: [ + "durable decision -> memory/decisions/ + memory/impressions.jsonl", + "unresolved follow-up -> memory/open-loops/ + memory/impressions.jsonl", + "project-level truth changed -> memory/summary.md + memory/impressions.jsonl", + "low-value one-off trace -> memory/events/ or skip", + "recall was wrong, missed, noisy, or overloaded -> memory/recall-feedback.jsonl", + ] }, + diagnostics, }; } -function levelFor(score) { - if (score >= 0.7) return "strong"; - if (score >= 0.35) return "weak"; - return "none"; -} - -if (!query) { - console.error('Usage: node scripts/dejavu-scan-memory.mjs [--memory-root memory] [--file memory/impressions.jsonl] "task or query"'); - process.exit(2); -} - -if (!existsSync(memoryPath)) { - console.log( - JSON.stringify( - { - matched: false, - level: "not_initialized", - score: 0, - matches: [], - budget: { - impression_scan: 0, - summaries_loaded: 0, - detail_records_loaded: 0, - why_loaded: ["memory/impressions.jsonl was not found"], - }, - diagnostics: [ - { - id: "missing-impressions", - level: "error", - message: "Deja Vu memory is not initialized", - path: memoryPath, - }, - ], - bootstrap_hint: { - required_files: ["AGENTS.md", "memory/summary.md", "memory/impressions.jsonl"], - next_step: "copy starter-kit/. into the repo or run deja-vu init", - }, - }, - null, - 2, - ), - ); - process.exit(0); -} - -const queryTokens = tokenize(query); -const matches = []; -const diagnostics = []; - -for (const [index, line] of readFileSync(memoryPath, "utf8").split(/\r?\n/).entries()) { - const trimmed = line.trim(); - if (!trimmed) continue; - try { - const record = JSON.parse(trimmed); - if (record.status && record.status !== "active") continue; - const result = scoreRecord(queryTokens, record); - if (result.score > 0) { - matches.push({ - id: record.id, - title: record.title, - record_path: record.record_path, - score: Number(result.score.toFixed(4)), - matched_keywords: result.matchedKeywords, - }); - } - } catch (error) { - diagnostics.push({ - id: `invalid-line-${index + 1}`, - level: "error", - message: "Invalid impression record", - path: memoryPath, - line: index + 1, - error: error instanceof Error ? error.message : String(error), - }); +function scan() { + const args = process.argv.slice(2); + let file, memoryRoot; + const parts = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === "--file" || args[i] === "--memory-root") { + const flag = args[i]; + const value = args[++i]; + if (!value || value.startsWith("--")) throw new Error(`Missing value for ${flag}`); + if (flag === "--file") file = value; else memoryRoot = value; + } else if (args[i].startsWith("--")) throw new Error(`Unknown option: ${args[i]}`); + else parts.push(args[i]); } + const query = parts.join(" ").trim(); + if (!query) throw new Error('Usage: deja-vu-scan-memory [--memory-root memory | --file impressions.jsonl] "query"'); + if (file && memoryRoot) throw new Error("Choose either --file or --memory-root"); + const memoryPath = file ? resolve(file) : resolve(memoryRoot ?? "memory", "impressions.jsonl"); + const projectRoot = dirname(dirname(memoryPath)); + const required = file ? [memoryPath] : [resolve(projectRoot, "AGENTS.md"), resolve(dirname(memoryPath), "summary.md"), memoryPath]; + const missing = required.filter((path) => !existsSync(path)); + if (missing.length) { + return { ...result("not_initialized", missing.map((path) => ({ level: "error", message: "Required memory file not found", path }))), + bootstrap_hint: { required_files: ["AGENTS.md", "memory/summary.md", "memory/impressions.jsonl"], next_step: "copy starter-kit/. into the repo or run deja-vu init" } }; + } + const diagnostics = []; + for (const path of required) { + if (!statSync(path).isFile()) diagnostics.push({ level: "error", message: "Required path is not a file", path }); + } + const entries = parseJsonl(memoryPath, diagnostics, "impression"); + validateImpressions(entries, diagnostics, memoryPath); + if (diagnostics.some((d) => d.level === "error")) return result("error", diagnostics, [], 1); + const tokens = tokenize(query); + const matches = entries.filter(({record}) => !record.status || record.status === "active") + .map(({record}) => ({ id: record.id, title: record.title, record_path: record.record_path, ...scoreRecord(tokens, record) })) + .filter((match) => match.score > 0) + .map((match) => ({...match, score: Number(match.score.toFixed(4))})) + .sort((a, b) => b.score - a.score).slice(0, 5); + const score = matches[0]?.score ?? 0; + const level = score >= 0.7 ? "strong" : score >= 0.35 ? "weak" : "none"; + // --file is cue-only. Project scans validate returned routes, not the memory tree. + if (!file && level !== "none") { + const ids = new Set(matches.map((match) => match.id)); + validateRoutes(entries.filter(({record}) => ids.has(record.id)), projectRoot, diagnostics, + [resolve(dirname(memoryPath), "summary.md")], entries); + } + return result(diagnostics.some((d) => d.level === "error") ? "error" : level, diagnostics, matches, 1); } -matches.sort((a, b) => b.score - a.score); -const topScore = matches[0]?.score ?? 0; -const level = levelFor(topScore); -const budget = { - impression_scan: 1, - summaries_loaded: 0, - detail_records_loaded: 0, - why_loaded: - level === "none" - ? ["cue scan found no match; no memory loaded"] - : [`cue scan found a ${level} familiarity match`], -}; - -console.log( - JSON.stringify( - { - matched: level !== "none", - level, - score: topScore, - matches: matches.slice(0, 5), - budget, - feedback_hint: { - outcomes: ["helpful", "irrelevant", "missed", "overloaded"], - write_to: "memory/recall-feedback.jsonl", - }, - writeback_hint: { - after_work: [ - "durable decision -> memory/decisions/ + memory/impressions.jsonl", - "unresolved follow-up -> memory/open-loops/ + memory/impressions.jsonl", - "project-level truth changed -> memory/summary.md + memory/impressions.jsonl", - "low-value one-off trace -> memory/events/ or skip", - "recall was wrong, missed, noisy, or overloaded -> memory/recall-feedback.jsonl", - ], - }, - diagnostics, - }, - null, - 2, - ), -); +try { + const output = scan(); + console.log(JSON.stringify(output, null, 2)); + process.exitCode = output.ok ? 0 : 1; +} catch (error) { + console.log(JSON.stringify(result("error", [{level: "error", message: error.message}]), null, 2)); + process.exitCode = 1; +} diff --git a/scripts/lib/init-files.mjs b/scripts/lib/init-files.mjs new file mode 100644 index 0000000..918113a --- /dev/null +++ b/scripts/lib/init-files.mjs @@ -0,0 +1,107 @@ +import * as nodeFs from "node:fs"; +import { dirname, join } from "node:path"; +import { randomUUID } from "node:crypto"; + +export function plannedFile(plan, operation, path, content, reason) { + const entry = { operation, path, ...(reason ? { reason } : {}) }; + // The JSON plan must never echo rules or existing memory contents. + Object.defineProperty(entry, "content", { value: content }); + plan.push(entry); +} + +export function executePlan(plan, { cwd, dryRun = false, fs = nodeFs } = {}) { + const writes = plan.filter((item) => ["create", "overwrite", "append"].includes(item.operation)); + const check = (path) => { + let cursor = path; + while (true) { + if (fs.existsSync(cursor)) { + const stat = fs.lstatSync(cursor); + if (stat.isSymbolicLink()) throw new Error(`Refusing symlink initialization target: ${cursor}`); + if (cursor !== path && !stat.isDirectory()) throw new Error(`Parent is not a directory: ${cursor}`); + if (cursor === path && !stat.isFile()) throw new Error(`Target is not a regular file: ${cursor}`); + } + const parent = dirname(cursor); + if (parent === cursor) break; + cursor = parent; + } + }; + // Preflight every target, even skipped files, before any file changes. + for (const item of plan) check(item.path); + const snapshots = new Map(writes.map((item) => [item.path, fs.existsSync(item.path) ? fs.readFileSync(item.path) : null])); + for (const item of writes) { + let parent = dirname(item.path); + while (!fs.existsSync(parent)) parent = dirname(parent); + fs.accessSync(parent, nodeFs.constants.W_OK); + if (snapshots.get(item.path)) fs.accessSync(item.path, nodeFs.constants.W_OK); + } + if (dryRun) return { backup_directory: null, backups_required: [...snapshots.values()].filter(Boolean).length }; + + let backupDirectory = null; + const staged = new Map(); + const completed = []; + const changedDirectories = new Set(); + const ensureDirectory = (path) => { + if (fs.existsSync(path)) return; + ensureDirectory(dirname(path)); + fs.mkdirSync(path); + changedDirectories.add(path); + }; + try { + const originals = writes.filter((item) => snapshots.get(item.path) !== null); + if (originals.length) { + const backupRoot = join(cwd, ".deja-vu-backups"); + if (fs.existsSync(backupRoot) && (fs.lstatSync(backupRoot).isSymbolicLink() || !fs.statSync(backupRoot).isDirectory())) throw new Error("Invalid backup directory"); + fs.mkdirSync(backupRoot, { recursive: true }); + const ignorePath = join(backupRoot, ".gitignore"); + if (!fs.existsSync(ignorePath)) fs.writeFileSync(ignorePath, "*\n", { flag: "wx", mode: 0o600 }); + else if (fs.lstatSync(ignorePath).isSymbolicLink() || fs.readFileSync(ignorePath, "utf8").trim() !== "*") throw new Error("Backup directory requires a private '*' .gitignore"); + backupDirectory = fs.mkdtempSync(join(backupRoot, "init-")); + const manifest = originals.map((item, index) => { + const name = `${index}.bak`; + fs.writeFileSync(join(backupDirectory, name), snapshots.get(item.path), { flag: "wx", mode: 0o600 }); + return { path: item.path, backup: name }; + }); + fs.writeFileSync(join(backupDirectory, "manifest.json"), JSON.stringify({ files: manifest }, null, 2), { flag: "wx", mode: 0o600 }); + } + for (const item of writes) { + ensureDirectory(dirname(item.path)); + const path = `${item.path}.deja-vu-${randomUUID()}.tmp`; + staged.set(item.path, path); + fs.writeFileSync(path, item.content, { flag: "wx", mode: 0o600 }); + if (snapshots.get(item.path)) fs.chmodSync(path, fs.statSync(item.path).mode); + } + for (const item of writes) { + const before = snapshots.get(item.path); + const current = fs.existsSync(item.path) ? fs.readFileSync(item.path) : null; + if ((before === null) !== (current === null) || (before && !before.equals(current))) throw new Error(`Target changed during initialization: ${item.path}`); + fs.renameSync(staged.get(item.path), item.path); + staged.delete(item.path); + completed.push(item); + } + return { backup_directory: backupDirectory, backups_required: originals.length }; + } catch (cause) { + const recoveryErrors = []; + for (const item of completed.reverse()) { + try { + if (!fs.readFileSync(item.path).equals(Buffer.from(item.content, "utf8"))) throw new Error("Target changed after write; manual recovery required"); + const before = snapshots.get(item.path); + if (before === null) fs.unlinkSync(item.path); + else { + const temp = `${item.path}.deja-vu-${randomUUID()}.tmp`; + staged.set(item.path, temp); + fs.writeFileSync(temp, before, { flag: "wx", mode: fs.statSync(item.path).mode }); + fs.renameSync(temp, item.path); + staged.delete(item.path); + } + } catch (error) { recoveryErrors.push({ path: item.path, message: error.message }); } + } + const error = new Error(`Initialization failed: ${cause.message}`, { cause }); + error.backup_directory = backupDirectory; + error.recovery_errors = recoveryErrors; + throw error; + } finally { + for (const path of staged.values()) { try { fs.unlinkSync(path); } catch {} } + // Only remove empty directories created by this operation. + for (const path of [...changedDirectories].reverse()) { try { fs.rmdirSync(path); } catch {} } + } +} diff --git a/scripts/lib/memory-validation.mjs b/scripts/lib/memory-validation.mjs new file mode 100644 index 0000000..09f09d5 --- /dev/null +++ b/scripts/lib/memory-validation.mjs @@ -0,0 +1,177 @@ +import { existsSync, readFileSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, relative, resolve } from "node:path"; + +export const validStatuses = new Set(["active", "superseded", "archived"]); +export const validOutcomes = new Set(["helpful", "irrelevant", "missed", "overloaded"]); +export const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string" && item.trim().length > 0); +export const isIsoDate = (value) => typeof value === "string" && /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z?)?$/.test(value); + +export function parseJsonl(filePath, diagnostics, kind = "memory") { + if (!existsSync(filePath)) return []; + const entries = []; + let text; + try { text = new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(filePath)); } + catch (error) { + diagnostics.push({ level: "error", message: `Cannot read ${kind} as UTF-8`, path: filePath, error: error.message }); + return entries; + } + for (const [index, line] of text.split(/\r?\n/).entries()) { + if (!line.trim()) continue; + try { + const record = JSON.parse(line); + if (!record || typeof record !== "object" || Array.isArray(record)) throw new Error("Record must be a non-null object"); + entries.push({ record, line: index + 1 }); + } catch { + // JSON.parse messages can quote raw memory. Keep diagnostics content-free. + diagnostics.push({ level: "error", message: `Invalid ${kind} record`, path: filePath, line: index + 1, code: "invalid_jsonl_record" }); + } + } + return entries; +} + +export function validateImpressions(entries, diagnostics, path) { + const ids = new Set(); + let scope; + for (const { record, line } of entries) { + const error = (message) => diagnostics.push({ level: "error", message, path, line }); + for (const field of ["id", "scope", "title", "record_path", "updated"]) { + if (typeof record[field] !== "string" || !record[field].trim()) error(`Missing or invalid ${field}`); + } + if (record.schema_version !== 1) error("schema_version must be 1"); + if (!/^project:[^\s:]+$/.test(record.scope ?? "")) error("scope must use project:"); + if (scope && record.scope !== scope) error("Mixed project scopes in impressions"); + scope ??= record.scope; + if (ids.has(record.id)) error("Duplicate impression id"); + ids.add(record.id); + if (!isStringArray(record.keywords) || record.keywords.length === 0) error("keywords must be a non-empty string array"); + if (record.aliases !== undefined && !isStringArray(record.aliases)) error("aliases must be a string array when present"); + if (record.status !== undefined && !validStatuses.has(record.status)) error("status must be active, superseded, or archived"); + if (record.weight !== undefined && (typeof record.weight !== "number" || !Number.isFinite(record.weight) || record.weight < 0 || record.weight > 1)) error("weight must be a number between 0 and 1"); + if (record.superseded_by !== undefined && (typeof record.superseded_by !== "string" || !record.superseded_by.trim())) error("superseded_by must be a non-empty string"); + } +} + +export function validateFeedback(entries, diagnostics, path) { + for (const { record, line } of entries) { + const add = (level, message) => diagnostics.push({ level, message, path, line }); + if (!validOutcomes.has(record.outcome)) add("error", "feedback outcome must be helpful, irrelevant, missed, or overloaded"); + if (typeof record.query !== "string" || !record.query.trim()) add("error", "feedback query must be a non-empty string"); + if (record.matched_id !== undefined && typeof record.matched_id !== "string") add("error", "feedback matched_id must be a string when present"); + if (!isIsoDate(record.created)) add("warning", "feedback created should use YYYY-MM-DD or ISO timestamp"); + } +} + +export function parseFrontmatter(text) { + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/); + if (!match) return null; + const fields = Object.create(null); + for (const line of match[1].split(/\r?\n/)) { + const field = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/); + if (field) fields[field[1]] = field[2].trim().replace(/^["']|["']$/g, ""); + } + return fields; +} + +export function resolveRecordPath(projectRoot, path) { + if (typeof path !== "string" || !path.trim() || isAbsolute(path) || /^[A-Za-z]:/.test(path)) throw new Error("record_path must be project-relative"); + const resolved = resolve(projectRoot, path); + const inside = (base, target) => { + const rel = relative(base, target); + return rel !== ".." && !rel.startsWith("../") && !rel.startsWith("..\\") && !isAbsolute(rel); + }; + if (!inside(projectRoot, resolved)) throw new Error("record_path must stay inside the project"); + if (existsSync(resolved) && !inside(realpathSync(projectRoot), realpathSync(resolved))) throw new Error("record_path resolves outside the project"); + return resolved; +} + +export function validateRoutes(entries, projectRoot, diagnostics, extraPaths = [], cueEntries = entries) { + const documents = new Map(); + const inspect = (path) => { + if (documents.has(path)) return documents.get(path); + let frontmatter; + try { + if (!statSync(path).isFile()) throw new Error("Target is not a file"); + frontmatter = parseFrontmatter(new TextDecoder("utf-8", { fatal: true }).decode(readFileSync(path))); + } catch (error) { + diagnostics.push({ level: "error", message: "record_path does not exist or is unreadable", path, error: error.message }); + documents.set(path, null); + return null; + } + documents.set(path, frontmatter); + return frontmatter; + }; + const scopes = new Set(entries.map(({ record }) => record.scope).filter(Boolean)); + for (const { record, line } of entries) { + try { + const path = resolveRecordPath(projectRoot, record.record_path); + const meta = inspect(path); + if (meta?.scope && meta.scope !== record.scope) diagnostics.push({ level: "error", message: "Route and target scopes differ", path, line }); + if ((!record.status || record.status === "active") && meta?.status && meta.status !== "active") { + diagnostics.push({ level: "error", message: "Active route points to an inactive record", path, line }); + } + } catch (error) { + diagnostics.push({ level: "error", message: error.message, line, record_path: record.record_path }); + } + } + for (const path of extraPaths) { + try { inspect(resolveRecordPath(projectRoot, relative(projectRoot, path))); } + catch (error) { diagnostics.push({ level: "error", message: error.message, path }); } + } + const byId = new Map(cueEntries.map(({ record }) => [record.id, record])); + const edges = new Map(); + for (const { record } of cueEntries) { + if (record.status === "superseded" && !record.superseded_by) diagnostics.push({ level: "error", message: "Superseded impression requires superseded_by", id: record.id }); + if (record.superseded_by) { + if (!byId.has(record.superseded_by)) diagnostics.push({ level: "error", message: "Impression superseded_by does not resolve", id: record.id }); + else edges.set("cue:" + record.id, "cue:" + record.superseded_by); + } + } + // Following successors also validates targets not directly routed by an impression. + for (const [path, meta] of documents) { + if (!meta) continue; + if (scopes.size === 1 && meta.scope && !scopes.has(meta.scope)) diagnostics.push({ level: "error", message: "Memory record belongs to a different project scope", path }); + if (meta.status === "superseded" && !meta.superseded_by) diagnostics.push({ level: "error", message: "Superseded record requires superseded_by", path }); + if (meta.superseded_by) { + try { + const next = resolveRecordPath(projectRoot, meta.superseded_by); + const target = inspect(next); + if (target?.scope && meta.scope && target.scope !== meta.scope) diagnostics.push({ level: "error", message: "Supersession crosses project scopes", path }); + edges.set("file:" + path, "file:" + next); + } catch (error) { diagnostics.push({ level: "error", message: error.message, path }); } + } + } + for (const start of edges.keys()) { + const seen = new Set(); + let cursor = start; + while (edges.has(cursor)) { + if (seen.has(cursor)) { diagnostics.push({ level: "error", message: "Supersession cycle", path: start }); break; } + seen.add(cursor); + cursor = edges.get(cursor); + } + } +} + +export function parseRules(text) { + const start = ""; + const end = ""; + if (text.includes(start) || text.includes(end)) { + if (text.split(start).length !== 2 || text.split(end).length !== 2 || text.indexOf(end) < text.indexOf(start)) return null; + text = text.slice(text.indexOf(start) + start.length, text.indexOf(end)); + } + const versions = [...text.matchAll(//g)]; + if (versions.length > 1 || (versions.length === 1 && versions[0][1] !== "1")) return null; + const scopes = [...text.matchAll(/Scope: `([^`]+)`/g)]; + if (scopes.length !== 1 || !/^project:[^\s:]+$/.test(scopes[0][1])) return null; + // Recognize the shipped legacy contract, not mere mentions of its filenames. + const valid = /Protocol: Deja Vu Protocol v0\.4/.test(text) + && /^\d+\. Inspect `memory\/impressions\.jsonl` for familiar cues\.$/m.test(text.replace(/\r/g, "")) + && /^\d+\. If there is no familiarity, do not load memory by default\.$/m.test(text.replace(/\r/g, "")) + && /^\d+\. If familiarity is weak, read `memory\/summary\.md`\.$/m.test(text.replace(/\r/g, "")) + && /^\d+\. If familiarity is strong, read only the 1-3 linked (?:detailed )?records needed for the task\.$/m.test(text.replace(/\r/g, "")) + && /Durable Writeback Only/.test(text); + return valid ? { scope: scopes[0][1] } : null; +} + +export function rulesStatus(text) { + return parseRules(text) !== null; +} diff --git a/src/core/semantic-recall-engine.ts b/src/core/semantic-recall-engine.ts index 3e18aef..159ae29 100644 --- a/src/core/semantic-recall-engine.ts +++ b/src/core/semantic-recall-engine.ts @@ -14,6 +14,7 @@ import type { RecallInput, RecallResult, SummaryRecord, + StoredMemory, UpdateMemoryInput, } from "../types/memory.js"; import type { SemanticRecallEngineConfig } from "../types/plugins.js"; @@ -21,6 +22,20 @@ import { createId } from "../utils/id.js"; import { extractKeywords } from "../utils/text.js"; import { isoNow } from "../utils/time.js"; +/** Includes a recovery copy if an adapter fails during compensation. Do not log its contents. */ +export class MemoryWriteError extends Error { + constructor( + message: string, + cause: unknown, + readonly recoverySnapshot: StoredMemory | null, + readonly recoveryErrors: unknown[], + readonly memoryId: string, + ) { + super(message, { cause }); + this.name = "MemoryWriteError"; + } +} + export class SemanticRecallEngine { private readonly familiarityLayer: FamiliarityLayer; private readonly summaryLayer: SummaryLayer; @@ -29,6 +44,13 @@ export class SemanticRecallEngine { private readonly summaryGenerator; private readonly thresholds; private readonly scoringStrategy; + private mutationQueue: Promise = Promise.resolve(); + + private mutate(operation: () => Promise): Promise { + const result = this.mutationQueue.then(operation); + this.mutationQueue = result.catch(() => undefined); + return result; + } constructor(private readonly config: SemanticRecallEngineConfig) { this.familiarityLayer = new FamiliarityLayer(config.storage, config.familiarityVectorStore); @@ -41,6 +63,16 @@ export class SemanticRecallEngine { } async addMemory(input: AddMemoryInput): Promise<{ id: string }> { + return this.mutate(async () => { + const id = input.id ?? createId("memory"); + if (await this.snapshot(id)) throw new Error(`Memory already exists: ${id}; use updateMemory`); + const prepared = await this.prepareMemory({ ...input, id }); + await this.commitMemory(prepared, null); + return { id }; + }); + } + + private async prepareMemory(input: AddMemoryInput): Promise { const id = input.id ?? createId("memory"); const now = isoNow(); const shortSummary = await this.summaryGenerator.generateShortSummary(input); @@ -89,12 +121,62 @@ export class SemanticRecallEngine { embeddingVector: summaryVector, }; - await this.familiarityLayer.save(familiarity); - await this.summaryLayer.save(summary); - await this.chunkLayer.save(chunks); - await this.config.storage.saveRawContent(id, input.content, now, now); + return { familiarity, summary, chunks, rawContent: input.content, createdAt: now, updatedAt: now }; + } + + private async snapshot(id: string): Promise { + if (this.config.storage.getMemorySnapshot) { + return structuredClone(await this.config.storage.getMemorySnapshot(id)); + } + const familiarity = await this.config.storage.getFamiliarity(id); + const summary = await this.config.storage.getSummary(id); + const rawContent = await this.config.storage.getRawContent(id); + const chunks = await this.config.storage.getChunks(id); + if (!familiarity && !summary && rawContent === null && chunks.length === 0) return null; + if (!familiarity || !summary || rawContent === null) { + throw new Error(`Incomplete memory: ${id}; repair storage before writing`); + } + // Legacy adapters do not expose raw timestamps. They can implement getMemorySnapshot to preserve them. + const createdAt = chunks[0]?.createdAt ?? familiarity.lastAccessedAt; + return structuredClone({ familiarity, summary, rawContent, chunks, createdAt, updatedAt: createdAt }); + } - return { id }; + private async commitMemory(next: StoredMemory, previous: StoredMemory | null): Promise { + const id = next.familiarity.id; + try { + // Generation has already completed and a detached recovery snapshot exists. + // Remove the old chunks too, including when the replacement has zero chunks. + if (previous) { + await this.config.storage.deleteMemory(id); + await this.config.chunkVectorStore.removeByMemoryId(id); + } + await this.summaryLayer.save(next.summary); + await this.chunkLayer.save(next.chunks); + await this.config.storage.saveRawContent(id, next.rawContent, next.createdAt, next.updatedAt); + await this.familiarityLayer.save(next.familiarity); + } catch (cause) { + const recoveryErrors: unknown[] = []; + const attempt = async (operation: () => Promise) => { + try { await operation(); } catch (error) { recoveryErrors.push(error); } + }; + await attempt(() => this.config.storage.deleteMemory(id)); + await attempt(() => this.config.familiarityVectorStore.remove(id)); + await attempt(() => this.config.chunkVectorStore.removeByMemoryId(id)); + if (previous) { + await attempt(() => this.config.storage.saveSummary(previous.summary)); + await attempt(() => this.config.storage.saveChunks(previous.chunks)); + await attempt(() => this.config.storage.saveRawContent( + id, previous.rawContent, previous.createdAt, previous.updatedAt, + )); + await attempt(() => this.config.storage.saveFamiliarity(previous.familiarity)); + await attempt(() => this.config.familiarityVectorStore.upsert(previous.familiarity)); + await attempt(() => this.config.chunkVectorStore.upsert(previous.chunks)); + } + throw new MemoryWriteError( + recoveryErrors.length ? "Memory write failed; recovery requires adapter repair" : "Memory write failed; previous state restored", + cause, previous, recoveryErrors, id, + ); + } } async recall(query: RecallInput | string): Promise { @@ -210,33 +292,38 @@ export class SemanticRecallEngine { } async updateMemory(id: string, input: UpdateMemoryInput): Promise<{ id: string }> { - const existingSummary = await this.summaryLayer.get(id); - const rawContent = await this.config.storage.getRawContent(id); - if (!existingSummary || rawContent === null) { - throw new Error(`Memory not found: ${id}`); - } - - await this.deleteMemory(id); - return this.addMemory({ - id, - title: input.title ?? String(existingSummary.metadata.title ?? id), - content: input.content ?? rawContent, - tags: (input.tags as string[] | undefined) ?? (existingSummary.metadata.tags as string[] | undefined), - context: input.context ?? existingSummary.context, - architectureOrIntent: input.architectureOrIntent ?? existingSummary.architectureOrIntent, - recentUpdates: input.recentUpdates ?? existingSummary.recentUpdates, - importance: input.importance, - metadata: { - ...existingSummary.metadata, - ...(input.metadata ?? {}), - }, - chunkStrategy: input.chunkStrategy, + return this.mutate(async () => { + const previous = await this.snapshot(id); + if (!previous) throw new Error(`Memory not found: ${id}`); + const existingSummary = previous.summary; + const prepared = await this.prepareMemory({ + id, + title: input.title ?? previous.familiarity.title, + content: input.content ?? previous.rawContent, + tags: input.tags ?? previous.familiarity.tags, + context: input.context ?? existingSummary.context, + architectureOrIntent: input.architectureOrIntent ?? existingSummary.architectureOrIntent, + recentUpdates: input.recentUpdates ?? existingSummary.recentUpdates, + importance: input.importance ?? previous.familiarity.importance, + metadata: { + ...existingSummary.metadata, + ...(input.metadata ?? {}), + title: input.title ?? previous.familiarity.title, + tags: input.tags ?? previous.familiarity.tags, + }, + chunkStrategy: input.chunkStrategy, + }); + prepared.createdAt = previous.createdAt; + await this.commitMemory(prepared, previous); + return { id }; }); } async deleteMemory(id: string): Promise { - await this.familiarityLayer.remove(id); - await this.chunkLayer.remove(id); - await this.config.storage.deleteMemory(id); + return this.mutate(async () => { + await this.familiarityLayer.remove(id); + await this.chunkLayer.remove(id); + await this.config.storage.deleteMemory(id); + }); } } diff --git a/src/storage/in-memory-storage.ts b/src/storage/in-memory-storage.ts index 36bea1b..087f8db 100644 --- a/src/storage/in-memory-storage.ts +++ b/src/storage/in-memory-storage.ts @@ -1,4 +1,4 @@ -import type { FamiliarityRecord, MemoryChunk, SummaryRecord } from "../types/memory.js"; +import type { FamiliarityRecord, MemoryChunk, SummaryRecord, StoredMemory } from "../types/memory.js"; import type { ImpressionSearchHit, MemoryStorage } from "../types/plugins.js"; import { lexicalSimilarity } from "../utils/text.js"; @@ -15,6 +15,19 @@ export class InMemoryStorage implements MemoryStorage { private readonly chunks = new Map(); private readonly raw = new Map(); + async getMemorySnapshot(id: string): Promise { + const familiarity = this.familiarities.get(id); + const summary = this.summaries.get(id); + const raw = this.raw.get(id); + const chunks = this.chunks.get(id) ?? []; + if (!familiarity && !summary && !raw && chunks.length === 0) return null; + if (!familiarity || !summary || !raw) throw new Error(`Incomplete memory: ${id}`); + return structuredClone({ + familiarity, summary, chunks, rawContent: raw.content, + createdAt: raw.createdAt, updatedAt: raw.updatedAt, + }); + } + async saveFamiliarity(record: FamiliarityRecord): Promise { this.removeFromImpressionIndex(record.id); this.familiarities.set(record.id, record); diff --git a/src/types/plugins.ts b/src/types/plugins.ts index 3c03bc5..2932a5c 100644 --- a/src/types/plugins.ts +++ b/src/types/plugins.ts @@ -5,6 +5,7 @@ import type { RecallInput, ScoredCandidate, SummaryRecord, + StoredMemory, } from "./memory.js"; export interface EmbeddingProvider { @@ -55,6 +56,8 @@ export interface ChunkVectorStore { } export interface MemoryStorage { + /** Optional complete snapshot, including raw timestamps, for recoverable updates. */ + getMemorySnapshot?(id: string): Promise; saveFamiliarity(record: FamiliarityRecord): Promise; saveSummary(record: SummaryRecord): Promise; saveChunks(chunks: MemoryChunk[]): Promise; diff --git a/tests/memory-cli.test.ts b/tests/memory-cli.test.ts index d679a12..5550cab 100644 --- a/tests/memory-cli.test.ts +++ b/tests/memory-cli.test.ts @@ -56,7 +56,7 @@ test("scan CLI reports unmatched when overlap stays below threshold", () => { "utf8", ); - const result = runJson(scanScript, ["alpha delta epsilon zeta eta theta"], project); + const result = runJson(scanScript, ["--file", "memory/impressions.jsonl", "alpha delta epsilon zeta eta theta"], project); assert.equal(result.level, "none"); assert.equal(result.matched, false); @@ -70,7 +70,7 @@ test("scan CLI reports unmatched when overlap stays below threshold", () => { test("scan CLI distinguishes missing setup from no familiarity", () => { const project = mkdtempSync(join(tmpdir(), "dejavu-scan-missing-")); - const result = runJson(scanScript, ["settings refactor"], project); + const result = runJsonAllowFailure(scanScript, ["settings refactor"], project); assert.equal(result.level, "not_initialized"); assert.equal(result.matched, false); diff --git a/tests/package-pack.test.ts b/tests/package-pack.test.ts index a0bda30..76bb6ab 100644 --- a/tests/package-pack.test.ts +++ b/tests/package-pack.test.ts @@ -24,6 +24,8 @@ test("package metadata exposes memory CLI binaries and starter kit", () => { assert.equal(result[0].name, "@focaxisdev/deja-vu"); assert.equal(result[0].version, "0.7.0"); assert.ok(files.has("scripts/deja-vu.mjs")); + assert.ok(files.has("scripts/lib/init-files.mjs")); + assert.ok(files.has("scripts/lib/memory-validation.mjs")); assert.ok(files.has("scripts/check-markdown-links.mjs")); assert.ok(files.has("scripts/dejavu-scan-memory.mjs")); assert.ok(files.has("scripts/dejavu-lint-memory.mjs")); @@ -52,4 +54,25 @@ test("package metadata exposes memory CLI binaries and starter kit", () => { encoding: "utf8", }); assert.ok(help.includes("deja-vu init --merge-agents")); + + const runInstalled = (script: string, args: string[]) => JSON.parse(execFileSync( + process.execPath, [join(installedRoot, "scripts", script), ...args], + { cwd: installRoot, encoding: "utf8" }, + )); + assert.equal(runInstalled("deja-vu.mjs", ["init", "--project-id", "project:smoke", "--json"]).ready, true); + assert.equal(runInstalled("deja-vu.mjs", ["doctor", "--json"]).ok, true); + assert.equal(runInstalled("dejavu-lint-memory.mjs", []).ok, true); + assert.equal(runInstalled("dejavu-scan-memory.mjs", ["project constraints"]).ok, true); + assert.equal(runInstalled("dejavu-feedback-report.mjs", []).ok, true); + const smoke = execFileSync(process.execPath, ["--input-type=module", "-e", ` + import { createInMemorySemanticRecallEngine, MemoryWriteError } from "@focaxisdev/deja-vu"; + const engine = createInMemorySemanticRecallEngine(); + await engine.addMemory({ id: "smoke", title: "Smoke", content: "original", importance: 0.9 }); + await engine.updateMemory("smoke", { content: "updated" }); + if (!(await engine.getSummary("smoke")) || typeof MemoryWriteError !== "function") throw new Error("Missing API"); + await engine.deleteMemory("smoke"); + if (await engine.getSummary("smoke")) throw new Error("Delete failed"); + console.log("ok"); + `], { cwd: installRoot, encoding: "utf8" }); + assert.equal(smoke.trim(), "ok"); }); diff --git a/tests/reliability.test.ts b/tests/reliability.test.ts new file mode 100644 index 0000000..ed4d47d --- /dev/null +++ b/tests/reliability.test.ts @@ -0,0 +1,392 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import * as fs from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { pathToFileURL } from "node:url"; +import { + SemanticRecallEngine, MemoryWriteError, InMemoryStorage, + InMemoryFamiliarityVectorStore, InMemoryChunkVectorStore, + DefaultSummaryGenerator, DefaultChunker, +} from "../src/index.js"; + +// Inject failure before OR after an adapter mutates, then allow compensation. +function faultProxy(target: T, label: string, fault: { point: string; after: boolean; persistent?: boolean }): T { + return new Proxy(target, { + get(object, key) { + const value = Reflect.get(object, key); + if (typeof value !== "function") return value; + return async (...args: unknown[]) => { + const hit = fault.point === label + "." + String(key); + if (hit && !fault.persistent) fault.point = ""; + if (hit && !fault.after) throw new Error("injected failure"); + const result = await value.apply(object, args); + if (hit) throw new Error("injected failure"); + return result; + }; + }, + }); +} + +function harness() { + const fault = { point: "", after: false, persistent: false }; + const storage = new InMemoryStorage(); + const familiarity = new InMemoryFamiliarityVectorStore(); + const chunks = new InMemoryChunkVectorStore(); + const embedding = { async embed() { return [1, 0]; } }; + const engine = new SemanticRecallEngine({ + storage: faultProxy(storage, "storage", fault), + familiarityVectorStore: faultProxy(familiarity, "familiarity", fault), + chunkVectorStore: faultProxy(chunks, "chunks", fault), + embeddingProvider: faultProxy(embedding, "embedding", fault), + summaryGenerator: faultProxy(new DefaultSummaryGenerator(), "summary", fault), + chunker: new DefaultChunker(), + }); + return { engine, storage, familiarity, chunks, fault }; +} +const original = { id: "stable", title: "Settings", content: "Decision: preserve settings sync.", importance: 0.95, tags: ["settings"] }; + +for (const point of ["embedding.embed", "summary.generateShortSummary", "summary.generateStructuredSummary"]) { + test(`preparation failure leaves previous memory intact: ${point}`, async () => { + const h = harness(); + await h.engine.addMemory(original); + const before = await h.storage.getMemorySnapshot("stable"); + h.fault.point = point; + await assert.rejects(h.engine.updateMemory("stable", { content: "replacement" }), /injected/); + assert.deepEqual(await h.storage.getMemorySnapshot("stable"), before); + }); +} + +for (const point of ["storage.deleteMemory", "storage.saveSummary", "storage.saveChunks", "storage.saveRawContent", "storage.saveFamiliarity", "chunks.removeByMemoryId", "chunks.upsert", "familiarity.upsert"]) { + for (const after of [false, true]) { + test(`update compensates ${after ? "after" : "before"} mutation: ${point}`, async () => { + const h = harness(); + await h.engine.addMemory(original); + const before = await h.storage.getMemorySnapshot("stable"); + const vectorBefore = await h.chunks.searchByMemoryId("stable", [1, 0], 99); + h.fault.point = point; + h.fault.after = after; + await assert.rejects(h.engine.updateMemory("stable", { content: "replacement" }), (error: unknown) => { + assert.ok(error instanceof MemoryWriteError); + assert.equal(error.memoryId, "stable"); + assert.deepEqual(error.recoveryErrors, []); + return true; + }); + assert.deepEqual(await h.storage.getMemorySnapshot("stable"), before); + assert.deepEqual(await h.chunks.searchByMemoryId("stable", [1, 0], 99), vectorBefore); + assert.deepEqual(await h.familiarity.search([1, 0], 99), [{ id: "stable", similarity: 1 }]); + }); + } +} + +test("failed new add removes partial storage and vectors; queue remains usable", async () => { + const h = harness(); + h.fault.point = "familiarity.upsert"; + h.fault.after = true; + await assert.rejects(h.engine.addMemory(original), MemoryWriteError); + assert.equal(await h.storage.getMemorySnapshot("stable"), null); + assert.deepEqual(await h.familiarity.search([1, 0], 99), []); + assert.deepEqual(await h.chunks.searchByMemoryId("stable", [1, 0], 99), []); + await h.engine.addMemory(original); +}); + +for (const failAt of [2, 3]) { + test(`later embedding failure preserves the original memory (call ${failAt})`, async () => { + const storage = new InMemoryStorage(); + let calls = 0; + let armed = false; + const engine = new SemanticRecallEngine({ + storage, + familiarityVectorStore: new InMemoryFamiliarityVectorStore(), + chunkVectorStore: new InMemoryChunkVectorStore(), + embeddingProvider: { async embed() { + if (armed && ++calls === failAt) throw new Error("late embedding failure"); + return [1, 0]; + } }, + }); + await engine.addMemory(original); + const before = await storage.getMemorySnapshot("stable"); + armed = true; + await assert.rejects(engine.updateMemory("stable", { content: "new content" }), /late embedding failure/); + assert.deepEqual(await storage.getMemorySnapshot("stable"), before); + }); +} + +test("legacy adapters without snapshots remain usable without deleting absent IDs", async () => { + const storage = new InMemoryStorage(); + const legacy = new Proxy(storage, { + get(target, key) { + if (key === "getMemorySnapshot") return undefined; + if (key === "deleteMemory") return async (id: string) => { + assert.ok(await storage.getFamiliarity(id), "adapter rejects deletion of missing IDs"); + return storage.deleteMemory(id); + }; + const value = Reflect.get(target, key); + return typeof value === "function" ? value.bind(target) : value; + }, + }); + const engine = new SemanticRecallEngine({ + storage: legacy, + familiarityVectorStore: new InMemoryFamiliarityVectorStore(), + chunkVectorStore: new InMemoryChunkVectorStore(), + embeddingProvider: { async embed() { return [1, 0]; } }, + }); + await engine.addMemory(original); + await engine.updateMemory("stable", { content: "legacy update" }); + assert.equal(await storage.getRawContent("stable"), "legacy update"); + assert.equal((await storage.getFamiliarity("stable"))?.importance, 0.95); +}); + +test("persistent adapter failure exposes a detached recovery snapshot", async () => { + const h = harness(); + await h.engine.addMemory(original); + const before = await h.storage.getMemorySnapshot("stable"); + h.fault.point = "storage.saveSummary"; + h.fault.persistent = true; + await assert.rejects(h.engine.updateMemory("stable", { content: "replacement" }), (error: unknown) => { + assert.ok(error instanceof MemoryWriteError); + assert.ok(error.recoveryErrors.length > 0); + assert.deepEqual(error.recoverySnapshot, before); + return true; + }); +}); + +test("updates preserve importance and createdAt, reject duplicate adds, and clear empty chunks", async () => { + const h = harness(); + await h.engine.addMemory(original); + const before = await h.storage.getMemorySnapshot("stable"); + await assert.rejects(h.engine.addMemory(original), /already exists/); + await h.engine.updateMemory("stable", { content: "" }); + const next = await h.storage.getMemorySnapshot("stable"); + assert.equal(next?.familiarity.importance, 0.95); + assert.equal(next?.createdAt, before?.createdAt); + assert.equal(next?.rawContent, ""); + assert.deepEqual(await h.engine.getChunks("stable"), []); + assert.deepEqual(await h.chunks.searchByMemoryId("stable", [1, 0], 99), []); +}); + +test("concurrent updates retain each other's fields", async () => { + const h = harness(); + await h.engine.addMemory(original); + await Promise.all([ + h.engine.updateMemory("stable", { title: "Renamed" }), + h.engine.updateMemory("stable", { content: "New content" }), + ]); + const next = await h.storage.getMemorySnapshot("stable"); + assert.equal(next?.familiarity.title, "Renamed"); + assert.equal(next?.rawContent, "New content"); +}); + +const root = process.cwd(); +function cli(script: string, args: string[], cwd: string) { + const result = spawnSync(process.execPath, [join(root, "scripts", script), ...args], { cwd, encoding: "utf8" }); + assert.equal(result.error, undefined); + return { status: result.status, data: JSON.parse(result.stdout) }; +} +function project() { + const path = fs.mkdtempSync(join(tmpdir(), "dejavu-reliability-")); + assert.equal(cli("deja-vu.mjs", ["init", "--project-id", "project:test", "--json"], path).data.ready, true); + return path; +} +const cue = { schema_version: 1, id: "settings", scope: "project:test", title: "Settings sync", keywords: ["settings", "sync", "preferences"], record_path: "memory/detail.md", status: "active", updated: "2026-09-17" }; +function record(status = "active", extra = "", scope = "project:test") { + return `---\nid: detail\ntitle: Settings\nstatus: ${status}\nscope: ${scope}\nupdated: 2026-09-17\n${extra}---\n# Settings\n`; +} +function routeProject(content: string, entry = cue) { + const path = project(); + fs.writeFileSync(join(path, "memory", "detail.md"), content); + fs.writeFileSync(join(path, "memory", "impressions.jsonl"), JSON.stringify(entry) + "\n"); + return path; +} + +for (const invalid of ["null\n", "[]\n", "{broken\n", '{"keywords":null}\n']) { + test(`malformed impressions return JSON errors, not no familiarity: ${invalid.trim()}`, () => { + const path = project(); + fs.writeFileSync(join(path, "memory", "impressions.jsonl"), invalid); + for (const [script, args] of [ + ["deja-vu.mjs", ["doctor", "--json"]], + ["dejavu-lint-memory.mjs", []], + ["dejavu-scan-memory.mjs", ["settings sync"]], + ] as const) { + const result = cli(script, [...args], path); + assert.equal(result.status, 1); + assert.equal(result.data.ok, false); + assert.ok(result.data.diagnostics.length); + } + }); +} + +for (const [name, content] of [ + ["inactive", record("superseded", "superseded_by: memory/missing.md\n")], + ["cross scope", record("active", "", "project:other")], + ["cycle", record("superseded", "superseded_by: memory/detail.md\n")], +]) { + test(`doctor, lint and scan reject ${name} routes`, () => { + const path = routeProject(content); + for (const [script, args] of [ + ["deja-vu.mjs", ["doctor", "--json"]], + ["dejavu-lint-memory.mjs", []], + ["dejavu-scan-memory.mjs", ["settings sync"]], + ] as const) { + const result = cli(script, [...args], path); + assert.equal(result.status, 1); + assert.equal(result.data.ok, false); + } + }); +} + +test("invalid feedback is an error, not silently excluded as healthy", () => { + const path = project(); + fs.writeFileSync(join(path, "memory", "recall-feedback.jsonl"), '{"outcome":"invalid","query":"settings"}\n'); + assert.equal(cli("dejavu-feedback-report.mjs", [], path).status, 1); +}); + +test("missing targets, unsafe traversal and unresolved cue successors fail validation", () => { + for (const entry of [ + { ...cue, record_path: "memory/missing.md" }, + { ...cue, record_path: "../outside.md" }, + { ...cue, status: "superseded", superseded_by: "missing-id" }, + { ...cue, status: "superseded", superseded_by: cue.id }, + ]) { + const path = routeProject(record(), entry); + assert.equal(cli("deja-vu.mjs", ["doctor", "--json"], path).status, 1); + assert.equal(cli("dejavu-lint-memory.mjs", [], path).status, 1); + } +}); + +test("all required project files are checked; cue-only mode is explicit", () => { + const path = project(); + fs.unlinkSync(join(path, "memory", "summary.md")); + const missing = cli("dejavu-scan-memory.mjs", ["settings"], path); + assert.equal(missing.status, 1); + assert.equal(missing.data.level, "not_initialized"); + assert.equal(cli("dejavu-scan-memory.mjs", ["--file", "memory/impressions.jsonl", "settings"], path).status, 0); +}); + +test("init refuses scope mismatch and unknown block versions without overwriting", () => { + const path = project(); + const original = fs.readFileSync(join(path, "AGENTS.md"), "utf8"); + assert.equal(cli("deja-vu.mjs", ["init", "--project-id", "project:other", "--json"], path).data.ready, false); + fs.writeFileSync(join(path, "AGENTS.md"), original.replace("rules:version=1", "rules:version=99")); + assert.equal(cli("deja-vu.mjs", ["doctor", "--json"], path).status, 1); + assert.equal(cli("deja-vu.mjs", ["init", "--merge-agents", "--json"], path).data.ready, false); +}); + +test("readiness uses only the validated rules block scope", () => { + for (const blockMatches of [false, true]) { + const path = project(); + const agentsPath = join(path, "AGENTS.md"); + const generated = fs.readFileSync(agentsPath, "utf8"); + const block = blockMatches ? generated : generated.replace("Scope: `project:test`", "Scope: `project:wrong`"); + const outside = blockMatches ? "project:unrelated" : "project:test"; + const text = `# Unrelated rules\n\n- Scope: \`${outside}\`\n\n${block}`; + fs.writeFileSync(agentsPath, text); + const doctor = cli("deja-vu.mjs", ["doctor", "--json"], path); + const init = cli("deja-vu.mjs", ["init", "--json"], path); + assert.equal(doctor.data.ok, blockMatches); + assert.equal(doctor.status, blockMatches ? 0 : 1); + assert.equal(init.data.ready, blockMatches); + assert.equal(fs.readFileSync(agentsPath, "utf8"), text); + } +}); + +test("duplicate scopes inside a rules block require manual review", () => { + const path = project(); + const agentsPath = join(path, "AGENTS.md"); + const generated = fs.readFileSync(agentsPath, "utf8"); + fs.writeFileSync(agentsPath, generated.replace("Scope: `project:test`", "Scope: `project:test`\n- Scope: `project:wrong`")); + assert.equal(cli("deja-vu.mjs", ["doctor", "--json"], path).data.ok, false); + assert.equal(cli("deja-vu.mjs", ["init", "--json"], path).data.ready, false); +}); + +test("JSONL parse diagnostics never echo malformed record contents", () => { + const sentinel = "PRIVATE_DEMO_VALUE"; + for (const [filename, commands] of [ + ["impressions.jsonl", [["deja-vu.mjs", ["doctor", "--json"]], ["dejavu-lint-memory.mjs", []], ["dejavu-scan-memory.mjs", ["settings"]]]], + ["recall-feedback.jsonl", [["deja-vu.mjs", ["doctor", "--json"]], ["dejavu-lint-memory.mjs", []], ["dejavu-feedback-report.mjs", []]]], + ] as const) { + const path = project(); + const file = join(path, "memory", filename); + fs.writeFileSync(file, `\n${sentinel}\n`); + for (const [script, args] of commands) { + const result = cli(script, [...args], path); + assert.equal(result.status, 1); + assert.equal(result.data.ok, false); + assert.equal(JSON.stringify(result.data).includes(sentinel), false); + assert.ok(result.data.diagnostics.some((item: { path?: string; line?: number; code?: string }) => + item.path === file && item.line === 2 && item.code === "invalid_jsonl_record")); + } + assert.equal(fs.readFileSync(file, "utf8"), `\n${sentinel}\n`); + } +}); + +test("executable demo preserves lexical limits and grounds the successful recall", () => { + const memoryRoot = join(root, "docs", "examples", "settings-project", "memory"); + const vague = cli("dejavu-scan-memory.mjs", ["--memory-root", memoryRoot, "Continue the settings refactor."], root); + assert.equal(vague.status, 0); + assert.equal(vague.data.level, "none"); + assert.equal(vague.data.score, 0.2437); + const explicit = cli("dejavu-scan-memory.mjs", ["--memory-root", memoryRoot, "settings sync"], root); + assert.equal(explicit.status, 0); + assert.equal(explicit.data.level, "strong"); + assert.equal(explicit.data.score, 0.975); + assert.equal(explicit.data.matches[0].id, "decision-settings-sync"); + assert.equal(explicit.data.matches[1].score, 0.475); + assert.match(fs.readFileSync(join(memoryRoot, "decisions", "settings-sync.md"), "utf8"), /IME/); + assert.match(fs.readFileSync(join(memoryRoot, "open-loops", "settings-migration.md"), "utf8"), /legacy setting names/); +}); + +test("negative rule mentions and partial markers require manual review", () => { + for (const content of ["Never read memory/impressions.jsonl or memory/summary.md.\n", "\n"]) { + const path = project(); + fs.writeFileSync(join(path, "AGENTS.md"), content); + const result = cli("deja-vu.mjs", ["init", "--merge-agents", "--json"], path); + assert.equal(result.data.ready, false); + assert.equal(fs.readFileSync(join(path, "AGENTS.md"), "utf8"), content); + assert.equal(cli("deja-vu.mjs", ["doctor", "--json"], path).status, 1); + } +}); + +test("force backs up original bytes and nonexistent cwd is created safely", () => { + const parent = fs.mkdtempSync(join(tmpdir(), "dejavu-new-cwd-")); + const path = join(parent, "nested", "project"); + assert.equal(cli("deja-vu.mjs", ["init", "--cwd", path, "--json"], parent).data.ready, true); + const before = fs.readFileSync(join(path, "AGENTS.md")); + const result = cli("deja-vu.mjs", ["init", "--force", "--json"], path); + assert.equal(result.data.ready, true); + const backup = result.data.backup_directory; + const manifest = JSON.parse(fs.readFileSync(join(backup, "manifest.json"), "utf8")); + const entry = manifest.files.find((item: { path: string }) => item.path === join(path, "AGENTS.md")); + assert.deepEqual(fs.readFileSync(join(backup, entry.backup)), before); +}); + +test("init preflight prevents partial writes when a parent is a file", () => { + const path = fs.mkdtempSync(join(tmpdir(), "dejavu-preflight-")); + fs.writeFileSync(join(path, "memory"), "keep"); + const result = cli("deja-vu.mjs", ["init", "--json"], path); + assert.equal(result.status, 1); + assert.equal(result.data.ok, false); + assert.equal(fs.existsSync(join(path, "AGENTS.md")), false); + assert.equal(fs.readFileSync(join(path, "memory"), "utf8"), "keep"); +}); + +test("init restores prior bytes when a staged rename fails", async () => { + const { executePlan, plannedFile } = await import(pathToFileURL(join(root, "scripts", "lib", "init-files.mjs")).href); + const path = fs.mkdtempSync(join(tmpdir(), "dejavu-rollback-")); + const a = join(path, "a.md"); + const b = join(path, "b.md"); + const originalBytes = Buffer.from([0xff, 0x00, 0x81]); + fs.writeFileSync(a, originalBytes); + const plan: unknown[] = []; + plannedFile(plan, "overwrite", a, "changed"); + plannedFile(plan, "create", b, "new"); + const injectedFs = { ...fs, renameSync(from: fs.PathLike, to: fs.PathLike) { + if (to === b) throw new Error("injected rename failure"); + fs.renameSync(from, to); + } }; + assert.throws(() => executePlan(plan, { cwd: path, fs: injectedFs }), /Initialization failed/); + assert.deepEqual(fs.readFileSync(a), originalBytes); + assert.equal(fs.existsSync(b), false); + assert.equal(fs.readdirSync(path).some((name) => name.endsWith(".tmp")), false); +});