ruvnet-brain 4.0.24 โ†’ 4.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # ๐Ÿง  RuvNet Brain
6
6
 
7
- ### ๐Ÿง  RuvNet Brain โ€” [![RuvNet Brain version 4.0.24 โ€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.0.24-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
7
+ ### ๐Ÿง  RuvNet Brain โ€” [![RuvNet Brain version 4.0.35 โ€” updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.0.35-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
8
8
 
9
9
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack โ€” delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
10
10
 
@@ -30,7 +30,7 @@
30
30
  [![explainer](https://img.shields.io/badge/โ–ถ%20see%20it%20live-isovision.ai%2Fruvnet--brain-e8a13a?style=flat-square)](https://isovision.ai/ruvnet-brain/)
31
31
  [![license](https://img.shields.io/badge/license-MIT-8ecae6?style=flat-square)](LICENSE)
32
32
  [![grounded](https://img.shields.io/badge/answers-cited%20rUv%20source-333?style=flat-square)](#testing--proof)
33
- [![coverage](https://img.shields.io/badge/coverage-34%25%20of%20ALL%20source%20ยท%20honest-b58900?style=flat-square)](#testing--proof)
33
+ [![coverage](https://img.shields.io/badge/coverage-36%25%20of%20ALL%20source%20ยท%20honest-b58900?style=flat-square)](#testing--proof)
34
34
 
35
35
  > **One Brain generation everywhere.** npm, the GitHub tag/release, bundle manifests, source metadata, and checksum-bound RVF generations must share the same product version. Headline claims are regenerated and checked by the claims ledger (`scripts/claims-verify.mjs`); other numbers below are hand-stamped and dated:
36
36
  > - **`plugin`** (badge above) โ€” the Claude Code plugin itself: SKILL.md, the grounding hooks, the MCP server. Read live from [`plugin/.claude-plugin/plugin.json`](plugin/.claude-plugin/plugin.json). Updates often โ€” this is where behavior fixes land.
@@ -485,7 +485,7 @@ node plugin/test/run-tests.mjs # full plugin QA over real JSO
485
485
  | **L4 "orchestrate"** | **downgraded โ€” measures speech, not obedience** | L4 asserts the hook's own injected prose contains required words (`must: ['take the wheel','SPARC','swarm',โ€ฆ]`). That proves **the brain spoke**. It cannot fail when the advice is read and ignored โ€” which is the failure this product exists to prevent. Counterfactual replay against a brain-off control (ADR-058 ยงD4) is what will earn this row back |
486
486
  | **Plugin QA** | **60 / 60** | manifests, hook firing, MCP `initialize`/`tools/list`, capability battery |
487
487
  | **Clean-room install** | **3 / 3** | download the published bundle fresh โ†’ unzip โ†’ query โ†’ grounded, cited answers |
488
- | **Unit tests** | **2,327 passing, 169 todo** ยท 34% of ALL source covered | `npm run test:cov` regenerates both โ€” the coverage floor fails CI if it slips (`claims:verify` re-derives the %, it is not a hand-typed badge). 34% is the honest number over every shipped file; the previous "75%" measured a hand-picked 8-file subset |
488
+ | **Unit tests** | **3,035 passing, 161 todo** ยท 36% of ALL source covered | `npm run test:cov` regenerates both โ€” the coverage floor fails CI if it slips (`claims:verify` re-derives the %, it is not a hand-typed badge). 36% is the honest number over every shipped file; the previous "75%" measured a hand-picked 8-file subset |
489
489
  | **Grounding proof** | `npx ruvnet-brain --doctor` | asks a real question, then checks the cited path really exists in the on-disk store; a citation that doesn't resolve is reported as **NOT grounded** |
490
490
  | **Held-out eval** | **grounded 100/100** ยท routed 63/80 | `npm run eval` โ€” 120 frozen, hash-pinned questions across 5 strata, never used for tuning, graded on ground truth, never by a model |
491
491
 
package/bin/install.mjs CHANGED
@@ -25,6 +25,24 @@ import {
25
25
  missingEmbedderModels,
26
26
  } from '../kb/model-requirements.mjs';
27
27
  import { applyManagedCatalogUpdate } from '../scripts/model-router-catalog.mjs';
28
+ import { cmpVersion } from '../scripts/stack-sync.mjs';
29
+
30
+ // ISSUE #123 โ€” AN INSTALL THAT IS AHEAD OF THE PUBLISHED RELEASE IS NOT A BROKEN INSTALL.
31
+ // Host convergence compared installed === PACKAGE_VERSION, so 4.0.29-dev against a published
32
+ // 4.0.28 read EXACTLY like being behind: --update exited 1 with 'host synchronization is
33
+ // incomplete' and --doctor printed 'โœ— FAILING' while every displayed check was green (121 repos
34
+ // indexed, grounding proven, Codex MCP live, 17 hooks enabled). The prescribed repair โ€”
35
+ // reinstalling the plugin extras โ€” was a no-op, because the extras were present and firing.
36
+ // Telling someone their working install is broken, then handing them a fix that changes nothing,
37
+ // is the worst kind of false alarm: it burns trust AND time.
38
+ // Convergence means NOT BEHIND. Ordering comes from cmpVersion, which is prerelease-aware and is
39
+ // documented as the only place ordering is decided in this system.
40
+ const versionSatisfies = (installed, expected) => {
41
+ if (!expected) return true;
42
+ if (!installed) return false;
43
+ if (installed === expected) return true;
44
+ try { return cmpVersion(installed, expected) >= 0; } catch { return installed === expected; }
45
+ };
28
46
  import {
29
47
  CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
30
48
  } from '../scripts/console-runtime-identity.mjs';
@@ -847,7 +865,7 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
847
865
  // code says the command ran, not that the plugin is usable; the commands either exist on disk or
848
866
  // they do not. This is the difference between `/rvbc` working and "Unknown command: /rvbc".
849
867
  const installed = claudePluginStatus();
850
- if (installed.installed && (!expectedVersion || installed.version === expectedVersion)) {
868
+ if (installed.installed && versionSatisfies(installed.version, expectedVersion)) {
851
869
  ok(`plugin installed at user scope (global, alongside Ruflo / RuVector) โ€” exact version ${installed.version}`);
852
870
  info(` commands available after a restart: ${c.bold('/rvbc')}, ${c.bold('/ruvnet-brain:configure')}`);
853
871
  return { host: true, wired: true, version: installed.version, manualMarketplace, manualInstall };
@@ -855,7 +873,7 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
855
873
 
856
874
  // The honest failure. The brain still WORKS โ€” this is the difference between a broken install and
857
875
  // a partial one, and the user is told exactly which they have instead of being congratulated.
858
- const mismatch = installed.installed && expectedVersion && installed.version !== expectedVersion;
876
+ const mismatch = installed.installed && !versionSatisfies(installed.version, expectedVersion);
859
877
  warn(mismatch
860
878
  ? `Claude installed ${installed.version || 'an unknown version'}, not required ${expectedVersion}; refusing to call this host converged.`
861
879
  : `the plugin did NOT land โ€” so slash commands like ${c.bold('/rvbc')} will not exist yet.`);
@@ -1259,7 +1277,7 @@ export function wireCodexPlugin({
1259
1277
  if (announce) warn(`Codex Brain plugin is installed but disabled by user or policy โ€” left disabled (${CODEX_PLUGIN_ID}).`);
1260
1278
  return { host: true, action: 'disabled', ...before };
1261
1279
  }
1262
- if (before.installed && before.enabled && (!expectedVersion || before.version === expectedVersion)) {
1280
+ if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
1263
1281
  if (announce) ok(`Codex Brain plugin already installed and enabled (${before.version || 'version unknown'}) โ€” no changes.`);
1264
1282
  return { host: true, action: 'unchanged', ...before };
1265
1283
  }
@@ -1297,7 +1315,7 @@ export function wireCodexPlugin({
1297
1315
  return { host: true, action: 'plugin-failed', error: added.error };
1298
1316
  }
1299
1317
  const after = codexPluginStatus({ ...options, runJson });
1300
- if (!after.installed || !after.enabled || (expectedVersion && after.version !== expectedVersion)) {
1318
+ if (!after.installed || !after.enabled || !versionSatisfies(after.version, expectedVersion)) {
1301
1319
  if (announce) warn('Codex accepted the install command but the Brain plugin is not installed and enabled.');
1302
1320
  return { host: true, action: 'verification-failed', expectedVersion, ...after };
1303
1321
  }
@@ -2363,12 +2381,15 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
2363
2381
  }
2364
2382
 
2365
2383
  export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSION) {
2366
- if (!receipt || receipt.desiredVersion !== expectedVersion) {
2384
+ // Same 'ahead is not behind' rule as host convergence above (#123). A receipt written by a
2385
+ // NEWER generation than the installer currently running is not a version mismatch โ€” it is a
2386
+ // machine that is further along. Only a receipt for an OLDER generation is stale.
2387
+ if (!receipt || !versionSatisfies(receipt.desiredVersion, expectedVersion)) {
2367
2388
  return { healthy: false, state: 'version-mismatch', action: `required version ${expectedVersion}` };
2368
2389
  }
2369
2390
  const hostStates = Object.values(receipt.hosts || {});
2370
2391
  const badHost = hostStates.find((host) => !['ready', 'disabled', 'absent'].includes(host?.state)
2371
- || (host.state === 'ready' && host.version !== expectedVersion));
2392
+ || (host.state === 'ready' && !versionSatisfies(host.version, expectedVersion)));
2372
2393
  if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' };
2373
2394
  if (receipt.consoleRuntime?.state !== 'ready') {
2374
2395
  return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: 'restart Console, then re-run --doctor' };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.0.24",
3
+ "version": "4.0.35",
4
4
  "description": "One-command installer for RuvNet Brain โ€” a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code โ€” grounds every RuvNet decision in real source across 71 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships an enforced UserPromptSubmit retrieve-and-inject grounding hook that sharply reduces drift.",
4
- "version": "4.0.24",
4
+ "version": "4.0.35",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.0.24",
3
+ "version": "4.0.35",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -709,11 +709,27 @@ export const CAPABILITIES = [
709
709
 
710
710
  const S = helpers.lessonStore.STATUS || {};
711
711
  const inForce = lessons.filter((l) => l?.status === S.RATIFIED || l?.status === S.ACTIVE).length;
712
- const candidates = lessons.filter((l) => l?.status === S.CANDIDATE).length;
713
- // A candidate can never block (lesson-store.mjs). Counting all 12 as "your lessons" would be
714
- // the flattering number; the honest one is how many can actually affect a decision.
712
+ // AWAITING YOU means RATIFIABLE BY YOU (issue #125). This counted every CANDIDATE, including
713
+ // the seeded imported-owner lessons โ€” which `ratify()` refuses by design and `pending()`
714
+ // excludes, so `lesson-ratify --list` correctly reported "0 awaiting your decision" while this
715
+ // card said 12 were awaiting ratification. Two surfaces, two answers, and the card's was the
716
+ // one asking the user to act on something that cannot be acted on. The quarantine is right;
717
+ // describing it as a pending decision is not.
718
+ //
719
+ // Delegated to the store's own pending(), rather than re-deriving the predicate here โ€” a
720
+ // second copy is exactly how these two numbers drifted apart in the first place.
721
+ const ratifiable = typeof helpers.lessonStore.pending === 'function'
722
+ ? helpers.lessonStore.pending(lessons).length
723
+ : lessons.filter((l) => l?.status === S.CANDIDATE && !l?.demoted).length;
724
+ const quarantined = lessons.filter((l) => l?.status === S.CANDIDATE).length - ratifiable;
715
725
  if (inForce > 0) return row(STATE.ON, `${inForce} of ${lessons.length} lessons are ratified and can affect what your AI does`);
716
- return row(STATE.OFF, `all ${candidates} recorded lessons are still candidates awaiting your ratification โ€” none of them can influence anything yet`);
726
+ if (ratifiable > 0) {
727
+ return row(STATE.OFF, `${ratifiable} recorded lesson(s) are candidates awaiting your ratification โ€” none of them can influence anything yet`);
728
+ }
729
+ // Nothing is actually waiting on the user. Say what IS true instead of inventing a decision.
730
+ return row(STATE.OFF, quarantined > 0
731
+ ? `${quarantined} bundled maintainer lesson(s) are quarantined and cannot be ratified โ€” nothing is awaiting your decision`
732
+ : 'no lessons are in force yet, and none are awaiting your decision');
717
733
  },
718
734
  },
719
735
 
@@ -492,14 +492,14 @@ export async function runSessionStart({
492
492
  }
493
493
  let confidenceInstruction;
494
494
  if (readiness.state === 'ready') {
495
- emit('USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here โ€” nothing to reinstall per project. search_ruvnet is ready and live; the grounding hooks are active.');
495
+ emit('USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here โ€” nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is ready and live; the grounding hooks are active.');
496
496
  confidenceInstruction = `Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "๐Ÿง  RuvNet Brain active (v${bannerVersion})" โ€” ONE version, in parentheses, always; never a second number, never a bundle tag beside it โ€” and convey: it grounds rUv's stack (RVF, Ruflo, AgentDB, SPARC, agentic-flowโ€ฆ) in his real source rather than guessing; npx github:stuinfla/ruvnet-brain --doctor checks it; ${consoleInvoke} opens a visual settings page.`;
497
497
  } else if (readiness.state === 'degraded') {
498
498
  const receipt = readiness.receipt || {};
499
- emit(`USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here โ€” nothing to reinstall per project. search_ruvnet is registered but degraded (${receipt.phase || 'startup'}: ${receipt.error || 'readiness failed'}); the grounding hooks remain active.`);
499
+ emit(`USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here โ€” nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is registered but degraded (${receipt.phase || 'startup'}: ${receipt.error || 'readiness failed'}); the grounding hooks remain active.`);
500
500
  confidenceInstruction = `Open your FIRST response with ONE short line: "๐Ÿง  RuvNet Brain active (v${bannerVersion}) โ€” search is degraded right now." Do not claim source grounding until a real search succeeds. npx github:stuinfla/ruvnet-brain --doctor shows the current verdict; ${consoleInvoke} opens the Console.`;
501
501
  } else {
502
- emit('USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here โ€” nothing to reinstall per project. search_ruvnet is registered; live readiness is not yet proven. The grounding hooks are active.');
502
+ emit('USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here โ€” nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is registered; live readiness is not yet proven. The grounding hooks are active.');
503
503
  confidenceInstruction = `Open your FIRST response with ONE short line: "๐Ÿง  RuvNet Brain active (v${bannerVersion}) โ€” search is registered and will prove readiness on first use." Do not claim source grounding until a real search returns a citation. npx github:stuinfla/ruvnet-brain --doctor shows the current verdict; ${consoleInvoke} opens the Console.`;
504
504
  }
505
505
  emit(confidenceInstruction);
@@ -316,10 +316,14 @@ function gc() {
316
316
  console.log(`gc: kept ${[...keep].join(', ') || '(none)'} ยท removed ${removed}`);
317
317
  }
318
318
 
319
+ // Version ordering, hoisted to module scope so BOTH the exact payload selector and the
320
+ // already-converged check below can use one comparator (#126). Two copies is how the
321
+ // selection rule and the convergence rule would drift apart.
322
+ const semverish = (v) => String(v).split(/[.-]/).map((x) => (Number.isFinite(+x) ? +x : x));
323
+ const cmp = (a, b) => { const A = semverish(a), B = semverish(b); for (let i = 0; i < Math.max(A.length, B.length); i++) { if ((A[i] ?? 0) === (B[i] ?? 0)) continue; if (typeof A[i] === 'number' && typeof B[i] === 'number') return A[i] - B[i]; return String(A[i] ?? '') < String(B[i] ?? '') ? -1 : 1; } return 0; };
324
+
319
325
  // โ”€โ”€ sources โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
320
326
  function newestStagedCC(expectedVersion = null) {
321
- const semverish = (v) => v.split(/[.-]/).map((x) => (Number.isFinite(+x) ? +x : x));
322
- const cmp = (a, b) => { const A = semverish(a), B = semverish(b); for (let i = 0; i < Math.max(A.length, B.length); i++) { if ((A[i] ?? 0) === (B[i] ?? 0)) continue; if (typeof A[i] === 'number' && typeof B[i] === 'number') return A[i] - B[i]; return String(A[i] ?? '') < String(B[i] ?? '') ? -1 : 1; } return 0; };
323
327
  const candidates = [];
324
328
  for (const cache of PLUGIN_CACHES) {
325
329
  if (!fs.existsSync(cache)) continue;
@@ -400,6 +404,27 @@ function main() {
400
404
  const active = readJSON(ACTIVE);
401
405
  if (!staged) {
402
406
  if (expectedVersion) {
407
+ // ALREADY AHEAD IS ALREADY CONVERGED (issue #126) โ€” but the SELECTION above stays exact.
408
+ //
409
+ // Issue #64 requires that we never apply "a different newer payload in another host cache",
410
+ // and that requirement is right: the caches are per-host, so grabbing a newer payload from
411
+ // .codex to satisfy a .claude request installs something nobody asked for. My first attempt
412
+ // at #126 loosened newestStagedCC() to "newest >= expected" and reintroduced exactly that
413
+ // bug โ€” tests/qe/release/issue-64-host-convergence.test.mjs caught it.
414
+ //
415
+ // The real defect is one layer up: after a release the plugin is bumped to the next -dev
416
+ // generation, so the ACTIVE host is routinely ahead of the published version the updater
417
+ // requests. No payload matched, this returned 1, host sync reported incomplete, and the
418
+ // Console runtime refresh inside that sync was rolled back โ€” so the header's
419
+ // "โŸณ update available โ€” click to update" ran clean and changed nothing, forever.
420
+ //
421
+ // Nothing to apply because you are already past it is SUCCESS, not failure. This asserts
422
+ // that about the ACTIVE spine only; it never selects an unrequested payload.
423
+ const activeNow = readJSON(ACTIVE);
424
+ if (activeNow?.version && cmp(activeNow.version, expectedVersion) >= 0) {
425
+ console.log(`already on ${activeNow.version}, at or above requested ${expectedVersion} โ€” nothing to apply.`);
426
+ return 0;
427
+ }
403
428
  console.error(`โœ— no staged host payload exactly matches expected version ${expectedVersion} โ€” spine unchanged`);
404
429
  return 1;
405
430
  }
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GITHUB HEALTH WATCH โ€” one place that notices GitHub is unhappy, before the maintainer does.
4
+ *
5
+ * WHY THIS EXISTS. On 2026-08-08 the maintainer said: "You should never ever let it be in a
6
+ * situation where it's got failed pushes. Your job is to always be looking out for GitHub to make
7
+ * sure if there's any problem that you're on it immediately." They were right, and the reason it
8
+ * kept happening is structural, not attentional: every existing signal watched ONE thing.
9
+ *
10
+ * ยท signal-watch โ€” CI verdicts, per push
11
+ * ยท issue-watch โ€” open issues and their SLA
12
+ * ยท published-surface โ€” npm vs GitHub, nightly
13
+ *
14
+ * Nothing watched the states that actually stall work: a red default branch, a PR that has silently
15
+ * gone CONFLICTING, a branch left behind after a merge, a stuck queue, or a release that published
16
+ * and then failed its own bookkeeping. Each was found by a human noticing something felt wrong.
17
+ *
18
+ * WHAT IT DOES NOT DO. It does not push, merge, close, publish, or edit anything. It REPORTS. A
19
+ * watcher that also acts is a watcher whose failures are hard to reason about, and this repo has
20
+ * already been bitten by automation that satisfied its own success predicate (ADR-050). Exit code
21
+ * is the contract: 0 = healthy, 1 = something needs attention, and the reasons print in full.
22
+ *
23
+ * node scripts/github-health-watch.mjs # human-readable
24
+ * node scripts/github-health-watch.mjs --json # machine-readable, for hooks/CI
25
+ */
26
+ import { execFileSync } from 'node:child_process';
27
+
28
+ const REPO = 'stuinfla/ruvnet-brain';
29
+ const JSON_OUT = process.argv.includes('--json');
30
+ const findings = [];
31
+ const note = (level, area, detail, action) => findings.push({ level, area, detail, action });
32
+
33
+ const gh = (args) => {
34
+ try { return execFileSync('gh', args, { encoding: 'utf8', timeout: 60_000 }).trim(); }
35
+ catch (error) { return { __error: String(error.stderr || error.message).slice(0, 200) }; }
36
+ };
37
+ const ghJson = (args) => {
38
+ const out = gh(args);
39
+ if (out && out.__error) return out;
40
+ try { return JSON.parse(out); } catch { return { __error: 'unparseable gh output' }; }
41
+ };
42
+
43
+ // โ”€โ”€ 1. Is the default branch green? A red main blocks every release and every merge. โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
44
+ const runs = ghJson(['run', 'list', '--repo', REPO, '--branch', 'main', '--workflow', 'ci',
45
+ '--limit', '1', '--json', 'status,conclusion,headSha,url']);
46
+ if (runs.__error) note('warn', 'ci', `could not read CI status: ${runs.__error}`, 'check gh auth');
47
+ else if (!runs.length) note('warn', 'ci', 'no CI runs found on main', 'verify the workflow is enabled');
48
+ else {
49
+ const [run] = runs;
50
+ if (run.status === 'completed' && run.conclusion !== 'success') {
51
+ note('fail', 'ci', `main is RED at ${run.headSha.slice(0, 7)} (${run.conclusion})`, `read ${run.url}`);
52
+ }
53
+ }
54
+
55
+ // โ”€โ”€ 2. Queue health. A stalled queue looks identical to slow CI until someone waits an hour. โ”€โ”€โ”€โ”€โ”€
56
+ const queued = ghJson(['run', 'list', '--repo', REPO, '--limit', '60', '--json', 'status']);
57
+ if (!queued.__error && Array.isArray(queued)) {
58
+ const q = queued.filter((r) => r.status === 'queued').length;
59
+ const running = queued.filter((r) => r.status === 'in_progress').length;
60
+ // Many queued with NOTHING executing is the shape of a stuck plane or an exhausted runner pool โ€”
61
+ // measured on 2026-08-06, when two hung notifier runs held both slots and starved 21 real runs.
62
+ if (running === 0 && q >= 5) {
63
+ note('fail', 'queue', `${q} runs queued, 0 executing โ€” the Actions plane may be stalled`,
64
+ 'check githubstatus.com, then cancel any hung run holding a concurrency slot');
65
+ }
66
+ }
67
+
68
+ // โ”€โ”€ 3. PRs that have gone unmergeable. These rot silently; nobody is notified when main moves. โ”€โ”€โ”€
69
+ const prs = ghJson(['pr', 'list', '--repo', REPO, '--state', 'open', '--json',
70
+ 'number,title,mergeable,isDraft,updatedAt']);
71
+ if (!prs.__error && Array.isArray(prs)) {
72
+ for (const pr of prs) {
73
+ if (pr.mergeable === 'CONFLICTING') {
74
+ note('fail', 'pr', `#${pr.number} is CONFLICTING โ€” "${pr.title.slice(0, 60)}"`,
75
+ 'rebase or resolve against main; a conflicting PR is invisible work');
76
+ }
77
+ }
78
+ }
79
+
80
+ // โ”€โ”€ 4. Branches left behind after a merge. The maintainer's standing rule is a few, resolved fast.
81
+ const branches = gh(['api', `repos/${REPO}/branches?per_page=100`, '--jq', '.[].name']);
82
+ if (typeof branches === 'string') {
83
+ const list = branches.split('\n').filter(Boolean).filter((b) => b !== 'main');
84
+ if (list.length > 3) {
85
+ note('warn', 'branches', `${list.length} non-main branches: ${list.slice(0, 6).join(', ')}`,
86
+ 'delete merged branches; resolve the rest back to main');
87
+ }
88
+ }
89
+
90
+ // โ”€โ”€ 5. Published surfaces naming different generations โ€” the whole of issue #77. โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
91
+ const npmLatest = (() => {
92
+ try { return execFileSync('npm', ['view', 'ruvnet-brain', 'dist-tags.latest'], { encoding: 'utf8', timeout: 60_000 }).trim(); }
93
+ catch { return null; }
94
+ })();
95
+ const release = ghJson(['api', `repos/${REPO}/releases/latest`, '--jq', '.tag_name']);
96
+ if (npmLatest && typeof release === 'string' && release) {
97
+ if (npmLatest !== release.replace(/^v/, '')) {
98
+ note('fail', 'surfaces', `npm ${npmLatest} != GitHub ${release}`,
99
+ 'run scripts/published-surface-probe.mjs; this is issue #77 recurring');
100
+ }
101
+ }
102
+
103
+ // โ”€โ”€ 6. Issues past their response SLA. โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
104
+ const issues = ghJson(['issue', 'list', '--repo', REPO, '--state', 'open', '--json',
105
+ 'number,title,createdAt,comments']);
106
+ if (!issues.__error && Array.isArray(issues)) {
107
+ for (const issue of issues) {
108
+ const ageH = (Date.now() - Date.parse(issue.createdAt)) / 3_600_000;
109
+ if (ageH > 4 && (issue.comments ?? 0) === 0) {
110
+ note('fail', 'issues', `#${issue.number} is ${Math.round(ageH)}h old with NO response`,
111
+ 'respond; the 4h SLA is the promise, not the target');
112
+ }
113
+ }
114
+ }
115
+
116
+ const failed = findings.filter((f) => f.level === 'fail');
117
+ if (JSON_OUT) {
118
+ process.stdout.write(`${JSON.stringify({ healthy: failed.length === 0, findings }, null, 2)}\n`);
119
+ } else if (!findings.length) {
120
+ process.stdout.write('[github-health] โœ“ clean โ€” main green, no conflicting PRs, surfaces coherent, no unanswered issues.\n');
121
+ } else {
122
+ for (const f of findings) {
123
+ process.stdout.write(`[github-health] ${f.level === 'fail' ? 'โœ—' : 'โš '} ${f.area}: ${f.detail}\n โ†’ ${f.action}\n`);
124
+ }
125
+ }
126
+ process.exit(failed.length ? 1 : 0);
@@ -105,8 +105,34 @@ export function formatTable(rows) {
105
105
  const withBar = totalFrontier > 0 ? Math.min(BAR, Math.max(1, Math.round(BAR * (totalCost / totalFrontier)))) : BAR;
106
106
  const drawBar = (n) => 'โ–ˆ'.repeat(n) + 'โ–‘'.repeat(BAR - n);
107
107
  const rule = 'โ”€'.repeat(70);
108
+ // โ”€โ”€ IS THIS STILL HAPPENING, OR IS IT A SOUVENIR? (added 2026-08-08) โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
109
+ //
110
+ // This card reported "SAVED ~61% ยท 43 routed tasks" for FOURTEEN DAYS after routing had entirely
111
+ // stopped. Every number was true and every number was history: the newest receipt was 2026-07-24,
112
+ // and in the fortnight since โ€” including three days of heavy work โ€” not one task was routed. The
113
+ // card looked like a live dashboard and was actually a museum plaque.
114
+ //
115
+ // SKILL.md already records this exact failure once ("after two days, the receipts log held 3
116
+ // entries, all test pings, $0.018 saved โ€” while real work was done inline in the most expensive
117
+ // model") and answered it by making the routing rule a floor rather than advice. That did not
118
+ // help, because nothing MEASURED whether the floor was being honoured. A silent zero reads
119
+ // identically to a healthy system, which is the same disease as every other defect fixed this
120
+ // week: a surface reporting something other than what it measured.
121
+ //
122
+ // So the card now leads with recency. Savings you earned last month are not savings you are
123
+ // earning, and a stale ledger must say so before it says anything else.
124
+ const newest = rows.map((r) => Date.parse(r.ts || r.date || r.at || 0)).filter(Boolean).sort().at(-1);
125
+ const idleDays = newest ? (Date.now() - newest) / 86_400_000 : Infinity;
126
+ const staleness = !Number.isFinite(idleDays)
127
+ ? ' โš  NO ROUTING RECEIPTS AT ALL โ€” these numbers describe nothing that has happened.'
128
+ : idleDays >= 3
129
+ ? ` โš  STALE: nothing has been routed in ${Math.floor(idleDays)} days (newest receipt ${new Date(newest).toISOString().slice(0, 10)}).\n`
130
+ + ' The figures below are HISTORY, not current performance โ€” mechanical work is running unrouted.'
131
+ : null;
132
+
108
133
  return [
109
134
  rule,
135
+ ...(staleness ? [staleness, ''] : []),
110
136
  ` ๐Ÿ’ฐ SAVED ~${pct}%${speedBadge} ยท ~${ratio}ร— cheaper ยท ${rows.length} routed task(s) (${subagents} subagent, ${rows.length - subagents} openrouter/calibration)`,
111
137
  '',
112
138
  ` without routing ${drawBar(BAR)} ${fmt$(totalFrontier)}`,
@@ -119,6 +119,18 @@ sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
119
119
  # host verification, post-publication seal) still runs exactly as designed. It stands down on any
120
120
  # unexpected state โ€” dirty tree, open PRs, no green exact-SHA evidence, a -dev version, a stalled
121
121
  # Actions plane โ€” because a watchdog acting on a partial picture is worse than no watchdog.
122
+ # โ”€โ”€ GITHUB HEALTH (2026-08-08). Stuart: "You should never ever let it be in a situation where
123
+ # it's got failed pushes. Your job is to always be looking out for GitHub." Every prior signal
124
+ # watched exactly ONE thing โ€” signal-watch CI verdicts, issue-watch the SLA, published-surface
125
+ # npm-vs-GitHub โ€” and nothing watched the states that actually stall work: a red main, a PR that
126
+ # silently went CONFLICTING, a stalled Actions queue, branches left behind after a merge.
127
+ #
128
+ # REPORTS ONLY. It cannot push, merge, close or publish (asserted by test). Exit 1 means a human
129
+ # needs to look, and the reasons print in full rather than as a count.
130
+ echo "===== GITHUB-HEALTH watch โ€” $(date -u +%FT%TZ) =====" >> "$LOG"
131
+ /usr/local/bin/node scripts/github-health-watch.mjs >> "$LOG" 2>&1 \
132
+ || echo "[github-health] findings above need attention" >> "$LOG"
133
+
122
134
  echo "===== RELEASE-CONVERGENCE watchdog โ€” $(date -u +%FT%TZ) =====" >> "$LOG"
123
135
  /usr/local/bin/node scripts/release-convergence-watchdog.mjs --dispatch >> "$LOG" 2>&1 \
124
136
  || echo "[release-watchdog] exited non-zero โ€” see above; nightly continues" >> "$LOG"
@@ -428,8 +428,39 @@ function wiringSurvey() {
428
428
  function sessionHookExists() {
429
429
  return fs.existsSync(path.join(CONSOLE_ROOT, '.claude/hooks/agentdb-ensure.sh')) || fs.existsSync(path.join(CONSOLE_ROOT, '.claude/hooks'));
430
430
  }
431
+ // WHICH FILE IS THE PROJECT'S MEMORY STORE IS NOT A CONSTANT (issue #127).
432
+ //
433
+ // This probed only `.swarm/memory.db`. A reporter on ruflo 3.34.0 measured their live store โ€” 31MB
434
+ // with a fresh project-state checkpoint โ€” sitting in `.swarm/agentdb-memory.db`, so the card scored
435
+ // the project on an empty file and reported "no project-state checkpoint found" and "liveness: 1
436
+ // entries" about a store that had neither problem. The card's own remedy could not fix it, because
437
+ // the data was never missing; the probe was looking elsewhere.
438
+ //
439
+ // I could NOT reproduce their attribution on this machine: same ruflo 3.34.0, both with and without
440
+ // an explicit --path, a fresh `ruflo memory store` lands in `.swarm/memory.db` (proven by exact-key
441
+ // SQL against both files). So which name the CLI picks is evidently config- or environment-
442
+ // dependent, and hard-coding EITHER name is how this breaks again on the next machine.
443
+ //
444
+ // So the probe stops guessing and asks the filesystem: consider both known names, and use whichever
445
+ // actually holds rows. That is correct on their machine and on mine without knowing why they differ.
446
+ const MEMORY_DB_NAMES = ['memory.db', 'agentdb-memory.db'];
447
+ export function resolveMemoryDb(projectDir) {
448
+ const candidates = MEMORY_DB_NAMES
449
+ .map((name) => path.join(projectDir, '.swarm', name))
450
+ .filter((file) => fs.existsSync(file));
451
+ if (!candidates.length) return path.join(projectDir, '.swarm/memory.db'); // canonical name for the "absent" message
452
+ if (candidates.length === 1) return candidates[0];
453
+ // Both present: prefer the one with real content. Size is a proxy for rows that costs no query and
454
+ // cannot throw on a locked or WAL-mode database โ€” this runs inside a UI probe that must never hang.
455
+ return candidates.sort((a, b) => {
456
+ const sa = (() => { try { return fs.statSync(a).size; } catch { return 0; } })();
457
+ const sb = (() => { try { return fs.statSync(b).size; } catch { return 0; } })();
458
+ return sb - sa;
459
+ })[0];
460
+ }
461
+
431
462
  function probeMemory(projectDir) {
432
- const db = path.join(projectDir, '.swarm/memory.db');
463
+ const db = resolveMemoryDb(projectDir);
433
464
  const probes = {};
434
465
  // compaction survival + session surfacing are filesystem facts, always checkable
435
466
  const snapshot = inspectSessionSnapshots(projectDir);
@@ -1633,7 +1664,10 @@ function findMemoryStores(root) {
1633
1664
  const p = path.join(dir, e.name);
1634
1665
  if (VENDOR.some((m) => (p + '/').includes(m))) continue;
1635
1666
  if (e.name === '.swarm') {
1636
- if (fs.existsSync(path.join(p, 'memory.db'))) out.push({ project: dir, db: path.join(p, 'memory.db') });
1667
+ // Same resolution as probeMemory โ€” the fleet walk had the identical hardcoded assumption (#127).
1668
+ const resolved = ['memory.db', 'agentdb-memory.db'].map((n) => path.join(p, n)).filter((f) => fs.existsSync(f))
1669
+ .sort((a, b) => { const sz = (f) => { try { return fs.statSync(f).size; } catch { return 0; } }; return sz(b) - sz(a); })[0];
1670
+ if (resolved) out.push({ project: dir, db: resolved });
1637
1671
  continue;
1638
1672
  }
1639
1673
  if (e.name.startsWith('.') || e.name === 'node_modules') continue;
@@ -216,7 +216,21 @@ export function liveReleaseProvider({
216
216
  const pending = [];
217
217
  for (const { receipt, release } of latestByTransaction.values()) {
218
218
  if (TERMINAL_STATES.has(receipt.state)) continue;
219
- const settled = receipt.schemaVersion === 1 && release.draft === false
219
+ // CONVERGENCE IS A FACT ABOUT THE CHANNELS, NOT ABOUT THE RECEIPT FORMAT (fixed 2026-08-07).
220
+ //
221
+ // This required `receipt.schemaVersion === 1`. Receipts are written as schemaVersion 2
222
+ // (release-transaction.mjs stateReceipt), so NO current transaction could ever be recognised
223
+ // as settled โ€” it stayed `pending` forever and blocked every later release, which is the
224
+ // same deadlock shape as `aborted` being unreachable. It bit immediately: 4.0.24 published
225
+ // successfully to both channels, failed only on its final ledger entry, and then blocked
226
+ // 4.0.27 with `pending release 064b6b4eโ€ฆ blocks โ€ฆ`.
227
+ //
228
+ // The three conditions below are what actually prove convergence: the release is published,
229
+ // and this transaction's exact version and tag are what npm and GitHub currently serve. If
230
+ // all three hold, the transaction reached its goal whatever schema its receipts use. The
231
+ // check is not weakened โ€” the schema clause was never load-bearing for that question, it
232
+ // just silently expired when the schema moved on.
233
+ const settled = release.draft === false
220
234
  && receipt.identity?.version === npmLatest && receipt.identity?.tag === githubLatest;
221
235
  if (settled) legacySettled.push(receipt.transactionId);
222
236
  else pending.push(receipt);
@@ -36,7 +36,15 @@ export const ALLOWED_TRANSITIONS = Object.freeze({
36
36
  'github-promote-intent': ['github-promoted-nonlatest', 'manual-intervention-required', ...ABORTABLE],
37
37
  'github-promoted-nonlatest': ['npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
38
38
  'npm-promote-intent': ['npm-promoted', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
39
- 'npm-promoted': ['github-latest-intent', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
39
+ // `defaults-promoted` added 2026-08-07: when GitHub is ALREADY latest at the moment npm is
40
+ // promoted, the reducer correctly chooses `finalize` and the finalize path moves straight to
41
+ // `defaults-promoted` (release-transaction.mjs:406) โ€” there is no `github-latest-intent` to pass
42
+ // through, because there is nothing left to intend. The table assumed that hop was mandatory, so
43
+ // the 4.0.24 publish promoted BOTH channels successfully and then died on its own bookkeeping
44
+ // with `illegal release transition npm-promoted -> defaults-promoted`. The publication was real;
45
+ // only the ledger entry was refused. Both defaults genuinely are promoted in that state, so this
46
+ // records what happened rather than permitting anything new.
47
+ 'npm-promoted': ['github-latest-intent', 'defaults-promoted', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
40
48
  'github-latest-intent': ['defaults-promoted', 'compensation-intent', 'manual-intervention-required', ...ABORTABLE],
41
49
  'compensation-intent': ['compensated', 'manual-intervention-required', ...ABORTABLE],
42
50
  compensated: ['github-promote-intent', 'npm-promote-intent', 'manual-intervention-required', ...ABORTABLE],
@@ -408,7 +416,23 @@ export async function runReleaseTransaction({ identity, assets, adapter, private
408
416
  }
409
417
  await transition('finalize-intent');
410
418
  const final = await adapter.finalize(identity, current, hostVerifier);
411
- if (final.verdict !== 'PASS') throw new Error('final release convergence failed');
419
+ // SAY WHICH CONVERGENCE FAILED, AND WHY (2026-08-07). This threw the bare sentence
420
+ // `final release convergence failed` while `finalize` had already returned exactly the reason
421
+ // โ€” hostVerifierError, hosts.verifier.error, publicationError or sealError โ€” and the throw
422
+ // discarded every one of them. That is the same defect that let `spawnSync ENOBUFS` masquerade
423
+ // as `staged GitHub payload mismatch` for three days: an error that reports its category and
424
+ // withholds its evidence. Publication had ALREADY succeeded on both channels here, so the
425
+ // operator is reading this line while npm and GitHub are correct, with nothing to act on.
426
+ if (final.verdict !== 'PASS') {
427
+ const why = final.hostVerifierError
428
+ || final.hosts?.verifier?.error
429
+ || final.publicationError
430
+ || final.sealError
431
+ || `verdict=${final.verdict ?? '(none)'}`;
432
+ const fixtures = final.hosts?.verifier?.fixtures;
433
+ const detail = fixtures ? ` | fixtures: ${JSON.stringify(fixtures).slice(0, 400)}` : '';
434
+ throw new Error(`final release convergence failed: ${why}${detail}`);
435
+ }
412
436
  const reobserved = await adapter.observeSnapshot(identity, draft, { forceAssets: true });
413
437
  if (!(npmLatestIsB(reobserved, identity) && reobserved.github?.latest && githubIsB(reobserved, identity))) {
414
438
  throw new Error('provider defaults drifted during finalization');
@@ -55,6 +55,20 @@ export const CLAUDE_TIERS = {
55
55
  'claude-sonnet-5': { in: 2.0, out: 10.0 },
56
56
  'claude-opus-4.8': { in: 5.0, out: 25.0 },
57
57
  'claude-fable-5': { in: 10.0, out: 50.0 },
58
+ // OPUS 5 ADDED 2026-08-08, and its absence was silently costing every receipt.
59
+ //
60
+ // dispatch-receipt refuses to price an unknown model ("refusing to invent savings"), which is the
61
+ // right call โ€” but it means a session running on a model this table does not know can NEVER record
62
+ // a dispatch. Opus 5 is the current main-loop model, so every subagent routed from one of those
63
+ // sessions produced no receipt at all, and `/savings` read as "routing stopped" when what actually
64
+ // stopped was RECORDING. That is why the ledger sat unchanged for 14 days.
65
+ //
66
+ // Prices verified LIVE against the OpenRouter /models API on 2026-08-08, per this file's own
67
+ // standing rule โ€” never recalled, never inferred from the 4.8 row:
68
+ // anthropic/claude-opus-5 in $5.00/Mtok out $25.00/Mtok
69
+ // anthropic/claude-opus-5-fast in $10.00/Mtok out $50.00/Mtok
70
+ 'claude-opus-5': { in: 5.0, out: 25.0 },
71
+ 'claude-opus-5-fast': { in: 10.0, out: 50.0 },
58
72
  };
59
73
 
60
74
  /** Price lookup across both tables. Unknown model โ†’ null (never invent a savings number). */