From 9ae8922d5aacfdf771ecf86efc7536b38dede12e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 15 Jul 2026 23:10:45 -0700 Subject: [PATCH] fix(ruvnet-brain): drift in the release-tag namespace so it converges MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ruvnet-brain status row reported a version that could never converge: it compared the plugin semver (plugin.json, e.g. 0.5.0-dev) against the GitHub *release* tag (e.g. 3.0.1). Those are unrelated version tracks — ruvnet-brain has THREE (plugin semver, KB bundle brainVersion, and the release tags the installer downloads by), and none is stamped on disk in the release namespace. So the row nagged "3.0.1 available" forever and no reinstall could clear it (the plugin's own version stays 0.5.0-dev). Fix: compute drift against ak's OWN record of the release it last pulled (kit.json versionCheck.ruvnetBrain.installedRelease, written by recordInstalledRelease() after a successful install) vs GitHub releases/latest — same namespace, so it converges. A present-but-unstamped install (manual / pre-existing) surfaces as outdated once so `ak sync` pulls it onto the managed track, then converges. - ruvnet-brain.mjs: classifyDrift() (pure), recordInstalledRelease(), drift() reworked to the release-tag namespace + cache preserves the stamp. - heal.installRuvnetBrain(): stamp the pulled release; drop the misleading plugin-semver diff from its detail. - status/setup: release-tag phrasing; setup no longer prints the plugin semver. - tests: classifyDrift cases (converged / older / unversioned / offline). - MAINTAINER: document the three-namespace gotcha. --- MAINTAINER.md | 15 +++++++++-- src/commands/setup.mjs | 2 +- src/commands/status.mjs | 6 +++-- src/lib/heal.mjs | 11 +++++--- src/lib/ruvnet-brain.mjs | 47 ++++++++++++++++++++++++--------- tests/kit/ruvnet-brain.test.mjs | 29 +++++++++++++++++++- 6 files changed, 88 insertions(+), 22 deletions(-) diff --git a/MAINTAINER.md b/MAINTAINER.md index 276ab845..e4ddd1b6 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -72,13 +72,24 @@ packages: detected via `installedVersion` (npm global root) and drift-checked wi `npm view`. The RuvNet Brain is *not* — `npx github:stuinfla/ruvnet-brain` installs a ~512 MB offline KB to `~/.cache/ruvnet-brain/kb` (override `$RUVNET_BRAIN_KB`) and a user-scope Claude Code plugin (the `search_ruvnet` MCP + hooks + a skill). So it gets a -*parallel* lifecycle in `src/lib/ruvnet-brain.mjs`: `present()`/`installedVersion()` probe -disk, `latestVersion()`/`drift()` hit the GitHub releases API (TTL-cached in kit.json like +*parallel* lifecycle in `src/lib/ruvnet-brain.mjs`: `present()` probes disk, +`latestVersion()`/`drift()` hit the GitHub releases API (TTL-cached in kit.json like `selfDrift`). setup/sync install via `heal.installRuvnetBrain()` with `--no-stack --no-enhance` (ak already manages ruflo/RuVector and owns the `ruvnet-brain-reference` CLAUDE.md block). Toggle with the `ruvnetBrain` kit.json flag / `--no-ruvnet-brain`. Note: the installer's `--enable-nightly` does **not** exist (nightly is a separate, unmanaged LaunchAgent). +> **Version gotcha — three unrelated namespaces.** The plugin semver (`plugin.json`, e.g. +> `0.5.0-dev`), the KB bundle's `brainVersion` (e.g. `v0.3.0-dev`), and the GitHub **release +> tags** the installer downloads by (e.g. `v3.0.1`) are all different tracks, and *none* is +> stamped on disk in the release namespace. So drift is computed against **ak's own record** +> of the release it last pulled (`kit.json` → `versionCheck.ruvnetBrain.installedRelease`, +> written by `recordInstalledRelease()` after a successful install). `classifyDrift()` compares +> that stamp vs `releases/latest` — same namespace, so it converges. A present-but-unstamped +> install (manual / pre-existing) surfaces as outdated once, so `ak sync` pulls it onto the +> managed track. Do **not** compare `installedVersion()` (plugin semver) against a release tag — +> that was the original bug and it can never converge. + ### Dogfooding artifacts are NOT source Running `ruflo init` / `aqe init` against *this* repo writes `.agentic-qe/`, diff --git a/src/commands/setup.mjs b/src/commands/setup.mjs index 847664ce..0aa6beda 100644 --- a/src/commands/setup.mjs +++ b/src/commands/setup.mjs @@ -88,7 +88,7 @@ export async function run_machine({ flags, pkgRoot, cfg }) { const r = await heal.installRuvnetBrain(); (r.ok ? ok : warn)(`ruvnet-brain: ${r.detail}`); } else warn('ruvnet-brain skipped — install later with `ak sync` (or `ak setup --no-ruvnet-brain` to stop asking)'); - } else ok(`ruvnet-brain ${rb.installedVersion() ?? ''} present`); + } else ok('ruvnet-brain present (refresh to the latest release with `ak sync`)'); } // 2. heal natives + the #2670 aidefence gap up front diff --git a/src/commands/status.mjs b/src/commands/status.mjs index f6b18991..2aae4fd8 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -71,10 +71,12 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { if (!b.present) { rows.push(row('ruvnet-brain', 'warn', 'RuvNet Brain not installed', 'setup installs it (or `ak sync`)')); } else if (b.outdated) { + const have = b.installedRelease ? `release v${b.installedRelease}` : 'present (unversioned install)'; rows.push(row('ruvnet-brain', 'warn', - `ruvnet-brain ${b.installed} installed, ${b.latest} available`, 'sync re-runs the installer')); + `ruvnet-brain ${have}, release v${b.latest} available`, 'sync refreshes the KB')); } else { - rows.push(row('ruvnet-brain', 'ok', `ruvnet-brain ${b.installed ?? 'present'}${b.latest ? ' (latest)' : ''}`)); + const shown = b.installedRelease ? `release v${b.installedRelease}${b.latest ? ' (latest)' : ''}` : 'present'; + rows.push(row('ruvnet-brain', 'ok', `ruvnet-brain ${shown}`)); } } catch (e) { rows.push(row('ruvnet-brain', 'warn', `ruvnet-brain check unavailable: ${e.message}`)); diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index a8158ad7..ddff5a0a 100644 --- a/src/lib/heal.mjs +++ b/src/lib/heal.mjs @@ -10,7 +10,7 @@ import { rufloRoot, aqeRoot } from './paths.mjs'; import { agentdbLocations, bsq3IsNative, bsq3Root, aidefencePresent } from './natives.mjs'; import { KIT_PKG } from './versions.mjs'; import { scanRvf, quarantine } from './rvf.mjs'; -import { INSTALL_SPEC, INSTALL_ARGS, present as rbPresent, installedVersion as rbVersion } from './ruvnet-brain.mjs'; +import { INSTALL_SPEC, INSTALL_ARGS, present as rbPresent, latestVersion as rbLatest, recordInstalledRelease as rbRecord } from './ruvnet-brain.mjs'; // Packages whose install scripts must run for natives to build (npm >=11.17 // blocks them by default). Curated on the live 3.28/3.12.2 upgrade. @@ -124,12 +124,15 @@ export async function selfUpdate(version) { * check saw a newer release). Runs `--no-stack --no-enhance`: ak already * manages ruflo/RuVector and owns the CLAUDE.md grounding block. */ export async function installRuvnetBrain({ force = false } = {}) { - const was = rbVersion(); const args = ['-y', INSTALL_SPEC, ...INSTALL_ARGS, ...(force ? ['--force'] : [])]; const r = await run('npx', args, { timeout: 900_000 }); if (r.code === 0) { - const now = rbVersion(); - return { ok: true, detail: now ? (was && was !== now ? `updated ${was} → ${now}` : `installed ${now}`) : 'installed' }; + // A default install pulls GitHub releases/latest, so the release now on disk + // == latest at this moment. Stamp it (release-tag namespace) so drift converges + // — the plugin's own semver never tracks the KB release, so we can't use it. + const tag = await rbLatest(); + if (tag) rbRecord(tag); + return { ok: true, detail: tag ? `installed release v${tag}` : 'installed (release tag unknown)' }; } // A non-zero exit can still leave a usable install (post-verify smoke test may // fail offline); report the tail but reflect actual presence. diff --git a/src/lib/ruvnet-brain.mjs b/src/lib/ruvnet-brain.mjs index 7d602170..76b89595 100644 --- a/src/lib/ruvnet-brain.mjs +++ b/src/lib/ruvnet-brain.mjs @@ -77,26 +77,49 @@ export async function latestVersion({ timeout = 8000 } = {}) { } } -/** Presence + drift, TTL-cached in kit.json (mirrors selfDrift in versions.mjs) - * so status/nudge hit GitHub at most once per window. force=true bypasses the - * cache. `latest` is null when the release check is unavailable — treat that as - * "unknown", never "outdated". */ +/** Record the release tag ak just pulled, so future drift compares like-for-like. + * ruvnet-brain has THREE unrelated version tracks — the plugin semver + * (plugin.json, e.g. 0.5.0-dev), the KB bundle's brainVersion (e.g. v0.3.0-dev), + * and the GitHub *release* tags the installer downloads by (e.g. v3.0.1). None of + * them is stamped on disk in the release namespace, so ak keeps its own record of + * "which release did I last install" in kit.json. */ +export function recordInstalledRelease(tag, cfg = loadKitConfig()) { + if (!tag) return; + const cur = cfg.versionCheck?.ruvnetBrain ?? {}; + cfg.versionCheck = { ...cfg.versionCheck, ruvnetBrain: { ...cur, installedRelease: String(tag).replace(/^v/, '') } }; + try { saveKitConfig(cfg); } catch { /* read-only envs: best-effort */ } +} + +/** Pure drift classifier — both sides in the RELEASE-TAG namespace. + * installedRelease = the release ak last pulled (null = ak never installed it, + * e.g. a manual/pre-existing install); latest = GitHub releases/latest. + * A present-but-unstamped install counts as outdated when a latest is known, so + * `ak sync` refreshes it onto ak's managed track once, then converges. `latest` + * null (offline / rate-limited) is always "unknown", never "outdated". */ +export function classifyDrift({ present: isPresent, installedRelease, latest }) { + if (!isPresent) return { present: false, outdated: false, unversioned: false, installedRelease: null, latest: latest ?? null }; + const unversioned = !installedRelease; + const outdated = !!(latest && (unversioned || cmpVersions(latest, installedRelease) > 0)); + return { present: true, outdated, unversioned, installedRelease: installedRelease ?? null, latest: latest ?? null }; +} + +/** Presence + release drift, TTL-cached in kit.json (mirrors selfDrift in + * versions.mjs) so status/nudge hit GitHub at most once per window. force=true + * bypasses the cache. */ export async function drift({ force = false } = {}) { - const installed = installedVersion(); const cfg = loadKitConfig(); const ttlMs = (cfg.versionCheck?.ttlHours ?? 24) * 3600_000; - const cached = cfg.versionCheck?.ruvnetBrain; - const fresh = !force && cached?.last && Date.now() - cached.last < ttlMs; + const cached = cfg.versionCheck?.ruvnetBrain ?? {}; + const fresh = !force && cached.last && Date.now() - cached.last < ttlMs; let latest = fresh ? cached.latest ?? null : null; if (!fresh) { latest = await latestVersion(); - cfg.versionCheck = { ...cfg.versionCheck, ruvnetBrain: { last: Date.now(), latest } }; + // Preserve installedRelease across the cache write. + cfg.versionCheck = { ...cfg.versionCheck, ruvnetBrain: { ...cached, last: Date.now(), latest } }; try { saveKitConfig(cfg); } catch { /* read-only envs: next call re-fetches */ } } return { - present: present(), - installed, - latest, - outdated: !!(installed && latest && cmpVersions(latest, installed) > 0), + ...classifyDrift({ present: present(), installedRelease: cached.installedRelease ?? null, latest }), + pluginVersion: installedVersion(), }; } diff --git a/tests/kit/ruvnet-brain.test.mjs b/tests/kit/ruvnet-brain.test.mjs index b16d024d..43fafcd1 100644 --- a/tests/kit/ruvnet-brain.test.mjs +++ b/tests/kit/ruvnet-brain.test.mjs @@ -4,7 +4,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { - kbDir, present, installedVersion, INSTALL_SPEC, INSTALL_ARGS, REPO, + kbDir, present, installedVersion, classifyDrift, + INSTALL_SPEC, INSTALL_ARGS, REPO, } from '../../src/lib/ruvnet-brain.mjs'; import { BUILTIN_BLOCKS, detect } from '../../src/lib/blocks.mjs'; import { loadKitConfig, saveKitConfig } from '../../src/lib/config.mjs'; @@ -52,6 +53,32 @@ test('installedVersion() returns null or a version string (never throws)', () => assert.ok(v === null || typeof v === 'string'); }); +test('classifyDrift compares in the RELEASE-TAG namespace and converges', () => { + // absent → never outdated + assert.deepEqual( + classifyDrift({ present: false, installedRelease: null, latest: '3.0.1' }), + { present: false, outdated: false, unversioned: false, installedRelease: null, latest: '3.0.1' }); + + // present + ak-stamped older release + newer latest → outdated + const older = classifyDrift({ present: true, installedRelease: '3.0.0', latest: '3.0.1' }); + assert.equal(older.outdated, true); + assert.equal(older.unversioned, false); + + // present + stamp == latest → converged (this is what a refresh achieves; the + // OLD bug compared plugin semver 0.5.0-dev vs 3.0.1 and could never reach this) + assert.equal(classifyDrift({ present: true, installedRelease: '3.0.1', latest: '3.0.1' }).outdated, false); + + // present but ak never stamped a release (manual/pre-existing) + latest known → + // surfaced as outdated ONCE so `ak sync` pulls it onto the managed track + const unstamped = classifyDrift({ present: true, installedRelease: null, latest: '3.0.1' }); + assert.equal(unstamped.outdated, true); + assert.equal(unstamped.unversioned, true); + + // latest unknown (offline / rate-limited) → never falsely outdated + assert.equal(classifyDrift({ present: true, installedRelease: null, latest: null }).outdated, false); + assert.equal(classifyDrift({ present: true, installedRelease: '3.0.0', latest: null }).outdated, false); +}); + test('BUILTIN_BLOCKS carries the ruvnet-brain-reference row gated on the KB dir', async () => { const row = BUILTIN_BLOCKS.find((b) => b.slug === 'ruvnet-brain-reference'); assert.ok(row, 'row must be registered');