Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions MAINTAINER.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`,
Expand Down
2 changes: 1 addition & 1 deletion src/commands/setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions src/commands/status.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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}`));
Expand Down
11 changes: 7 additions & 4 deletions src/lib/heal.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
47 changes: 35 additions & 12 deletions src/lib/ruvnet-brain.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
};
}
29 changes: 28 additions & 1 deletion tests/kit/ruvnet-brain.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
Loading