diff --git a/MAINTAINER.md b/MAINTAINER.md index 924f5e4..a5fd460 100644 --- a/MAINTAINER.md +++ b/MAINTAINER.md @@ -67,28 +67,57 @@ docs/ `bin/agentic-kit.mjs`, `src/`, `claude/`, `docs/TROUBLESHOOTING.md`. Nothing else ships — verify with `npm pack --dry-run` before a release if you touch `files`. +> The consistency contract all managed tools share — install/update/version/display +> invariants, the per-tool table, and the add-a-tool checklist — lives in +> [docs/MANAGED-TOOLS.md](docs/MANAGED-TOOLS.md). The notes below are the +> brain-specific details behind that contract. + **RuvNet Brain is the odd one out.** ruflo/agentic-qe/the host CLIs are global npm packages: detected via `installedVersion` (npm global root) and drift-checked with -`npm view`. The RuvNet Brain is *not* — `npx github:stuinfla/ruvnet-brain` installs a +`npm view`. The RuvNet Brain is *not* — `npx ruvnet-brain@latest` (the **published** +installer; never `github:`, which runs the unreleased default-branch HEAD) 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()` 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). +`selfDrift`). setup/sync install via `heal.installRuvnetBrain()`, which resolves the +latest release tag FIRST and pins the installer to it (`--version v`), so the +bundle on disk is exactly the release ak stamps — no install-then-stamp race. +Toggle with the `ruvnetBrain` kit.json flag / `--no-ruvnet-brain`. + +> **Installer flag gotcha — `--yes` accepts *every* optional offer.** Audited live on the +> v3.3.1 installer (2026-07-17): under `--yes` it silently enables a nightly self-update +> LaunchAgent (`com.ruvnet.brain-update`, macOS, 03:47 — runs the bundle's +> `forge-update.mjs --apply`, which at v3.3.1 applies downloads **without signature +> verification**), writes telemetry consent, installs a spend-watchdog agent, and +> materializes model-router files. Hence `INSTALL_ARGS` carries FOUR suppression flags: +> `--no-stack --no-enhance` (ak manages ruflo/RuVector + the CLAUDE.md block) and +> `--no-nightly-prompt --no-telemetry` (ak owns updates; consent stays the user's). +> The nightly self-updater bypasses ak-managed updates, so `ak status` flags an existing +> agent as its own subsystem (`ruvnet-brain-nightly`) and `ak sync` disables it +> (`heal.disableRuvnetBrainNightly()`: `launchctl bootout` + plist removal — both steps, +> mirroring the installer's own `--disable-nightly`). Deliberate re-enrollment +> (`npx ruvnet-brain --enable-nightly`) gets re-flagged; opt ak out entirely with +> `ruvnetBrain:false`. The spend watchdog and telemetry consent of already-affected +> machines are left alone on purpose — local-only / a recorded user answer. > **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** +> tags** the installer downloads by (e.g. `v3.3.1`) are all different tracks. Evergreen-era +> release bundles stamp the release tag **on disk** (`SOURCE.json` → `releaseTag`), so the +> installed side resolves disk-first: `installedReleaseOnDisk()`, then **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. +> written by `recordInstalledRelease()` after a successful install) for pre-stamping +> bundles. The statusline footer mirrors the same order, and the dashboard's update +> banner folds the brain in from the same `drift()` result (`foldBrainDrift()` in +> dashboard-server.mjs — driftReport only carries npm tools; the kit's own +> `selfDrift` is folded the same way), so `ak status`, the footer, and the +> dashboard can never disagree. +> `classifyDrift()` compares that resolved value 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 diff --git a/README.md b/README.md index f6aa3b0..69b30f4 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,13 @@ applies (reversibly); `ak x provider off` restores the claude-only default. Full [docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md) — symptom → `agentic-kit` command. +## How tools are managed + +[docs/MANAGED-TOOLS.md](docs/MANAGED-TOOLS.md) — the consistency contract every +managed tool follows (release-pinned installs, sync as the single updater, +disk-first version truth, one drift story across status/statusline/dashboard), +with the per-tool table and the checklist for adding a new tool. + ## Credits ruflo/claude-flow by ruvnet · agentic-qe by proffesor-for-testing · prior art: diff --git a/claude/ruvnet-brain-reference.md b/claude/ruvnet-brain-reference.md index b6305c3..20f2578 100644 --- a/claude/ruvnet-brain-reference.md +++ b/claude/ruvnet-brain-reference.md @@ -1,9 +1,11 @@ ## RuvNet Brain — ground before you assert @@ -28,7 +30,7 @@ pull the latest release. `ak status` reports presence + drift (checked against GitHub releases, TTL-cached). Opt out with `ak setup --no-ruvnet-brain` (or set `ruvnetBrain:false` in `~/.config/agentic-kit/kit.json`). -- **Health check:** `npx github:stuinfla/ruvnet-brain --doctor` (verifies the KB, reader, and +- **Health check:** `npx ruvnet-brain --doctor` (verifies the KB, reader, and runs a smoke query). Not installed? that command also installs it. - It is an npx-driven Claude Code **plugin** (MCP server + hooks + a skill) at user scope, not a global npm package — so it is detected on disk, not via `npm ls`. diff --git a/docs/MANAGED-TOOLS.md b/docs/MANAGED-TOOLS.md new file mode 100644 index 0000000..6b0115b --- /dev/null +++ b/docs/MANAGED-TOOLS.md @@ -0,0 +1,100 @@ +# Managed tools — the consistency contract + +Every tool ak manages follows one contract for how it is installed, updated, +version-detected, and displayed. This doc states the contract's four +invariants, maps every managed tool onto them, and gives the checklist for +adding a new tool without breaking them. + +The contract exists because the failure modes it prevents were all observed +live: a version stamp disagreeing with what was actually on disk, a statusline +showing a different version than `ak status`, a third-party self-updater +rewriting managed files behind ak's back, and an "update available" banner +that stayed silent for tools it didn't know about. + +## The four invariants + +1. **Disk-first installed versions.** The "installed" side of every drift + check is read from what is actually on disk — never from a cached claim or + a side-record that can go stale. If a tool can change outside ak (manual + npm install, a hand-run updater), the reads still tell the truth. + +2. **Single update owner, with honest disowning.** `ak sync` is the only + updater for everything ak claims to manage. Where ak does *not* own the + artifact, it says so instead of pretending: externally-installed hosts are + filtered out of drift entirely, and a tool that ships its own self-updater + (the RuvNet Brain's nightly LaunchAgent) is detected as drift and disabled + by sync. One owner means the release stamp, the drift check, and the thing + on disk can converge. + +3. **Same-namespace comparisons.** Installed and latest are always compared + in the same version namespace: npm semver vs npm semver, GitHub release + tag vs release tag. A comparison across namespaces (the original + ruvnet-brain bug: plugin semver `0.5.0-dev` vs release tag `3.0.1`) can + never converge. "Latest" is not always npm-latest either — agentdb's + authority is ruflo's *bundled* version, because a latest-chasing agentdb + is the store-corruption risk its coherence guard exists to prevent. + +4. **One drift story across all surfaces.** `ak status` rows, the statusline + footer chips, and the dashboard (subsystem cards *and* the update banner) + derive from the same reads, so they cannot disagree. The dashboard banner + is the easy one to miss: `driftReport()` only knows npm tools, so + non-npm-managed tools (the brain, the kit itself) are folded into the same + `{pkg, installed, latest, outdated}` array explicitly + (`foldBrainDrift()` / the `selfDrift` fold in + `src/lib/dashboard-server.mjs`). + +## The tools + +| Tool | Install / update spec | Update owner | Installed version read from | Drift compared against | status / statusline / dashboard | +| --- | --- | --- | --- | --- | --- | +| **ruflo** | npm `ruflo@latest` | `ak sync` | disk: global `package.json` | npm `view latest` (TTL-cached) | row ✓ / upstream's own `RuFlo V` header ✓ / card + banner ✓ | +| **agentic-qe** | npm `agentic-qe@latest` | `ak sync` | disk: global `package.json` (project-local fallback) | npm `view latest` (TTL-cached) | row ✓ / `Agentic QE V` chip ✓ / card + banner ✓ | +| **hosts** (claude, codex) | npm `@latest` — only when npm-managed | `ak sync` if npm-installed; **explicitly disowned** if brew/mise/native | disk: global `package.json`, else `--version` probe | npm latest for npm-managed only; external → `outdated:false` | row ✓ (version + method) / n/a / card + banner (npm-managed only) ✓ | +| **agentdb** | npm, **pinned to ruflo's bundled version** — deliberately not latest | `ak sync` (repins on core skew) | disk: global `package.json` | ruflo's **bundled** copy (coherence), not npm latest — by design | row ✓ / n/a / card ✓; banner excluded (its authority isn't "latest") | +| **ruvnet-brain** | npm `ruvnet-brain@latest` + `--version v` pin (never `github:` HEAD) | `ak sync`; the installer's own nightly self-updater is suppressed at install (`--no-nightly-prompt`) and disabled by sync if found (`ruvnet-brain-nightly` subsystem) | disk: KB `SOURCE.json → releaseTag`, falling back to ak's kit.json stamp for pre-stamping bundles | GitHub `releases/latest` tag (TTL-cached) | row ✓ / `V` chip ✓ / card + banner ✓ | +| **kit (self)** | npm, **pinned to the exact version drift saw** (`@pacphi/agentic-kit@`) | `ak sync` (runs last — npm replaces the running code) | disk: running copy's `package.json` | npm `latest` (+ `next` for prereleases, TTL-cached) | row ✓ / n/a / header version + card + banner ✓ | + +Statusline "n/a" cells are by design: the footer decorates the activation rows +it renders (ruflo / Agentic QE / brain) — hosts, agentdb, and the kit have no +footer row to decorate, and their versions live in `ak status` and the +dashboard. + +## Where each piece lives + +- **npm tools** — `src/lib/versions.mjs` (`installedVersion`, `driftReport`, + `selfDrift`), heals in `src/lib/heal.mjs` (`upgradePackage`, `selfUpdate`). +- **hosts** — `src/lib/providers.mjs` (`hostInstallState`, `installHost`, + `updateHost`, `hostDrift`). +- **agentdb** — `src/lib/agentdb.mjs` (`coherence`), heal + `healAgentdb` (pins to the bundled version). +- **ruvnet-brain** — `src/lib/ruvnet-brain.mjs` (`installedReleaseOnDisk`, + `latestVersion`, `classifyDrift`, `drift`, nightly-agent detection), heals + `installRuvnetBrain` / `disableRuvnetBrainNightly`. Full background on its + three version namespaces and the installer's `--yes` gotcha: MAINTAINER.md. +- **display surfaces** — `src/commands/status.mjs` (rows), + `src/templates/statusline-footer.cjs` (chips), + `src/lib/dashboard-server.mjs` (cards from the same rows; banner = + `driftReport` + `selfDrift` fold + `foldBrainDrift`). + +## Adding a new tool: the checklist + +1. **Install a published artifact** (npm release, tagged release asset) — + never a moving branch head. If the installer takes a version, resolve the + target version *first*, install it pinned, and only then record it. +2. **Decide the version authority** and keep both sides of the drift check in + that one namespace. Document the authority if it isn't npm-latest. +3. **Read installed state from disk** in a way that survives out-of-band + changes. If the tool stamps its own version on disk, prefer that stamp; + keep any ak-side record as a fallback only. +4. **Make sync the only updater.** If the tool ships auto-update machinery, + suppress it at install time and detect + disable it as drift (its own + subsystem, so sync's fix is proportionate — never a forced reinstall). + If ak can't own updates (external install), report installed-only and + `outdated: false`. +5. **Wire all the surfaces**: a `status` row (with the fix named), the + statusline only if the tool has a footer row, a dashboard card (free — + cards render status rows), and the dashboard banner (fold into the drift + array if the tool isn't in `driftReport`). +6. **Lock it with tests**: the install spec + suppression flags (regression + lock), the disk-read parser's edge cases (missing / malformed / junk), + and the display resolution order. diff --git a/docs/TROUBLESHOOTING.md b/docs/TROUBLESHOOTING.md index 8cc14b3..3669a5e 100644 --- a/docs/TROUBLESHOOTING.md +++ b/docs/TROUBLESHOOTING.md @@ -25,7 +25,7 @@ fix; `sync` applies them in the right order and re-checks afterward. | Want to change which MCP tool families are callable | Exclusions are `permissions.deny` rules, persisted in kit.json | `ak x mcp pick` (re-runnable); `x mcp status` shows the inventory; `x mcp off` unregisters | | `ruflo memory store` says OK but reads return nothing | Absolute-DB-path pin missing, or WAL not checkpointed, or the WASM fallback above | `ak setup` in the project re-pins + verifies a real write lands on disk | | Suspicious token burn | Background automation vs interactive usage | ask Claude to run the **ruflo-token-audit** skill (deployed by `setup`) | -| `status` shows `ruvnet-brain … not installed` | The RuvNet Brain (offline KB + `search_ruvnet` MCP) isn't on disk | `ak sync` (or `ak setup`) runs the installer; `npx github:stuinfla/ruvnet-brain --doctor` health-checks it | +| `status` shows `ruvnet-brain … not installed` | The RuvNet Brain (offline KB + `search_ruvnet` MCP) isn't on disk | `ak sync` (or `ak setup`) runs the installer; `npx ruvnet-brain --doctor` health-checks it | | Don't want the RuvNet Brain (the ~512 MB KB download) | It's on by default | `ak setup --no-ruvnet-brain`, or set `ruvnetBrain: false` in `~/.config/agentic-kit/kit.json` | | RuvNet Brain KB lives somewhere non-default | The installer + ak honor `$RUVNET_BRAIN_KB` (default `~/.cache/ruvnet-brain/kb`) | export `RUVNET_BRAIN_KB` so detection points at your KB | diff --git a/src/commands/status.mjs b/src/commands/status.mjs index e136ef1..5c0476b 100644 --- a/src/commands/status.mjs +++ b/src/commands/status.mjs @@ -15,7 +15,7 @@ import { registry, syncBlocks } from '../lib/blocks.mjs'; import { loadKitConfig } from '../lib/config.mjs'; import { driftReport, selfDrift } from '../lib/versions.mjs'; import { upstreamCveCounterFabricated, fixStatusline } from '../lib/statusline.mjs'; -import { drift as ruvnetBrainDrift } from '../lib/ruvnet-brain.mjs'; +import { drift as ruvnetBrainDrift, nightlyAgentPresent as rbNightlyPresent, NIGHTLY_LABEL as RB_NIGHTLY_LABEL } from '../lib/ruvnet-brain.mjs'; import { coherence as adbCoherence } from '../lib/agentdb.mjs'; import { readJson } from '../lib/settings.mjs'; import { have } from '../lib/exec.mjs'; @@ -85,6 +85,15 @@ export async function collect({ pkgRoot, cwd = process.cwd() }) { } catch (e) { rows.push(row('ruvnet-brain', 'warn', `ruvnet-brain check unavailable: ${e.message}`)); } + // The installer's own nightly self-updater (macOS LaunchAgent, 03:47) bypasses + // ak-managed updates: it rewrites the KB outside ak's release stamp, so status + // and the statusline drift from disk. Own subsystem so sync's fix is "disable + // the agent", never a needless force-reinstall of the brain itself. + if (rbNightlyPresent()) { + rows.push(row('ruvnet-brain-nightly', 'warn', + `ruvnet-brain nightly self-updater active (${RB_NIGHTLY_LABEL}) — bypasses ak-managed updates`, + 'sync disables it (re-enable deliberately: `npx ruvnet-brain --enable-nightly`)')); + } } // self (the kit's own version — prerelease installs track the `next` tag) diff --git a/src/commands/sync.mjs b/src/commands/sync.mjs index 775abd7..febe714 100644 --- a/src/commands/sync.mjs +++ b/src/commands/sync.mjs @@ -71,6 +71,12 @@ export async function run({ flags, pkgRoot }) { if (subsystems.has('ruvnet-brain') && !flags['no-upgrade']) { report('ruvnet-brain', await heal.installRuvnetBrain({ force: true })); } + // The brain installer's own nightly self-updater (macOS LaunchAgent) bypasses + // ak-managed updates — disabling it is a heal, not an upgrade, so it runs even + // under --no-upgrade. Reversible: `npx ruvnet-brain --enable-nightly`. + if (subsystems.has('ruvnet-brain-nightly')) { + report('ruvnet-brain nightly', await heal.disableRuvnetBrainNightly()); + } if (subsystems.has('security') || subsystems.has('versions')) { report('aidefence', await heal.healAidefence()); report('aqe solver', await heal.healAqeSolver()); diff --git a/src/lib/dashboard-server.mjs b/src/lib/dashboard-server.mjs index c7497f0..f2f7fa0 100644 --- a/src/lib/dashboard-server.mjs +++ b/src/lib/dashboard-server.mjs @@ -19,7 +19,9 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFile } from 'node:child_process'; -import { driftReport } from './versions.mjs'; +import { driftReport, selfDrift } from './versions.mjs'; +import { drift as ruvnetBrainDrift } from './ruvnet-brain.mjs'; +import { loadKitConfig } from './config.mjs'; const HERE = path.dirname(fileURLToPath(import.meta.url)); const PKG_ROOT = path.resolve(HERE, '..', '..'); @@ -69,7 +71,23 @@ async function collectData({ cwd, fetchStatus }) { // Version drift: prefer what the status payload already carried; otherwise // ask versions.mjs directly (TTL-cached, so no extra network within the window). let drift = Array.isArray(status?.drift) ? status.drift : null; - if (!drift) { try { drift = await driftReport(); } catch { drift = null; } } + if (!drift) { + try { drift = await driftReport(); } catch { drift = null; } + // driftReport only carries the npm-managed tools — fold in the two managed + // outside it so the update banner covers ALL tools under management: the + // RuvNet Brain (release-managed; foldBrainDrift) and the kit itself + // (selfDrift already returns the banner's {pkg, installed, latest, outdated} + // shape). Self-computed path only: a payload that supplied its own drift + // owns the whole array (tests inject network-free payloads). Both folds are + // TTL-cached in kit.json, like driftReport's window. + try { + const s = await selfDrift({ pkgRoot: PKG_ROOT }); + if (s.installed) drift = [...(drift ?? []), s]; + } catch { /* banner is best-effort — the subsystem card still carries the self row */ } + try { + if (loadKitConfig().ruvnetBrain) drift = foldBrainDrift(drift, await ruvnetBrainDrift()); + } catch { /* banner is best-effort — the subsystem card still carries the brain row */ } + } return { generatedAt: new Date().toISOString(), @@ -88,6 +106,22 @@ function kitVersion() { return pj?.version ?? '0.0.0'; } +/** Fold the RuvNet Brain into the npm drift array, same {pkg, installed, latest, + * outdated} shape renderDrift expects. `b` is a src/lib/ruvnet-brain.mjs drift() + * result (release-tag namespace, disk-first — the same value `ak status` and the + * statusline show, so the banner can never disagree with them). Absent brain → + * array unchanged (the "not installed" story lives on the subsystem card). + * Pure; exported for tests. */ +export function foldBrainDrift(drift, b) { + if (!b?.present) return drift; + return [...(drift ?? []), { + pkg: 'ruvnet-brain', + installed: b.installedRelease ?? '(unversioned)', + latest: b.latest, + outdated: !!b.outdated, + }]; +} + /** * Start the dashboard HTTP server, bound to loopback only. * @param {{ port?: number, cwd?: string, fetchStatus?: () => Promise }} [opts] @@ -508,7 +542,7 @@ const JS = ` // severity rank for rollups + triage sort; preferred order breaks ties. var RANK={fail:3,warn:2,ok:1,info:0,unknown:0}; - var PREF=["versions","self","natives","security","learning","providers","hosts","mcp","ruvnet-brain","aqe","daemons","blocks","statusline","npx"]; + var PREF=["versions","self","natives","security","learning","providers","hosts","mcp","ruvnet-brain","ruvnet-brain-nightly","aqe","daemons","blocks","statusline","npx"]; // Collapse rows into one group per subsystem (kills repeated labels); the // group's level is the worst of its rows. Sort worst-first, then by PREF. diff --git a/src/lib/heal.mjs b/src/lib/heal.mjs index 1c40985..5f3fdce 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, latestVersion as rbLatest, recordInstalledRelease as rbRecord } from './ruvnet-brain.mjs'; +import { INSTALL_SPEC, INSTALL_ARGS, NIGHTLY_LABEL as RB_NIGHTLY_LABEL, nightlyAgentPlist as rbNightlyPlist, present as rbPresent, latestVersion as rbLatest, recordInstalledRelease as rbRecord } from './ruvnet-brain.mjs'; import { PKG as ADB_PKG, present as adbPresent, coherence as adbCoherence } from './agentdb.mjs'; // Packages whose install scripts must run for natives to build (npm >=11.17 @@ -139,21 +139,51 @@ 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 args = ['-y', INSTALL_SPEC, ...INSTALL_ARGS, ...(force ? ['--force'] : [])]; + // Resolve the release tag FIRST and pin the installer to it (--version v), + // so the bundle that lands on disk is exactly the release ak stamps — the old + // install-then-stamp order left a window where a release published mid-install + // made the stamp disagree with disk. Offline (tag null): the installer's own + // latest logic applies and the stamp is best-effort afterwards, as before. + const tag = await rbLatest(); + const args = ['-y', INSTALL_SPEC, ...INSTALL_ARGS, + ...(tag ? ['--version', `v${tag}`] : []), + ...(force ? ['--force'] : [])]; const r = await run('npx', args, { timeout: 900_000 }); if (r.code === 0) { - // 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)' }; + // Stamp the release-tag namespace so drift converges — the plugin's own + // semver never tracks the KB release, so we can't use it. + const stamped = tag ?? await rbLatest(); + if (stamped) rbRecord(stamped); + return { ok: true, detail: stamped ? `installed release v${stamped}` : '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. return { ok: rbPresent(), detail: (r.stderr || `exit ${r.code}`).trim().split('\n').slice(-2).join(' ').slice(0, 200) }; } +/** Disable the brain installer's nightly self-update LaunchAgent (macOS-only — + * the installer never schedules anything elsewhere). ak is the single owner of + * brain updates (`ak sync` + the release stamp); the installer's 03:47 + * forge-update job rewrites the KB outside that record, and on the released + * v3.3.1 bundle it applies downloads without signature verification. Mirrors + * `npx ruvnet-brain --disable-nightly` exactly, and BOTH steps are required: + * bootout unloads the job from the live launchd session (file removal alone + * leaves it scheduled until logout); removing the plist stops launchd from + * re-registering it at next login. Idempotent; the user can re-enroll + * deliberately with `npx ruvnet-brain --enable-nightly` (status will re-flag), + * or set ruvnetBrain:false in kit.json to have ak stand down entirely. */ +export async function disableRuvnetBrainNightly({ runner = run } = {}) { + if (process.platform !== 'darwin') return { ok: true, detail: 'not macOS — installer never schedules here' }; + const plist = rbNightlyPlist(); + if (!fs.existsSync(plist)) return { ok: true, detail: 'nightly self-updater already off' }; + // Failure is fine: "not loaded" is exactly the state we want. + await runner('launchctl', ['bootout', `gui/${process.getuid()}/${RB_NIGHTLY_LABEL}`]); + try { fs.rmSync(plist); } catch (e) { + return { ok: false, detail: `couldn't remove ${plist}: ${e.message} — remove it by hand or run \`npx ruvnet-brain --disable-nightly\`` }; + } + return { ok: true, detail: 'nightly self-updater disabled (LaunchAgent removed; brain updates flow through ak sync)' }; +} + /** Ensure the standalone agentdb CLI is present AND coherent with ruflo's * bundled agentdb. Pins the global to the bundled version (not npm-latest) so * the shared cognitive store never skews on the core version — a core skew is diff --git a/src/lib/ruvnet-brain.mjs b/src/lib/ruvnet-brain.mjs index 76b8959..366395d 100644 --- a/src/lib/ruvnet-brain.mjs +++ b/src/lib/ruvnet-brain.mjs @@ -15,10 +15,30 @@ import { cmpVersions } from './versions.mjs'; import { loadKitConfig, saveKitConfig } from './config.mjs'; export const REPO = 'stuinfla/ruvnet-brain'; -/** npx spec + flags: --no-stack (ak manages ruflo/RuVector) and --no-enhance - * (ak owns the CLAUDE.md grounding block) prevent double-management. */ -export const INSTALL_SPEC = `github:${REPO}`; -export const INSTALL_ARGS = ['--yes', '--no-stack', '--no-enhance']; +/** npx spec + flags. The PUBLISHED npm installer, not `github:` — a github: spec + * runs the default-branch HEAD (an unreleased -dev installer, audited live + * 2026-07-17: HEAD was 3.4.5-dev while releases/latest was v3.3.1), which is + * inconsistent with every other tool ak manages by released artifact. + * --no-stack (ak manages ruflo/RuVector) and --no-enhance (ak owns the + * CLAUDE.md grounding block) prevent double-management. --no-nightly-prompt and + * --no-telemetry exist because the installer's `--yes` accepts EVERY optional + * offer — without them it silently enables the 03:47 nightly self-update + * LaunchAgent (macOS) and writes telemetry consent, both audited on the v3.3.1 + * installer. ak owns brain updates (`ak sync`), so the self-updater must stay off. */ +export const INSTALL_SPEC = 'ruvnet-brain@latest'; +export const INSTALL_ARGS = ['--yes', '--no-stack', '--no-enhance', '--no-nightly-prompt', '--no-telemetry']; + +/** The installer's nightly self-update LaunchAgent (macOS). Its label/path are the + * installer's own (`--enable-nightly` writes it; `--disable-nightly` removes it). + * ak detects it as drift because that 03:47 forge-update job rewrites the KB + * outside ak's release stamp — status/statusline would go stale against disk. */ +export const NIGHTLY_LABEL = 'com.ruvnet.brain-update'; +export function nightlyAgentPlist() { + return path.join(home, 'Library', 'LaunchAgents', `${NIGHTLY_LABEL}.plist`); +} +export function nightlyAgentPresent() { + return process.platform === 'darwin' && fs.existsSync(nightlyAgentPlist()); +} /** KB cache dir — the installer honors RUVNET_BRAIN_KB, so we do too. */ export function kbDir() { @@ -57,6 +77,21 @@ export function installedVersion() { } } +/** Release tag stamped ON DISK by the bundle itself (SOURCE.json → releaseTag). + * Release bundles carry it since the evergreen mechanism (the brain's own + * installer/telemetry read the same field); older or locally-built bundles + * don't → null. This is ground truth in the RELEASE namespace: it stays + * correct even when the KB changes outside ak (e.g. a user runs the bundle's + * forge-update.mjs by hand), where ak's kit.json stamp would go stale. */ +export function installedReleaseOnDisk() { + try { + const j = JSON.parse(fs.readFileSync(path.join(kbDir(), 'SOURCE.json'), 'utf8')); + const raw = String(j.releaseTag ?? ''); + if (/^[A-Za-z0-9._-]{1,32}$/.test(raw)) return raw.replace(/^v/, ''); + } catch { /* absent / unreadable / pre-stamping bundle — fall back to the kit.json stamp */ } + return null; +} + /** Latest release tag from GitHub, best-effort (null on any failure or rate * limit — callers must treat null as "unknown", never as "up to date"). */ export async function latestVersion({ timeout = 8000 } = {}) { @@ -80,9 +115,11 @@ export async function latestVersion({ timeout = 8000 } = {}) { /** 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. */ + * and the GitHub *release* tags the installer downloads by (e.g. v3.3.1). + * Evergreen-era release bundles stamp the release tag on disk + * (SOURCE.json → releaseTag; see installedReleaseOnDisk), but older bundles + * don't — so ak still keeps its own record of "which release did I last + * install" in kit.json as the fallback for pre-stamping installs. */ export function recordInstalledRelease(tag, cfg = loadKitConfig()) { if (!tag) return; const cur = cfg.versionCheck?.ruvnetBrain ?? {}; @@ -105,7 +142,9 @@ export function classifyDrift({ present: isPresent, installedRelease, latest }) /** 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. */ + * bypasses the cache. Installed side resolves disk-first: the bundle's own + * SOURCE.json releaseTag when stamped, else ak's kit.json record — the same + * order the statusline uses, so `ak status` and the footer can never disagree. */ export async function drift({ force = false } = {}) { const cfg = loadKitConfig(); const ttlMs = (cfg.versionCheck?.ttlHours ?? 24) * 3600_000; @@ -118,8 +157,9 @@ export async function drift({ force = false } = {}) { cfg.versionCheck = { ...cfg.versionCheck, ruvnetBrain: { ...cached, last: Date.now(), latest } }; try { saveKitConfig(cfg); } catch { /* read-only envs: next call re-fetches */ } } + const installedRelease = installedReleaseOnDisk() ?? cached.installedRelease ?? null; return { - ...classifyDrift({ present: present(), installedRelease: cached.installedRelease ?? null, latest }), + ...classifyDrift({ present: present(), installedRelease, latest }), pluginVersion: installedVersion(), }; } diff --git a/src/templates/statusline-footer.cjs b/src/templates/statusline-footer.cjs index 4e26ca1..3fb3e04 100644 --- a/src/templates/statusline-footer.cjs +++ b/src/templates/statusline-footer.cjs @@ -271,16 +271,26 @@ function rufloActivationSegments(cwd){ var kbDir = process.env.RUVNET_BRAIN_KB || path.join(os2.homedir(), ".cache", "ruvnet-brain", "kb"); if (fs.existsSync(path.join(kbDir, "forge-mcp-all.mjs"))) { // Version — best-effort, empty on any failure (never blocks the row). - // Prefer the GitHub RELEASE tag ak tracks (what `ak status` shows, e.g. - // 3.3.1) over the plugin.json SEMVER (e.g. 0.5.0-dev) — they are DIFFERENT - // version namespaces for the same install, and showing the plugin semver - // here confused users (it disagreed with `ak status`). Three-namespace - // gotcha; see MAINTAINER.md. Fall back to the plugin semver only when ak - // has no recorded release (manual/pre-existing install). + // RELEASE-tag namespace (what `ak status` shows, e.g. 3.3.1), never the + // plugin.json SEMVER (e.g. 0.5.0-dev) — different namespaces for the same + // install; showing the semver here confused users (it disagreed with + // `ak status`). Three-namespace gotcha; see MAINTAINER.md. Resolution + // order MIRRORS drift() in src/lib/ruvnet-brain.mjs so this row and + // `ak status` can never disagree: + // 1) the bundle's own on-disk stamp (SOURCE.json.releaseTag) — ground + // truth, current even when the KB changed outside ak (e.g. a manual + // forge-update.mjs run); + // 2) ak's kit.json record of the release it last installed; + // 3) plugin semver — last resort for manual/pre-stamping installs. var bver = ""; try { var relTag = null; try { + var srcJ = JSON.parse(fs.readFileSync(path.join(kbDir, "SOURCE.json"), "utf8")); + var rawTag = String(srcJ.releaseTag || ""); + if (/^[A-Za-z0-9._-]{1,32}$/.test(rawTag)) relTag = rawTag; + } catch(e){} + if (!relTag) try { var kitCfg = path.join(os2.homedir(), ".config", "agentic-kit", "kit.json"); var kj = JSON.parse(fs.readFileSync(kitCfg, "utf8")); if (kj && kj.versionCheck && kj.versionCheck.ruvnetBrain) relTag = kj.versionCheck.ruvnetBrain.installedRelease; diff --git a/tests/dashboard.test.cjs b/tests/dashboard.test.cjs index 9e21794..4864750 100644 --- a/tests/dashboard.test.cjs +++ b/tests/dashboard.test.cjs @@ -135,9 +135,14 @@ async function main() { } // A second fixture WITHOUT improvement.json / health ring: those keys must be - // null/absent, never a crash. + // null/absent, never a crash. `drift: []` is REQUIRED to keep this suite + // network-free: without it collectData takes the self-computed path + // (driftReport's npm views + the brain fold-in's GitHub fetch), and the + // fetch's undici keep-alive handle trips libuv's + // `!(handle->flags & UV_HANDLE_CLOSING)` assert (win/async.c:94) at process + // exit on Windows CI — every test passed, then the exit code was 1. const bare = mkFixture({}); - const srv2 = await startDashboard({ port: 0, cwd: bare, fetchStatus: async () => ({ overall: 'ok', rows: [] }) }); + const srv2 = await startDashboard({ port: 0, cwd: bare, fetchStatus: async () => ({ overall: 'ok', rows: [], drift: [] }) }); try { await test('missing improvement.json / health ring → null, no crash', async () => { const r = await get(srv2.url + 'api/status'); @@ -149,6 +154,31 @@ async function main() { await srv2.close(); } + // ── foldBrainDrift: the brain joins the npm drift array (banner covers ALL managed tools) ── + const { foldBrainDrift } = await import('file://' + MOD); + const npmDrift = [{ pkg: 'ruflo', installed: '4.0.0', latest: '4.1.0', outdated: true }]; + + await test('foldBrainDrift appends an outdated brain in renderDrift shape', async () => { + const out = foldBrainDrift(npmDrift, { present: true, installedRelease: '3.3.1', latest: '3.4.0', outdated: true }); + assert(out.length === 2, 'brain entry must be appended'); + const b = out[1]; + assert(b.pkg === 'ruvnet-brain' && b.installed === '3.3.1' && b.latest === '3.4.0' && b.outdated === true, + 'entry must carry {pkg, installed, latest, outdated}: ' + JSON.stringify(b)); + assert(out[0] === npmDrift[0], 'npm entries pass through untouched'); + }); + + await test('foldBrainDrift: absent brain → array unchanged; null drift → brain-only array', async () => { + assert(foldBrainDrift(npmDrift, { present: false }) === npmDrift, 'absent brain must not add an entry'); + assert(foldBrainDrift(npmDrift, null) === npmDrift, 'null drift result must be a no-op'); + const solo = foldBrainDrift(null, { present: true, installedRelease: '3.3.1', latest: '3.3.1', outdated: false }); + assert(Array.isArray(solo) && solo.length === 1 && solo[0].outdated === false, 'null npm drift still yields the brain entry'); + }); + + await test('foldBrainDrift labels a pre-stamping install honestly (unversioned, never fabricated)', async () => { + const out = foldBrainDrift([], { present: true, installedRelease: null, latest: '3.4.0', outdated: true }); + assert(out[0].installed === '(unversioned)', 'no release known → "(unversioned)", got ' + out[0].installed); + }); + console.log(`\n${failed === 0 ? '\x1b[32m' : '\x1b[31m'}${passed} passed, ${failed} failed\x1b[0m`); process.exit(failed === 0 ? 0 : 1); } diff --git a/tests/kit/ruvnet-brain.test.mjs b/tests/kit/ruvnet-brain.test.mjs index 43fafcd..338d978 100644 --- a/tests/kit/ruvnet-brain.test.mjs +++ b/tests/kit/ruvnet-brain.test.mjs @@ -4,8 +4,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { - kbDir, present, installedVersion, classifyDrift, - INSTALL_SPEC, INSTALL_ARGS, REPO, + kbDir, present, installedVersion, installedReleaseOnDisk, classifyDrift, + INSTALL_SPEC, INSTALL_ARGS, REPO, NIGHTLY_LABEL, nightlyAgentPlist, nightlyAgentPresent, } from '../../src/lib/ruvnet-brain.mjs'; import { BUILTIN_BLOCKS, detect } from '../../src/lib/blocks.mjs'; import { loadKitConfig, saveKitConfig } from '../../src/lib/config.mjs'; @@ -29,16 +29,55 @@ test('kbDir honors $RUVNET_BRAIN_KB, else defaults under ~/.cache', () => { }); }); -test('install spec/args stay pinned to the GitHub installer with --no-stack --no-enhance', () => { - // Regression lock: ak owns ruflo/RuVector + the CLAUDE.md block, so these two - // flags MUST persist or the installer double-manages them. +test('install spec/args stay pinned to the PUBLISHED installer with the four suppression flags', () => { + // Regression locks, each audited live on the v3.3.1 installer (2026-07-17): + // - npm spec, never github: — a github: spec runs the unreleased default-branch + // HEAD installer, inconsistent with ak's release-versioned management. + // - --no-stack/--no-enhance: ak owns ruflo/RuVector + the CLAUDE.md block. + // - --no-nightly-prompt/--no-telemetry: the installer's --yes accepts EVERY + // optional offer, silently enabling the 03:47 self-update LaunchAgent (macOS) + // and telemetry consent. ak owns updates, so these MUST persist. assert.equal(REPO, 'stuinfla/ruvnet-brain'); - assert.equal(INSTALL_SPEC, 'github:stuinfla/ruvnet-brain'); + assert.equal(INSTALL_SPEC, 'ruvnet-brain@latest'); assert.ok(INSTALL_ARGS.includes('--no-stack')); assert.ok(INSTALL_ARGS.includes('--no-enhance')); + assert.ok(INSTALL_ARGS.includes('--no-nightly-prompt')); + assert.ok(INSTALL_ARGS.includes('--no-telemetry')); assert.ok(INSTALL_ARGS.includes('--yes')); }); +test('installedReleaseOnDisk reads SOURCE.json releaseTag — validated, v-stripped, null-safe', () => { + const writeSource = (body) => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rb-src-')); + if (body !== null) fs.writeFileSync(path.join(tmp, 'SOURCE.json'), body); + return tmp; + }; + const readWith = (body) => + withEnv('RUVNET_BRAIN_KB', writeSource(body), () => installedReleaseOnDisk()); + + // stamped release bundle → tag in the RELEASE namespace, leading v stripped + assert.equal(readWith('{"releaseTag":"v3.3.1"}'), '3.3.1'); + // already unprefixed stays as-is + assert.equal(readWith('{"releaseTag":"3.3.1"}'), '3.3.1'); + // pre-stamping bundle (no field) → null, so the kit.json stamp takes over + assert.equal(readWith('{"builder":"rvf-kb-forge"}'), null); + // malformed JSON / missing file / junk tag → null, never a throw + assert.equal(readWith('{nope'), null); + assert.equal(readWith(null), null); + assert.equal(readWith('{"releaseTag":"not a tag !!"}'), null); + assert.equal(readWith(`{"releaseTag":"${'x'.repeat(40)}"}`), null); +}); + +test('nightly self-updater detection: label/plist fixed, present() is darwin-gated and never throws', () => { + // The label + path are the brain installer's own (--enable-nightly writes them); + // lock them so the sync heal keeps targeting the right LaunchAgent. + assert.equal(NIGHTLY_LABEL, 'com.ruvnet.brain-update'); + assert.ok(nightlyAgentPlist().endsWith(path.join('Library', 'LaunchAgents', 'com.ruvnet.brain-update.plist'))); + const p = nightlyAgentPresent(); + assert.equal(typeof p, 'boolean'); + if (process.platform !== 'darwin') assert.equal(p, false, 'installer never schedules off-macOS'); +}); + test('present() is true when the KB entrypoint exists (forge-mcp-all.mjs)', () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'rb-kb-')); fs.writeFileSync(path.join(tmp, 'forge-mcp-all.mjs'), '// stub'); @@ -79,6 +118,16 @@ test('classifyDrift compares in the RELEASE-TAG namespace and converges', () => assert.equal(classifyDrift({ present: true, installedRelease: '3.0.0', latest: null }).outdated, false); }); +test('disableRuvnetBrainNightly no-ops cleanly when nothing is scheduled', async (t) => { + // Guard: on a macOS machine that IS enrolled, running this would remove the + // real LaunchAgent — a unit test must never mutate the host. Skip there. + if (nightlyAgentPresent()) return t.skip('real nightly LaunchAgent present — not mutating the host'); + const { disableRuvnetBrainNightly } = await import('../../src/lib/heal.mjs'); + const r = await disableRuvnetBrainNightly({ runner: async () => ({ code: 0 }) }); + assert.equal(r.ok, true); + assert.match(r.detail, /not macOS|already off/); +}); + 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'); diff --git a/tests/statusline-brain.test.cjs b/tests/statusline-brain.test.cjs index 9071786..b77db27 100644 --- a/tests/statusline-brain.test.cjs +++ b/tests/statusline-brain.test.cjs @@ -53,11 +53,14 @@ function renderWithKb(kb) { finally { if (prev === undefined) delete process.env.RUVNET_BRAIN_KB; else process.env.RUVNET_BRAIN_KB = prev; } } -// Build a fixture KB dir. `entry` controls whether the forge-mcp-all.mjs probe file exists. -function mkKb({ entry = true, dataBytes = 0 } = {}) { +// Build a fixture KB dir. `entry` controls whether the forge-mcp-all.mjs probe file +// exists; `source` (string) is written verbatim as SOURCE.json (the bundle's own +// on-disk release stamp, read FIRST by the version resolution). +function mkKb({ entry = true, dataBytes = 0, source = null } = {}) { const kb = mkdir(); if (entry) fs.writeFileSync(path.join(kb, 'forge-mcp-all.mjs'), '// entrypoint\n'); if (dataBytes > 0) fs.writeFileSync(path.join(kb, 'agentdb.rvf'), Buffer.alloc(dataBytes)); + if (source !== null) fs.writeFileSync(path.join(kb, 'SOURCE.json'), source); // A __MACOSX artifact dir must never be counted toward KB size (it is a directory). fs.mkdirSync(path.join(kb, '__MACOSX'), { recursive: true }); return kb; @@ -99,5 +102,26 @@ test('brain row occupies its OWN line, never merged with other segments', () => assert(bl, 'expected a 🧿 line, got: ' + JSON.stringify(lines)); }); +// ── version resolution: SOURCE.json releaseTag is ground truth (mirrors drift()) ── +test('SOURCE.json releaseTag renders as the version — release namespace, disk-first', () => { + const out = renderWithKb(mkKb({ entry: true, source: '{"releaseTag":"v3.3.1"}' })); + contains(out, 'V3.3.1'); +}); + +test('unprefixed releaseTag renders with the V chip prefix intact', () => { + const out = renderWithKb(mkKb({ entry: true, source: '{"releaseTag":"3.3.1"}' })); + contains(out, 'V3.3.1'); +}); + +test('junk releaseTag is rejected (validated, never rendered verbatim)', () => { + const out = renderWithKb(mkKb({ entry: true, source: '{"releaseTag":"not a tag !!"}' })); + absent(out, 'not a tag'); +}); + +test('malformed SOURCE.json never breaks the row (falls back, 🧿 still renders)', () => { + const out = renderWithKb(mkKb({ entry: true, source: '{nope' })); + contains(out, '🧿'); +}); + console.log(`\n${failed === 0 ? '\x1b[32m' : '\x1b[31m'}${passed} passed, ${failed} failed\x1b[0m`); process.exit(failed === 0 ? 0 : 1);