diff --git a/AGENTS.md b/AGENTS.md index a337eab..4f24754 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,6 +42,7 @@ npx github:sparkling/ruflo-source-patch adr-reindex install # adds /adr-re npx github:sparkling/ruflo-source-patch verify-interface install # reopen ruvnet-brain's unopenable PreToolUse gate (#12). RETIRED as of ruvnet-brain 3.2.9 (auto-retires; see ADR-010) npx github:sparkling/ruflo-source-patch mcp-prefix install # rewrite bundled mcp__claude-flow__* refs to mcp__plugin_ruflo-core_ruflo__* — dead under plugin loading (#2685) npx github:sparkling/ruflo-source-patch design-wall install # scope ruvnet-brain's design-grade commit gate to its own repo — it fires on ANY repo's README otherwise (ruvnet-brain#17) +npx github:sparkling/ruflo-source-patch memory-health install # fix the Onboarding Console's memory-health card scoring the WRONG project (background refresh spawned with the plugin's own cwd) ``` ### Keep it live (actions add `run | check`) diff --git a/README.md b/README.md index 42c699a..2d98ad9 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,7 @@ Actions: `install` · `uninstall` · `status` |--------|---------------|----------| | **`mcp-prefix`** | **The plugins' own tools are dead under plugin loading.** Every `ruflo` plugin bundles skills/agents/commands/hooks that name the MCP tools `mcp__claude-flow__*`. That prefix resolves **only** when the server is registered *standalone* under the key `claude-flow` ([#2206](https://github.com/ruvnet/ruflo/issues/2206)). Used **as a plugin** (the marketplace path), Claude Code namespaces the plugin's bundled server, so the same tools are exposed as `mcp__plugin_ruflo-core_ruflo__*`. Per Claude Code's own MCP docs, *"a hook matcher written against the bare server key … never fires for a plugin-bundled server,"* so the bundled `allowed-tools` globs grant nothing and prompt tool names name tools that don't exist. The platform **won't** bridge it ([anthropics/claude-code#29360](https://github.com/anthropics/claude-code/issues/29360) and [#15145](https://github.com/anthropics/claude-code/issues/15145) are both *closed as not planned*). Measured: **3,482 refs across 474 files in ~30 packages**; only `ruflo-core` ships a server, so the plugin-namespaced name is uniformly `mcp__plugin_ruflo-core_ruflo__*`. Rewrites the bare prefix to it. Correct where it takes effect (the files load only when the plugin is enabled, and then that prefix resolves), inert where it doesn't. It does **not** touch the CLI init generators that emit the same prefix into *your project* files, where that prefix is right for standalone-registered projects, so it's genuinely environment-dependent and out of scope | [#2685](https://github.com/ruvnet/ruflo/issues/2685) | | **`design-wall`** | **A visual-design gate that gates everyone else's repos too.** `ruvnet-brain`'s `design-wall.sh` blocks `git commit` on a staged `README.md`/`explainer/`/`console/` file until a fresh design-grade stamp is recorded, a sound idea for ruvnet-brain's OWN visual surfaces, applied to every other repository on the machine as well, since `${CLAUDE_PROJECT_DIR:-.}` is never checked for identity before requiring the ritual. Measured: blocked a plain-markdown README commit in an unrelated CLI tool's repo. Reads the project's git origin and requires it to actually name `ruvnet-brain`/`stuinfla` before any stamp is required; ruvnet-brain's own commits are still gated exactly as before | [stuinfla/ruvnet-brain#17](https://github.com/stuinfla/ruvnet-brain/issues/17) | +| **`memory-health`** | **The Onboarding Console's memory-health card scores the wrong project.** The console's "Your memory, proven" card is correct on the very first request: `gatherMemory(cwd)` checks the real launch directory's `.swarm/memory.db`. But every read after that is served from an on-disk cache, refreshed by a **detached child process** that `kickRefresh()` spawns with `cwd: REPO`, the plugin's OWN install directory, not the project being served. `REPO` has no project memory store, so the refresh genuinely reports "Liveness: fail" for the wrong project and writes that into the cache, which is then served to every subsequent request. Measured: a project with a real, actively-written 122MB `.swarm/memory.db` scored 33/100 with "this project has no memory store yet". The console never calls `process.chdir()`, so reading the live `process.cwd()` at spawn time (instead of the hardcoded `REPO`) is enough; no argument needs threading through anything | [stuinfla/ruvnet-brain (memory-health card)](https://github.com/stuinfla/ruvnet-brain) | ### Script targets @@ -1200,6 +1201,7 @@ this machine*, which is the only form of the question that can be acted on. | [ruvnet-brain#12](https://github.com/stuinfla/ruvnet-brain/issues/12) **fixed in 3.2.9, target retired** | `verify-interface.sh`'s PreToolUse gate was **unopenable**: its tool regex swallowed any hyphenated binary name (`ruflo-source-patch …` → `ruflo …`) and matched inside plain English prose (`another ruflo process is writing` → `ruflo process is`), while the documented `RUVNET_SKIP_INTERFACE_CHECK=1` override was read from the hook's own environment, where a caller could never set it. **Upstream shipped its own complete rewrite in v3.2.9** (commit `bfc2d36`): real JSON parsing and a command-position-anchored matcher with a working override | `verify-interface` (retired, ADR-010) | | [ruvnet-brain#13](https://github.com/stuinfla/ruvnet-brain/issues/13) **fixed in 3.2.9** | The same hook parsed its JSON payload with a **regex**, and `[^"]*` could not cross a quote, so a command containing an escaped `"` was **truncated at the first one**. `bash -c "ruflo memory search"` reached the gate as `bash -c \` and ran unchecked. **Fixed by the same v3.2.9 rewrite**: the payload is now parsed as real JSON | `verify-interface` (retired, ADR-010) | | [ruvnet-brain#17](https://github.com/stuinfla/ruvnet-brain/issues/17) | `design-wall.sh`'s commit gate never checks **which repository** it is running in before requiring a design-grade stamp. A plain README commit in an entirely unrelated repo trips the identical visual-design ritual meant for ruvnet-brain's own explainer/console surfaces. Reads the project's git origin and requires it to actually name `ruvnet-brain`/`stuinfla` first | `design-wall` | +| ruvnet-brain (memory-health card, filed alongside this patch) | The Onboarding Console's background cache-refresh child spawns with `cwd: REPO` (the plugin's own install directory) instead of the server's actual launch directory, so the memory-health card scores the WRONG project's `.swarm/memory.db` on every request after the first. Reads the live `process.cwd()` at spawn time instead (the console never calls `process.chdir()`, so the two are always identical) | `memory-health` | | [#2633](https://github.com/ruvnet/ruflo/issues/2633) | Unbounded daemon proliferation. `.claude-flow`/`.swarm` state and the daemon dedup lock anchored to raw `process.cwd()` | `cwd`, `daemon`, `cleanup` | | [#2640](https://github.com/ruvnet/ruflo/issues/2640) | `ruflo init` bundle duplicates plugin-provided skills/commands/agents (100% / 97% overlap) | `dedupe-bundle` | | [#2638](https://github.com/ruvnet/ruflo/issues/2638) | `ruflo init` (CLAUDE.md) and `codex init` (AGENTS.md) generate divergent instruction files | `dual-codex-claude` | diff --git a/bin/cli.mjs b/bin/cli.mjs index f2053bc..df84677 100755 --- a/bin/cli.mjs +++ b/bin/cli.mjs @@ -51,6 +51,7 @@ import { adrReindexCommand } from '../lib/adr-reindex/commands.mjs'; import { verifyInterfaceCommand } from '../lib/verify-interface/commands.mjs'; import { mcpPrefixCommand } from '../lib/mcp-prefix/commands.mjs'; import { designWallCommand } from '../lib/design-wall/commands.mjs'; +import { memoryHealthCommand } from '../lib/memory-health/commands.mjs'; const ACTIONS = new Set(['install', 'init', 'uninstall', 'remove', 'status', 'run', 'check']); // `plugin-only` is the current name (it does more than dedupe a bundle now: strips the plugin-duplicated @@ -75,6 +76,9 @@ const PLUGIN_PATCH_TARGETS = { // ruvnet-brain again, a different script: its design-grade commit gate never checks which repo // it is running in before demanding a visual design ritual for a plain README.md commit. 'design-wall': designWallCommand, + // ruvnet-brain a THIRD time: its Onboarding Console's background cache refresh spawns with the + // plugin's own cwd instead of the server's, so the memory-health card scores the wrong project. + 'memory-health': memoryHealthCommand, }; function usage() { @@ -104,6 +108,8 @@ Plugin patches (ruvnet-brain) (actions: install | uninstall | status) ${pad('')} with a documented override that cannot work (stuinfla/ruvnet-brain#12) ${pad('design-wall')}its design-grade commit gate never checks which repo it's running in — ${pad('')} an unrelated repo's plain README.md commit trips the same visual-design wall + ${pad('memory-health')}its Onboarding Console scores the PLUGIN's own dir's memory store, not + ${pad('')} your project's — a cache-refresh child spawns with the wrong cwd Plugin patches (all ruflo plugins) (actions: install | uninstall | status) ${pad('mcp-prefix')}bundled skills/agents name tools \`mcp__claude-flow__*\`, which never resolve diff --git a/docs/adr/ADR-025-memory-health-kickrefresh-inherits-server-cwd.md b/docs/adr/ADR-025-memory-health-kickrefresh-inherits-server-cwd.md new file mode 100644 index 0000000..3c270b2 --- /dev/null +++ b/docs/adr/ADR-025-memory-health-kickrefresh-inherits-server-cwd.md @@ -0,0 +1,83 @@ +# ADR-025: memory-health: kickRefresh's cache-refresh child inherits the server's cwd + +**Status**: accepted +**Date**: 2026-07-24 +**Deciders**: Henrik Pettersen +**Tags**: patch-target, plugin, ruvnet-brain + +## Context + +`ruvnet-brain` ships an Onboarding Console (`scripts/onboarding-console.mjs`, served via +`/brain-console` and aliases) whose "Your memory, proven" card scores a project's memory-store +health across five weighted dimensions (liveness, coverage, recall quality, compaction survival, +session surfacing). A `fail` on any tested dimension caps the whole score at <=49. + +Measured live (2026-07-24): a project with a genuine, 122MB, actively-written +`.swarm/memory.db` (confirmed by `stat`, and separately confirmed healthy via the AgentDB/ruflo +memory bridge: 10,875 entries, 16,390 patterns learned) scored 33/100. The liveness probe reported +status `fail`, detail "this project has no memory store (.swarm/memory.db) yet". + +The card is correct on the very first request: `startServer({ cwd = process.cwd() })` captures the +real launch directory, and `gatherMemory(cwd)` checks `path.join(cwd, '.swarm/memory.db')` against +it. But every subsequent read is served from an on-disk cache +(`~/.claude/ruvnet-brain/state-cache.json` and siblings), refreshed by a **detached child process** +that `kickRefresh()` spawns at most once per 15 seconds: + +```js +function kickRefresh() { + const now = Date.now(); + if (now - LAST_REFRESH_KICK < 15000) return; + LAST_REFRESH_KICK = now; + try { + const child = spawn(process.execPath, [SELF, '--refresh-cache'], + { detached: true, stdio: 'ignore', cwd: REPO }); + child.unref(); + } catch { /* best-effort */ } +} +``` + +`REPO` (`path.dirname(__dirname)`, the plugin's own install directory) is hardcoded as the child's +`cwd`. The child was spawned with `cwd: REPO`, so the `--refresh-cache` CLI branch's call to +`gatherState(process.cwd())` resolves to the plugin's own directory, never the project being +served. `REPO` has no project `.swarm/memory.db`, so `gatherMemory()` falls back to scoring `REPO` +itself: a genuine "no store" result, for the wrong project, written straight into the on-disk cache +and served to every request thereafter (including the very next page load), then re-derived every +15 seconds by the next kick. Verified against the live server process (bound cwd: the correct +project), the live `/api/state` response (wrong: reports the plugin's own directory), and the +on-disk cache file (also wrong, byte-identical shape to what the console rendered). + +## Decision + +Read the live `process.cwd()` at spawn time instead of the hardcoded `REPO` constant: + +```js +const child = spawn(process.execPath, [SELF, '--refresh-cache'], + { detached: true, stdio: 'ignore', cwd: process.cwd() }); +``` + +This is sound because the console's server process never calls `process.chdir()` anywhere in the +file (grepped, zero hits), so `process.cwd()` read at the exact moment `kickRefresh()` fires is +always identical to the cwd the server was launched with: the same value `startServer()`'s own +default parameter and the `--serve`/`--print-state` CLI branches already use. No argument needs to +be threaded through `kickRefresh()`'s signature or either of its two call sites +(`startServer()`'s listen callback, and `serveCached()`'s warm-cache branch). + +Single literal edit, applied wherever `onboarding-console.mjs` is found installed. Measured: only +under the marketplace checkout's `scripts/` on this machine today. Neither `plugin/scripts/` nor +any cache copy ships this file, unlike `verify-interface.sh`/`design-wall.sh`; `discover()` still +checks all three shapes so a future layout change is caught rather than silently missed. + +## Consequences + +### Positive + +- The memory-health card scores the ACTUAL project the console was launched for, on every request, + not just the first. +- No behavior change to anything else `REPO` is used for in this file (SBOM path, script-runner + invocations, gate surveys); only the one spawn call inside `kickRefresh()` is touched. + +### Negative + +- None identified. The fix strictly narrows an existing bug's blast radius; it does not change any + documented behavior. `discover()`'s `plugin/scripts/` and cache-copy checks are speculative + (nothing to patch there today) and cost only an `fs.existsSync` each. diff --git a/lib/memory-health/README.md b/lib/memory-health/README.md new file mode 100644 index 0000000..3ffd3b0 --- /dev/null +++ b/lib/memory-health/README.md @@ -0,0 +1,53 @@ +# `memory-health` + +[← ruflo-source-patch](../../README.md) + +The Onboarding Console's memory-health card, scoring the wrong project. + +Patches the **`ruvnet-brain`** plugin's `scripts/onboarding-console.mjs`, the local server behind +`/brain-console` (aka `/rvbc`, `/rvcb`, `/ruvnet-brain:configure`). Its "Your memory, proven" card +reported a project's `.swarm/memory.db` as missing, "Liveness: fail", capping the score at 33/100, +on a project with a real, 122MB, actively-written store. + +## The bug + +`gatherMemory(cwd)` scores whichever project the console server was launched from. That much is +correct on the very first request. But every read after that is served from an on-disk cache +(`~/.claude/ruvnet-brain/state-cache.json` and friends), refreshed by a **detached child process** +that `kickRefresh()` spawns at most once per 15 seconds: + +```js +const child = spawn(process.execPath, [SELF, '--refresh-cache'], + { detached: true, stdio: 'ignore', cwd: REPO }); +``` + +`REPO` is `path.dirname(__dirname)`: the **plugin's own install directory**, not the project being +served. The refresh child's `--refresh-cache` branch calls `gatherState(process.cwd())`, which +resolves to `REPO` because that is the `cwd` it was spawned with. `REPO` has no project +`.swarm/memory.db` of its own, so the health check genuinely fails, for the wrong project, and +that result is written into the cache and served to every subsequent request, re-derived every 15s +by the next kick. + +## The fix + +The console's server process never calls `process.chdir()` anywhere, so `process.cwd()` read at +the moment `kickRefresh()` fires is always identical to the cwd the server was launched with. No +argument needs to be threaded through anything: + +```js +const child = spawn(process.execPath, [SELF, '--refresh-cache'], + { detached: true, stdio: 'ignore', cwd: process.cwd() }); +``` + +The refresh child now inherits the real project directory, exactly like the server's own +`--serve`/`--print-state` CLI paths already do. + +## Usage + +```bash +npx github:sparkling/ruflo-source-patch memory-health install +npx github:sparkling/ruflo-source-patch memory-health status +npx github:sparkling/ruflo-source-patch memory-health uninstall +``` + +See [ADR-025](../../docs/adr/ADR-025-memory-health-kickrefresh-inherits-server-cwd.md). diff --git a/lib/memory-health/commands.mjs b/lib/memory-health/commands.mjs new file mode 100644 index 0000000..db6453f --- /dev/null +++ b/lib/memory-health/commands.mjs @@ -0,0 +1,8 @@ +// Dispatch for the `memory-health` target — fixes the ruvnet-brain Onboarding Console's +// background cache refresh scoring the WRONG project's memory store (see patcher.mjs for what is +// broken). The install/uninstall/status flow and the shared composition engine live in +// ../plugin-command.mjs (ADR-020). + +import { runPluginCommand } from '../plugin-command.mjs'; + +export const memoryHealthCommand = (action) => runPluginCommand('memory-health', action); diff --git a/lib/memory-health/patcher.mjs b/lib/memory-health/patcher.mjs new file mode 100644 index 0000000..6a5219a --- /dev/null +++ b/lib/memory-health/patcher.mjs @@ -0,0 +1,120 @@ +// Patches the installed `ruvnet-brain` plugin's Onboarding Console server, +// `scripts/onboarding-console.mjs` (stuinfla/ruvnet-brain, memory-health card). +// +// THE BUG. The console's "Your memory, proven" card scores the memory store of the project the +// server was launched FROM — gatherMemory(cwd) checks `path.join(cwd, '.swarm/memory.db')`, and +// startServer() correctly captures that real `cwd` at launch. But every read after the first is +// served from an on-disk cache (serveCached(), STATE_CACHE/etc.), refreshed by a DETACHED CHILD +// PROCESS that kickRefresh() spawns at most once per 15s: +// +// const child = spawn(process.execPath, [SELF, '--refresh-cache'], +// { detached: true, stdio: 'ignore', cwd: REPO }); +// +// `REPO` is `path.dirname(__dirname)` — the PLUGIN's own install directory, not the project the +// server is serving. The refresh child's `--refresh-cache` branch calls +// `gatherState(process.cwd())`, which resolves to `REPO` because that is the `cwd` the child was +// spawned with. `REPO` has no `.swarm/memory.db` of its own, so `gatherMemory()` falls back to +// scoring `REPO` itself, produces a genuine "Liveness: fail" for the WRONG project, and writes +// that result into the on-disk cache — where it is then served to every subsequent request, +// including the very next page load, and re-written every 15s by the next kick. Measured live +// (2026-07-24): a project with a real, 122MB, actively-written `.swarm/memory.db` scored 33/100 +// with "this project has no memory store (.swarm/memory.db) yet", because the number being shown +// was ruvnet-brain's OWN directory, never the project's. +// +// The console's own server process never calls `process.chdir()` anywhere in this file (grepped), +// so `process.cwd()` read at the moment `kickRefresh()` fires is always identical to the cwd the +// server was launched with. The fix does not need to thread a `cwd` argument through anything — +// it only needs the spawn call to stop hardcoding `REPO` and read the live `process.cwd()` instead, +// exactly as `startServer()`'s own default parameter and the `--print-state`/`--serve` CLI branches +// already do. +// +// WHY PATCH RATHER THAN EDIT IN PLACE: a `/plugin update` re-fetches ruvnet-brain wholesale and +// reverts any hand-edit, silently — same reason verify-interface/design-wall are targets rather +// than one-off edits. +// +// WHEN UPSTREAM FIXES THIS: the anchor stops matching and this reports `skip:no-anchor-matched` +// loudly, at which point uninstall the target. It never guesses. + +import fs from 'node:fs'; +import path from 'node:path'; +import { HOME_BASE } from '../cwd/paths.mjs'; + +const MARKETPLACE = 'ruvnet-brain'; +const SCRIPT = ['scripts', 'onboarding-console.mjs']; + +// Exact string, not a regex — this toolkit's literal find/replace discipline, so an anchor +// upstream has reworded is SKIPPED rather than guessed at. +const BUGGY = ` const child = spawn(process.execPath, [SELF, '--refresh-cache'], { detached: true, stdio: 'ignore', cwd: REPO });`; + +const FIXED = ` // ruflo-source-patch: the refresh child must inherit the SERVER's cwd, not the plugin's own + // install directory. \`REPO\` has no project .swarm/memory.db of its own, so a refresh spawned + // with cwd:REPO scores the wrong project's memory health — every subsequent request (this + // console never calls process.chdir(), so process.cwd() here is always the server's real cwd). + const child = spawn(process.execPath, [SELF, '--refresh-cache'], { detached: true, stdio: 'ignore', cwd: process.cwd() });`; + +const EDITS = [ + { id: 'kickrefresh-cwd', find: BUGGY, replace: FIXED, done: (s) => s.includes("cwd: process.cwd() });") && s.includes('the refresh child must inherit') }, +]; + +// Every installed copy this console script could be loaded from. Unlike verify-interface.sh / +// design-wall.sh (which live under `plugin/scripts/`, the bundle Claude Code's hook loader reads), +// `onboarding-console.mjs` is invoked directly by the slash commands (/brain-console et al.) off +// the repo-root `scripts/` — measured live: no `plugin/scripts/onboarding-console.mjs` and no cache +// copy exist on this machine. Check all three shapes anyway rather than assume one: an absent path +// is a legitimate miss (fs.existsSync guards each), never a failure, and a future ruvnet-brain +// layout change is exactly the kind of drift this should keep finding without a re-release here. +export function discover() { + const found = []; + + const mpRoot = path.join(HOME_BASE, '.claude', 'plugins', 'marketplaces', MARKETPLACE, ...SCRIPT); + if (fs.existsSync(mpRoot)) found.push(mpRoot); + const mpPlugin = path.join(HOME_BASE, '.claude', 'plugins', 'marketplaces', MARKETPLACE, 'plugin', ...SCRIPT); + if (fs.existsSync(mpPlugin) && !found.includes(mpPlugin)) found.push(mpPlugin); + + const cacheRoot = path.join(HOME_BASE, '.claude', 'plugins', 'cache', MARKETPLACE, MARKETPLACE); + try { + for (const version of fs.readdirSync(cacheRoot)) { + const f = path.join(cacheRoot, version, ...SCRIPT); + if (fs.existsSync(f)) found.push(f); + } + } catch { /* not installed via the cache path */ } + + return [...new Set(found)]; +} + +const isPatched = (src) => EDITS.every((e) => e.done(src)); + +// How many times does this anchor occur? An anchor no longer unique would apply to every +// occurrence, silently, in a place never inspected. Today it is unique — a measurement, checked +// at apply time, not a promise trusted forever. +const occurrences = (src, needle) => { + let n = 0; + let i = 0; + while ((i = src.indexOf(needle, i)) !== -1) { n++; i += needle.length; } + return n; +}; + +function patchSource(pristine) { + let next = pristine; + const applied = []; + const missing = []; + for (const e of EDITS) { + if (e.done(next)) continue; + const n = occurrences(next, e.find); + if (n === 1) { next = next.split(e.find).join(e.replace); applied.push(e.id); } + else { missing.push(n > 1 ? `${e.id}(AMBIGUOUS: anchor occurs ${n}x)` : e.id); } + } + return { next, applied, missing }; +} + +// The composable descriptor. Single edit, so atomicity is moot, but declared explicitly rather +// than left implicit — a partial match on a single-edit target is just "not applied", never +// "half applied". +export const descriptor = { + name: 'memory-health', + atomic: false, + editCount: EDITS.length, + discover, + patchSource, + isPatched, +}; diff --git a/lib/plugin-compose.mjs b/lib/plugin-compose.mjs index e287e43..a95d6ae 100644 --- a/lib/plugin-compose.mjs +++ b/lib/plugin-compose.mjs @@ -31,12 +31,13 @@ import { descriptor as adrIndex } from './adr-index/patcher.mjs'; import { descriptor as verifyInterface } from './verify-interface/patcher.mjs'; import { descriptor as mcpPrefix } from './mcp-prefix/patcher.mjs'; import { descriptor as designWall } from './design-wall/patcher.mjs'; +import { descriptor as memoryHealth } from './memory-health/patcher.mjs'; // Compose order: the surgical, specific-file targets first, then the broad substitution sweep. The -// targets edit DISJOINT text (a status bullet, an importer's args, tool-ref tokens, a bash gate), so the -// order is result-invariant in practice; fixing one keeps the composed output stable, which is what -// resolvePristine's recogniser depends on. -const ORDER = [adrTemplate, adrIndex, verifyInterface, mcpPrefix, designWall]; +// targets edit DISJOINT text (a status bullet, an importer's args, tool-ref tokens, a bash gate, a +// spawn option), so the order is result-invariant in practice; fixing one keeps the composed output +// stable, which is what resolvePristine's recogniser depends on. +const ORDER = [adrTemplate, adrIndex, verifyInterface, mcpPrefix, designWall, memoryHealth]; const BY_NAME = Object.fromEntries(ORDER.map((d) => [d.name, d])); export const COMPOSE_TARGETS = ORDER.map((d) => d.name); diff --git a/lib/plugin-registry.mjs b/lib/plugin-registry.mjs index 82d2a01..8e562fb 100644 --- a/lib/plugin-registry.mjs +++ b/lib/plugin-registry.mjs @@ -15,7 +15,7 @@ import { applyComposed, statusComposed, COMPOSE_TARGETS } from './plugin-compose.mjs'; import * as adrReindex from './adr-reindex/patcher.mjs'; -export const PLUGIN_TARGETS = ['adr-template', 'adr-index', 'adr-reindex', 'verify-interface', 'mcp-prefix', 'design-wall']; +export const PLUGIN_TARGETS = ['adr-template', 'adr-index', 'adr-reindex', 'verify-interface', 'mcp-prefix', 'design-wall', 'memory-health']; export const PLUGIN_INFO = { 'adr-template': "adr-create's template writes metadata adr-index can't parse (#2659)", @@ -34,6 +34,9 @@ export const PLUGIN_INFO = { // never checks which repo it is actually running in, so an unrelated repo's plain README.md // commit trips the same visual-design ritual meant for ruvnet-brain's own explainer/console pages. 'design-wall': "ruvnet-brain's design-grade commit gate fires on ANY repo's README, not just its own", + // A FOURTH ruvnet-brain surface: its Onboarding Console's background cache refresh spawns with the + // PLUGIN's own cwd instead of the server's, so the memory-health card scores the wrong project. + 'memory-health': "ruvnet-brain's Onboarding Console scores the PLUGIN's own dir, not your project's memory store", }; /** Re-apply the given plugin targets. Shape mirrors patch-library's apply(). */ diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 24f90f6..b76c70a 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -29,7 +29,7 @@ export RSP_NO_LAUNCHCTL=1 # the real kill against only its OWN fakes. Same discipline as RSP_NO_LAUNCHCTL above. export RSP_NO_STALE_WRITER_KILL=1 -SUITES=(sequence-fuzz plugin-notify reporting untested concurrency cleanup-procs stale-writer monitor-internals mcp-prefix design-wall) +SUITES=(sequence-fuzz plugin-notify reporting untested concurrency cleanup-procs stale-writer monitor-internals mcp-prefix design-wall memory-health) tmp=$(mktemp -d); pids=(); fail=0 for s in "${SUITES[@]}"; do diff --git a/test/fixtures.mjs b/test/fixtures.mjs index 239cace..99705b6 100644 --- a/test/fixtures.mjs +++ b/test/fixtures.mjs @@ -94,6 +94,7 @@ const looksPatched = { adrTemplate: (buf) => composedIsOurs(buf), verifyInterface: (buf) => buf.includes('(^|[[:space:]]|[;&|(])($TOOLS)'), designWall: (buf) => buf.includes('ORIGIN=$(git -C') && buf.includes('*"ruvnet-brain"*'), + memoryHealth: (buf) => buf.includes('the refresh child must inherit') && buf.includes('cwd: process.cwd() });'), }; /** @@ -105,7 +106,8 @@ const looksPatched = { * 3. a PATCHED file with no backup is NOT a baseline, and we refuse it. Using it would make * the suite green while testing the patch against itself. * - * `kind` picks the recogniser: 'marker' (CLI targets), 'adrIndex', or 'adrTemplate'. + * `kind` picks the recogniser: 'marker' (CLI targets), 'adrIndex', 'adrTemplate', 'verifyInterface', + * 'designWall', or 'memoryHealth'. */ export function pristineBytes(file, kind = 'marker') { const backup = `${file}.rsp-backup`; diff --git a/test/memory-health.mjs b/test/memory-health.mjs new file mode 100644 index 0000000..2f54dfd --- /dev/null +++ b/test/memory-health.mjs @@ -0,0 +1,96 @@ +// Behavioural tests for `memory-health` (ruvnet-brain's Onboarding Console scoring the WRONG +// project's memory store — see patcher.mjs / ADR-025). Drives the REAL vendor file (its +// `.rsp-backup` if the patch is installed on this machine, else the file itself), and SKIPS rather +// than fabricates a fixture if ruvnet-brain isn't installed here at all — same discipline as +// design-wall.mjs / verify-interface's reporting.mjs block. +// +// WHY NOT boot the real console and observe kickRefresh() at runtime, the way design-wall.mjs spawns +// its (small, dependency-free) bash gate and watches it block/allow: onboarding-console.mjs pulls in +// nine sibling modules (stack-sync.mjs, memory-doctor.mjs, console-engine.mjs, ...) and its +// `--refresh-cache` path can reach the npm registry via `gatherStack()`'s audit — slow, and +// network-dependent in a suite meant to run offline and fast. The fix itself is a single literal +// substitution, not new runtime logic, so the risk that actually matters is: does the anchor match +// production bytes UNIQUELY, does it touch ONLY the one intended spawn call and leave the file's +// other eight `REPO` references (CONSOLE_DIR, the SBOM path, gatherMemory's fallback, the script +// runner's own cwd:REPO, gatesSurvey) untouched, and does install/uninstall round-trip cleanly. All +// of that is provable at the byte level against the real file, with no subprocess needed. + +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; + +const SANDBOX = process.argv[2] || fs.mkdtempSync(path.join(os.tmpdir(), 'mhealth-')); +process.env.RUFLO_SOURCE_PATCH_HOME = SANDBOX; // <- BEFORE any lib/ (or fixtures.mjs) import +process.env.RSP_NO_SELF_UPDATE = '1'; + +// Dynamic import, after the env var — a static top-of-file import would freeze paths.mjs's +// HOME_BASE to the REAL machine's home before the sandbox env var ever took effect (measured live +// in design-wall.mjs/mcp-prefix.mjs: a static import there silently patched the developer's actual +// ~/.claude/plugins/... instead of the sandbox). +const { pristineBytes } = await import('./fixtures.mjs'); + +let fail = 0; +const check = (desc, cond) => { console.log(`${cond ? '✓' : '✘'} ${desc}`); if (!cond) fail = 1; }; + +const REAL_CONSOLE = path.join(os.homedir(), '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'scripts', 'onboarding-console.mjs'); +if (!fs.existsSync(REAL_CONSOLE) && !fs.existsSync(`${REAL_CONSOLE}.rsp-backup`)) { + console.log('· memory-health (SKIPPED — the ruvnet-brain plugin is not installed)'); + process.exit(0); +} + +const consoleDir = path.join(SANDBOX, '.claude', 'plugins', 'marketplaces', 'ruvnet-brain', 'scripts'); +const consoleScript = path.join(consoleDir, 'onboarding-console.mjs'); +fs.mkdirSync(consoleDir, { recursive: true }); +const pristine = pristineBytes(REAL_CONSOLE, 'memoryHealth').toString('utf8'); +fs.writeFileSync(consoleScript, pristine); + +const BUGGY_LINE = `const child = spawn(process.execPath, [SELF, '--refresh-cache'], { detached: true, stdio: 'ignore', cwd: REPO });`; +const FIXED_MARKER = `cwd: process.cwd() });`; + +// MH1 — BEFORE the patch, the exact buggy spawn line is present, and present exactly ONCE. If this +// fails, either the fixture is not the buggy version (rest of the suite would be vacuous) or +// upstream has restructured the file and the anchor is no longer unique — both must be caught here, +// not discovered by a patch silently applying to the wrong place. +const occurrences = (src, needle) => { let n = 0, i = 0; while ((i = src.indexOf(needle, i)) !== -1) { n++; i += needle.length; } return n; }; +check('MH1 unpatched vendor file contains the buggy kickRefresh spawn line exactly once (fixture proven buggy)', occurrences(pristine, BUGGY_LINE) === 1); + +// The other REPO usages in this same file MUST survive the patch untouched — this file uses REPO +// eight times (CONSOLE_DIR, gatherMemory's fallback twice, the SBOM path + its path.relative, +// gatesSurvey, the script-runner's own legitimate cwd:REPO). A patch that is not surgical — e.g. an +// anchor accidentally matching more broadly — would silently break the console's other features. +const OTHER_REPO_USES = [ + `const CONSOLE_DIR = path.join(REPO, 'console');`, + `const project = fs.existsSync(path.join(cwd, '.swarm/memory.db')) ? cwd : REPO;`, + `const SBOM_PATH = path.join(REPO, 'sbom', 'ruvnet-brain.cdx.json');`, + `const rel = path.relative(REPO, SBOM_PATH);`, + `try { gates = gatesSurvey({ repo: REPO }); } catch { gates = null; }`, +]; +check('MH2 the file\'s other REPO usages are present before patching (baseline for the surgical-edit check below)', + OTHER_REPO_USES.every((s) => pristine.includes(s))); + +// Apply the patch. +const { applyComposed, reconcile } = await import('../lib/plugin-compose.mjs'); +const a1 = applyComposed(['memory-health']); +check('MH3 apply patched the onboarding-console.mjs copy', a1.patched >= 1 && a1.incomplete === 0 && a1.errors === 0); + +const patched = fs.readFileSync(consoleScript, 'utf8'); + +// MH4 — AFTER the patch, the spawn call reads the live process.cwd(), not the hardcoded REPO. +check('MH4 patched file\'s kickRefresh spawn call now uses cwd: process.cwd()', patched.includes(FIXED_MARKER)); +check('MH5 the buggy exact line is gone', !patched.includes(BUGGY_LINE)); + +// MH6 — surgical edit: every OTHER REPO usage in the file is untouched, byte-for-byte. +check('MH6 the file\'s other REPO usages are UNCHANGED after patching (edit was surgical, not a blanket REPO->cwd swap)', + OTHER_REPO_USES.every((s) => patched.includes(s))); + +// MH7 — idempotent: re-applying against an already-patched file changes nothing. +const a2 = applyComposed(['memory-health']); +check('MH7 re-apply against an already-patched file is a no-op', a2.patched === 0 && a2.unchanged >= 1); + +// MH8 — uninstall restores byte-identical vendor, no .rsp-backup left. +reconcile([], ['memory-health']); +check('MH8 uninstall restores byte-identical vendor, no .rsp-backup left', + fs.readFileSync(consoleScript, 'utf8') === pristine && !fs.existsSync(`${consoleScript}.rsp-backup`)); + +if (fail) { console.log('\n✘ test/memory-health.mjs FAILED'); process.exit(1); } +console.log('\n✓ memory-health: all checks passed');