ruvnet-brain 4.0.8 → 4.0.12

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.8 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.0.8-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.12 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.0.12-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
 
package/bin/install.mjs CHANGED
@@ -24,7 +24,10 @@ import {
24
24
  requiredEmbedderModels,
25
25
  missingEmbedderModels,
26
26
  } from '../kb/model-requirements.mjs';
27
- import { mergeManagedCatalog } from '../scripts/model-router-catalog.mjs';
27
+ import { applyManagedCatalogUpdate } from '../scripts/model-router-catalog.mjs';
28
+ import {
29
+ CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
30
+ } from '../scripts/console-runtime-identity.mjs';
28
31
 
29
32
  // SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
30
33
  // trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
@@ -613,22 +616,17 @@ export function beginConsoleRuntimeTransaction(cacheDir, sourceRoot = REPO_ROOT)
613
616
  fs.rmSync(prior, { recursive: true, force: true });
614
617
  fs.mkdirSync(staged, { recursive: true, mode: 0o700 });
615
618
 
616
- const required = [
617
- ['console', 'console'],
618
- ['scripts', 'scripts'],
619
- ['plugin/scripts', 'plugin/scripts'],
620
- ['data/model-catalog.json', 'data/model-catalog.json'],
621
- ['kb/brain-profile.mjs', 'kb/brain-profile.mjs'],
622
- ['bin/install.mjs', 'bin/install.mjs'],
623
- ['package.json', 'package.json'],
624
- ];
625
- for (const [from, to] of required) {
626
- const source = path.join(sourceRoot, from);
619
+ // The copy list IS the generation's digest input (scripts/console-runtime-identity.mjs). Adding a
620
+ // file the runtime needs adds it to the runtime's identity in the same edit, so an asset can never
621
+ // again travel separately from the executable that reads it (#76) and a candidate can never again
622
+ // be indistinguishable from the one it replaces (#79).
623
+ for (const relative of CONSOLE_RUNTIME_SURFACE) {
624
+ const source = path.join(sourceRoot, relative);
627
625
  if (!fs.existsSync(source)) {
628
626
  fs.rmSync(staged, { recursive: true, force: true });
629
- throw new Error(`console runtime is incomplete: missing ${from}`);
627
+ throw new Error(`console runtime is incomplete: missing ${relative}`);
630
628
  }
631
- const target = path.join(staged, to);
629
+ const target = path.join(staged, relative);
632
630
  fs.mkdirSync(path.dirname(target), { recursive: true });
633
631
  fs.cpSync(source, target, { recursive: true, force: true, preserveTimestamps: true });
634
632
  }
@@ -661,15 +659,28 @@ export function beginConsoleRuntimeTransaction(cacheDir, sourceRoot = REPO_ROOT)
661
659
  throw new Error(`console runtime failed syntax verification: ${path.relative(staged, syntaxTarget)}`);
662
660
  }
663
661
  }
662
+ // #76: prove the installed What's New boundary from the staged bytes, not from a file listing. The
663
+ // executable exits nonzero when its version manifest or curated notes are absent, so a runtime that
664
+ // would have to tell the user "the notes are unavailable" never activates in the first place.
665
+ const stagedWhatsNew = path.join(staged, 'plugin', 'scripts', 'whats-new.mjs');
666
+ const whatsNew = spawnSync(process.execPath, [stagedWhatsNew], { encoding: 'utf8' });
667
+ if (whatsNew.error || whatsNew.status !== 0) {
668
+ const detail = (whatsNew.stderr || whatsNew.error?.message || `exit ${whatsNew.status}`).trim();
669
+ fs.rmSync(staged, { recursive: true, force: true });
670
+ throw new Error(`console runtime cannot report its own release notes: ${detail}`);
671
+ }
664
672
  const identity = {
665
673
  product: 'ruvnet-brain-console-runtime',
666
674
  schema: 1,
667
675
  apiContract: 1,
668
676
  runtimeVersion: packageManifest.version,
669
677
  entrypoint: 'scripts/onboarding-console.mjs',
670
- sourceSha256: crypto.createHash('sha256').update(fs.readFileSync(stagedEntry)).digest('hex'),
678
+ // The WHOLE runtime, not just its entrypoint (#79). The launcher compares this against a live
679
+ // server's self-reported identity, so it has to change whenever anything the server executes or
680
+ // serves changes — otherwise a stale process passes as current.
681
+ sourceSha256: consoleRuntimeDigest(staged),
671
682
  };
672
- fs.writeFileSync(path.join(staged, 'runtime-identity.json'), `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 });
683
+ fs.writeFileSync(path.join(staged, CONSOLE_RUNTIME_IDENTITY_FILE), `${JSON.stringify(identity, null, 2)}\n`, { mode: 0o600 });
673
684
 
674
685
  let state = 'staged';
675
686
  const entry = path.join(runtime, identity.entrypoint);
@@ -2230,6 +2241,37 @@ const xmlEscape = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').
2230
2241
  const cronExample = (kbDir) =>
2231
2242
  `47 3 * * * cd ${kbDir} && ${process.execPath} forge-update.mjs --apply >> ${kbDir}/update.log 2>&1`;
2232
2243
 
2244
+ /**
2245
+ * kb/forge-update.mjs exit 11 = "the download completed but the bundle on disk did NOT change"
2246
+ * (its `EXIT_NOT_LANDED`). Duplicated as a literal on purpose: the updater lives in the KB BUNDLE,
2247
+ * which versions and ships independently of this installer, so there is no import to share. The
2248
+ * two are pinned together by tests/unit/update-not-landed-exit.test.mjs, which reads the constant
2249
+ * out of kb/forge-update.mjs and fails if they ever drift.
2250
+ */
2251
+ export const UPDATE_NOT_LANDED = 11;
2252
+
2253
+ /**
2254
+ * What `--update` does with the KB updater's exit code (issue #106).
2255
+ *
2256
+ * `--update` used to convert an honest failure into a reported success. The updater detected the
2257
+ * problem itself and said so — "UPDATE MISMATCH: SOURCE.json on disk is IDENTICAL to before the
2258
+ * update … REFUSING to report success" — and then the fresh-install FALLBACK below ran, succeeded
2259
+ * at re-installing the very same bytes, and its exit 0 became the exit code of the whole command.
2260
+ * A user's scheduled job read that as success while the corpus had not moved.
2261
+ *
2262
+ * The fallback exists for ONE thing: an old bundle whose canonical manifest URL 404s, where a fresh
2263
+ * install genuinely rescues the user. "Nothing landed" is not that case — it is a TRUE verdict
2264
+ * about an intact KB, and re-downloading the same bundle cannot change it. So that verdict is
2265
+ * terminal and keeps its own exit code; every other failure keeps today's fallback behaviour.
2266
+ */
2267
+ export function classifyUpdaterExit(status, { fallbackAllowed = true } = {}) {
2268
+ if (status === 0) return { verdict: 'updated', fallback: false, exitCode: 0 };
2269
+ if (status === UPDATE_NOT_LANDED) {
2270
+ return { verdict: 'not-landed', fallback: false, exitCode: UPDATE_NOT_LANDED };
2271
+ }
2272
+ return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
2273
+ }
2274
+
2233
2275
  function missingUpdaterHelp(kbDir) {
2234
2276
  console.error(`\n${c.red('✗ can\'t update:')} ${c.bold('forge-update.mjs')} is missing from ${kbDir}.`);
2235
2277
  console.error(` Either no brain is installed there, or the bundle predates the self-updater.`);
@@ -2366,12 +2408,29 @@ function runUpdate() {
2366
2408
  // real user, Jan Lafko, hit). NEVER leave the user stranded at a 404: re-run THIS installer as a
2367
2409
  // fresh install, which pulls the latest Release DIRECTLY (releases/latest) and never touches the
2368
2410
  // manifest — so --update always succeeds and self-heals the stale SOURCE.json in one shot.
2369
- if (updateStatus !== 0 && !process.env.RUVNET_BRAIN_NO_UPDATE_FALLBACK) {
2411
+ //
2412
+ // Scoped again 2026-08-04 (issue #106): "nothing landed" is excluded, because for THAT verdict the
2413
+ // fallback re-installed identical bytes and its exit 0 became the command's exit code, turning the
2414
+ // updater's own correct refusal into a reported success.
2415
+ const outcome = classifyUpdaterExit(updateStatus, {
2416
+ fallbackAllowed: !process.env.RUVNET_BRAIN_NO_UPDATE_FALLBACK,
2417
+ });
2418
+ if (outcome.verdict === 'not-landed') {
2419
+ console.error(`\n ${c.red('✗ the knowledge bundle did not change.')} The updater downloaded a bundle and refused to`);
2420
+ console.error(` call it an update because the copy on disk is identical to the one it replaced.`);
2421
+ console.error(` Nothing is broken and nothing was lost — but this run is ${c.bold('not')} a success, so it exits`);
2422
+ console.error(` ${c.bold(String(UPDATE_NOT_LANDED))} rather than 0 and a scheduled job will see the failure (issue #106).`);
2423
+ console.error(` If you believe a newer build exists, check: ${c.bold('node forge-update.mjs --check')} in ${kbDir}`);
2424
+ process.exit(outcome.exitCode);
2425
+ }
2426
+ if (outcome.fallback) {
2370
2427
  warn("\nthe bundle's own updater couldn't complete — falling back to a fresh install of the latest Release (this always works)…\n");
2371
2428
  const self = fileURLToPath(import.meta.url);
2372
2429
  const fr = spawnSync(process.execPath, [self, '--force'], { stdio: 'inherit',
2373
2430
  env: { ...process.env, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
2374
2431
  updateStatus = fr.error ? 1 : (fr.status === null ? 1 : fr.status);
2432
+ } else {
2433
+ updateStatus = outcome.exitCode;
2375
2434
  }
2376
2435
  }
2377
2436
  if (updateStatus === 0) {
@@ -2382,6 +2441,25 @@ function runUpdate() {
2382
2441
  updateStatus = 1;
2383
2442
  }
2384
2443
  }
2444
+ // Issue #87: a normal lifecycle update must also carry MANAGED MODEL additions to an existing
2445
+ // user. This call used to live only in offerRouterProfile() — the fresh-install path — so someone
2446
+ // who already had ~/.claude/model-router/catalog.json (i.e. every existing user, the only ones who
2447
+ // can be behind) never received a newly shipped managed candidate no matter how often they
2448
+ // updated. Additive and non-interactive; their overrides win, metered rows are never auto-enabled,
2449
+ // and a failure here is reported but never fails an otherwise-good update.
2450
+ if (updateStatus === 0) {
2451
+ try {
2452
+ const managed = applyManagedCatalogUpdate({
2453
+ routerDir: path.join(os.homedir(), '.claude', 'model-router'),
2454
+ packageRoot: REPO_ROOT,
2455
+ });
2456
+ if (managed.action === 'merged' && managed.added.length) {
2457
+ ok(`model router: added ${managed.added.length} managed model(s) — ${managed.added.join(', ')} (your catalog edits were preserved)`);
2458
+ }
2459
+ } catch (e) {
2460
+ warn(`managed model additions were not merged (${e.message}); your catalog was left unchanged`);
2461
+ }
2462
+ }
2385
2463
  // `--update --auto` = update now AND enroll in Evergreen, so this is the LAST time it's ever run by
2386
2464
  // hand. Only enroll if the update itself succeeded — never promise "you're set forever" on a failed
2387
2465
  // update. enableNightly() prints its own real verification (plist path + launchctl result).
@@ -2975,31 +3053,20 @@ export async function offerRouterProfile() {
2975
3053
  );
2976
3054
 
2977
3055
  fs.mkdirSync(path.join(routerDir, 'bin'), { recursive: true });
2978
- for (const [src, dst] of [['catalog.template.json', 'catalog.json'], ['policy.default.mjs', 'policy.default.mjs']]) {
2979
- const s = path.join(pkgRoot, 'config', 'model-router', src);
2980
- const d = path.join(routerDir, dst);
2981
- if (!fs.existsSync(s)) continue;
2982
- if (!fs.existsSync(d)) {
2983
- fs.copyFileSync(s, d);
2984
- ok(`installed ${dst} (edit freely — goldie keeps prices fresh where scheduled)`);
2985
- continue;
2986
- }
2987
- if (src === 'catalog.template.json') {
2988
- try {
2989
- const existing = JSON.parse(fs.readFileSync(d, 'utf8'));
2990
- const managed = JSON.parse(fs.readFileSync(s, 'utf8'));
2991
- const merged = mergeManagedCatalog(existing, managed);
2992
- if (merged !== existing) {
2993
- fs.copyFileSync(d, `${d}.pre-managed-merge`);
2994
- const tmp = `${d}.tmp-${process.pid}`;
2995
- fs.writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
2996
- fs.renameSync(tmp, d);
2997
- ok(`merged ${merged.candidates.length - existing.candidates.length} managed subscription model(s); your catalog overrides were preserved`);
2998
- }
2999
- } catch (error) {
3000
- warn(`managed model additions were not merged (${error.message}); your existing catalog was left unchanged`);
3001
- }
3002
- }
3056
+ const policySrc = path.join(pkgRoot, 'config', 'model-router', 'policy.default.mjs');
3057
+ const policyDst = path.join(routerDir, 'policy.default.mjs');
3058
+ if (fs.existsSync(policySrc) && !fs.existsSync(policyDst)) {
3059
+ fs.copyFileSync(policySrc, policyDst);
3060
+ ok('installed policy.default.mjs (edit freely — goldie keeps prices fresh where scheduled)');
3061
+ }
3062
+ // ONE implementation of the managed-catalog step, shared with runUpdate() (issue #87). It used to
3063
+ // live here only, which is exactly why existing users never received managed additions.
3064
+ try {
3065
+ const managed = applyManagedCatalogUpdate({ routerDir, packageRoot: pkgRoot });
3066
+ if (managed.action === 'created') ok('installed catalog.json (edit freely — goldie keeps prices fresh where scheduled)');
3067
+ if (managed.action === 'merged') ok(`merged ${managed.added.length} managed subscription model(s); your catalog overrides were preserved`);
3068
+ } catch (error) {
3069
+ warn(`managed model additions were not merged (${error.message}); your existing catalog was left unchanged`);
3003
3070
  }
3004
3071
  let copied = 0;
3005
3072
  // dispatch-receipt + metaharness-receipts added 2026-07-13: without the LOGGER, subagent routing is
package/console/app.js CHANGED
@@ -2313,6 +2313,25 @@ function renderDistribution(u) {
2313
2313
  el('p', { class: 'fineprint' }, u.note));
2314
2314
  }
2315
2315
 
2316
+ // One provider's key chip. THREE states, never two: found, not found, and NOT CHECKED. The third
2317
+ // exists because an instrument that could not run has found nothing — it has not found "no key"
2318
+ // (issue #86). Pure and total, so the not-checked branch cannot be reached by accident.
2319
+ function providerKeyChip(id, keys, keysVerified, names) {
2320
+ const name = names[id] || id;
2321
+ if (!keysVerified) {
2322
+ return el('span', {
2323
+ class: 'plan-key unknown',
2324
+ title: `Not checked: Brain could not load its provider catalog, so it cannot tell whether an API key for ${name} is set on this machine`,
2325
+ }, el('span', { class: 'plan-key-mark', 'aria-hidden': 'true' }, '?'), ` ${name}`);
2326
+ }
2327
+ const found = !!keys[id];
2328
+ return el('span', {
2329
+ class: `plan-key ${found ? 'yes' : 'no'}`,
2330
+ title: found ? `An API key for ${name} is set on this machine`
2331
+ : `No API key for ${name} found on this machine`,
2332
+ }, el('span', { class: 'plan-key-mark', 'aria-hidden': 'true' }, found ? '✓' : '✗'), ` ${name}`);
2333
+ }
2334
+
2316
2335
  // Plan block (issue #24, applying sparkling's #21 redesign) — three genuinely different questions
2317
2336
  // used to render as one flat row of identical "chips": which subscription this runs on ("house" — a
2318
2337
  // word nobody outside the source knows), whether cheap-task routing is on, and which OTHER API keys
@@ -2339,19 +2358,21 @@ function renderProviders(sv) {
2339
2358
 
2340
2359
  // BOX 1 — YOUR PLAN. The one choice here that changes cost: which subscription MetaHarness treats
2341
2360
  // as $0. Footer checklist: which OTHER providers have a real API key on this machine — honest now.
2361
+ //
2362
+ // Issue #86: `keys` is only a MEASUREMENT while the verified provider catalog actually loaded. When
2363
+ // that asset was missing from the packaged runtime, the server fell back to native detections and
2364
+ // said so in providerCatalog — and this checklist ignored it, printing a confident "✗ … No API key
2365
+ // for Gemini found on this machine" straight off `keys`. That turned an internal packaging failure
2366
+ // into a stated fact about the user's credentials. Not-checked is now its own third state.
2367
+ const catalogHealth = re.providerCatalog || null;
2368
+ const keysVerified = !catalogHealth
2369
+ || (catalogHealth.keysVerified !== false && catalogHealth.status !== 'degraded');
2342
2370
  const others = ['anthropic', 'openai', 'google', 'xai'].filter((id) => id !== house.provider);
2343
2371
  const checklist = el('div', { class: 'plan-keys' },
2344
- el('span', { class: 'plan-keys-lab' }, 'Other keys found:'),
2345
- ...others.map((id) => {
2346
- const ok = !!keys[id];
2347
- return el('span', {
2348
- class: `plan-key ${ok ? 'yes' : 'no'}`,
2349
- title: ok ? `An API key for ${KEY_NAME[id]} is set on this machine`
2350
- : `No API key for ${KEY_NAME[id]} found on this machine`,
2351
- },
2352
- el('span', { class: 'plan-key-mark', 'aria-hidden': 'true' }, ok ? '✓' : '✗'),
2353
- ` ${KEY_NAME[id]}`);
2354
- }));
2372
+ el('span', { class: 'plan-keys-lab' }, keysVerified ? 'Other keys found:' : 'Other keys:'),
2373
+ ...others.map((id) => providerKeyChip(id, keys, keysVerified, KEY_NAME)),
2374
+ ...(keysVerified ? [] : [el('span', { class: 'plan-keys-note' },
2375
+ `Not checked — Brain could not load its provider catalog${catalogHealth && catalogHealth.detail ? ` (${catalogHealth.detail})` : ''}.`)]));
2355
2376
  const planBox = el('div', { class: 'plan-box' },
2356
2377
  el('span', { class: 'plan-label' }, 'Your plan', infoBtn('Your plan and OpenRouter', PROVIDERS_INFO)),
2357
2378
  head('is-house', houseName, action('Change', 'provider')),
package/console/style.css CHANGED
@@ -1236,6 +1236,11 @@ details.sub .sub-body > *:first-child { margin-top: 4px; }
1236
1236
  .plan-key-mark { font-weight: 700; }
1237
1237
  .plan-key.yes .plan-key-mark { color: var(--green-text); }
1238
1238
  .plan-key.no, .plan-key.no .plan-key-mark { color: var(--faint); }
1239
+ /* Not checked (issue #86): visually distinct from "no key found" so a degraded catalog never reads
1240
+ as a finding about the user's machine. */
1241
+ .plan-key.unknown { color: var(--muted); }
1242
+ .plan-key.unknown .plan-key-mark { color: var(--amber); }
1243
+ .plan-keys-note { flex-basis: 100%; color: var(--muted); font-size: 11.5px; line-height: 1.5; }
1239
1244
 
1240
1245
  /* WP2b — first-run empty state: confident, designed, not an apology */
1241
1246
  .mh-empty {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.0.8",
3
+ "version": "4.0.12",
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 69 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.8",
4
+ "version": "4.0.12",
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.8",
3
+ "version": "4.0.12",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -160,7 +160,47 @@ const nowMs = Date.now();
160
160
  // force. This closes the empty-stdin hole that LOOP-SAFETY 1's `__source` check does not cover.
161
161
  if (!hookInput.session_id) process.exit(EXIT_ALLOW);
162
162
 
163
- const open = led.items.filter((i) => !i.done);
163
+ /**
164
+ * ARTIFACT-DERIVED OPEN WORK — the half that cannot be forgotten.
165
+ *
166
+ * WHY THIS EXISTS, measured rather than supposed. On 2026-08-04 the owner asked why the model had
167
+ * gone back to stopping early, and the ledger answered: 25 items, ZERO open, last written 2026-07-25.
168
+ * The gate had been structurally silent for ten days. Not broken — starved.
169
+ *
170
+ * The cause is the design, not the drift. Until now the ONLY source of "is work outstanding" was
171
+ * `--commit-to`, i.e. the model noticing its own commitment and recording it. So the guard against
172
+ * the model stopping early depended on the model remembering to arm it, and the failure mode is
173
+ * silent in exactly the sessions where it matters most. That is this project's oldest rule broken
174
+ * inside the mechanism meant to enforce it: status must be DERIVED FROM A VERIFIABLE ARTIFACT,
175
+ * never asserted.
176
+ *
177
+ * So the gate now also reads work that exists whether or not anyone remembered to write it down.
178
+ * `open-issues.json` is produced by the issue-watch pipeline against the real repo; an issue past
179
+ * its response SLA is outstanding work by definition, and no amount of forgetting can erase it.
180
+ *
181
+ * Deliberately narrow: ONLY SLA breaches, never the full backlog — a permanently non-empty backlog
182
+ * would make this fire forever, which is nagging, not enforcement. And only a FRESH observation
183
+ * (<6h, the same window session-start-core uses), because a stale file is not evidence of anything.
184
+ */
185
+ function artifactOpenWork() {
186
+ try {
187
+ const file = process.env.RUVNET_OPEN_ISSUES_FILE
188
+ || path.join(HOME, '.cache', 'ruvnet-brain', 'open-issues.json');
189
+ const status = JSON.parse(fs.readFileSync(file, 'utf8'));
190
+ const observedAt = Date.parse(status?.at || '');
191
+ if (!Number.isFinite(observedAt) || nowMs - observedAt > 6 * 3600_000) return [];
192
+ return (Array.isArray(status.issues) ? status.issues : [])
193
+ .filter((issue) => issue?.breach)
194
+ .map((issue) => ({
195
+ text: `issue #${issue.number} on ${status.repo} is ${issue.ageHours}h past its response SLA — ${String(issue.title || '').slice(0, 80)}`,
196
+ done: false,
197
+ at: new Date(observedAt).toISOString(),
198
+ derived: true,
199
+ }));
200
+ } catch { return []; }
201
+ }
202
+
203
+ const open = [...led.items.filter((i) => !i.done), ...artifactOpenWork()];
164
204
  if (!open.length) process.exit(EXIT_ALLOW); // nothing outstanding: silence is correct
165
205
 
166
206
  /**
@@ -14,6 +14,13 @@ import path from 'node:path';
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { readStdinBounded } from './hook-input.mjs';
16
16
  import { loadRuntimePreferences } from './runtime-preferences.mjs';
17
+ import { resolveRuflo, RUFLO_MISSING } from './ruflo-bin.mjs';
18
+
19
+ // ONE BOUNDED LINE ON STDERR. stderr because a SessionEnd hook's stdout is not surfaced, and bounded
20
+ // because a hook that prints a stack trace on every `/clear` gets muted — and a muted diagnostic is
21
+ // no diagnostic at all (the same lesson as the session-start line that reported its own defect every
22
+ // session for eight days and went unread).
23
+ const warn = (msg) => { try { process.stderr.write(`learn-flush: ${msg}\n`); } catch { /* stderr gone */ } };
17
24
 
18
25
  const HOME = os.homedir();
19
26
  const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || process.cwd();
@@ -48,7 +55,11 @@ const QUEUE_ROOT = LEARNING_SCOPE === 'user'
48
55
  ? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
49
56
  : path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
50
57
  const QUEUE = process.env.LEARN_QUEUE || path.join(QUEUE_ROOT, `session-${SID}.jsonl`);
51
- const RUFLO = path.join(HOME, '.npm-global/bin/ruflo');
58
+ // Issue #105: this was a hardcoded `path.join(HOME, '.npm-global/bin/ruflo')` — the owner's npm
59
+ // prefix. On any other prefix (Homebrew, nvm, Volta, plain `npm -g`) the path simply did not exist,
60
+ // every feed below threw ENOENT, and every throw landed in a `catch {}` that said nothing. One
61
+ // resolver, shared with distill-project.mjs and health-repair.mjs's original — see ruflo-bin.mjs.
62
+ const RUFLO = resolveRuflo();
52
63
  const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
53
64
  const MAX_ACTIONS = 8; // bound the work so SessionEnd stays fast
54
65
 
@@ -98,7 +109,16 @@ for (const line of lines) {
98
109
  const actions = allDistinct.slice(0, MAX_ACTIONS);
99
110
  const deferred = allDistinct.slice(MAX_ACTIONS);
100
111
 
112
+ // ruflo is genuinely not on this machine. SAY SO — once — and keep the queue. Exiting 0 keeps the
113
+ // best-effort contract (an absent optional learner must never break SessionEnd); saying nothing at
114
+ // all is what turned #105 into eight invisible ENOENTs and a queue that never drained.
115
+ if (!RUFLO && actions.length) {
116
+ warn(`0/${actions.length} fed — ${RUFLO_MISSING}. The queue is KEPT for retry.`);
117
+ process.exit(0);
118
+ }
119
+
101
120
  let fed = 0;
121
+ const failures = []; // WHY each feed failed — the thing `catch {}` used to destroy
102
122
  let stoppedAt = actions.length; // how far the feed actually got before the deadline
103
123
  for (let i = 0; i < actions.length; i++) {
104
124
  const remaining = DEADLINE - Date.now();
@@ -120,7 +140,22 @@ for (let i = 0; i < actions.length; i++) {
120
140
  timeout: Math.min(6000, remaining),
121
141
  });
122
142
  fed++;
123
- } catch { /* best-effort — one slow/failed record must not stall session end */ }
143
+ } catch (e) {
144
+ // BEST-EFFORT, NOT SILENT. The old `catch { /* best-effort */ }` swallowed the reason, so a
145
+ // machine where every call failed looked exactly like one where every call worked: exit 0,
146
+ // no output, and the only trace a queue that never shrank. "Reports success while doing
147
+ // nothing" is the defect class this project treats as the worst thing it can ship. Keep going
148
+ // (one bad record must not stall session end), but keep the reason.
149
+ failures.push(String(e?.message || e).split('\n')[0].slice(0, 120));
150
+ }
151
+ }
152
+ // SURFACE IT. Distinct reasons only, at most two: eight copies of the same ENOENT is noise, and the
153
+ // second distinct reason is usually where the real information is.
154
+ if (failures.length) {
155
+ const distinct = [...new Set(failures)];
156
+ warn(`${failures.length}/${actions.length} feed call(s) FAILED via ${RUFLO}`
157
+ + ` — ${distinct.slice(0, 2).join(' | ')}${distinct.length > 2 ? ` (+${distinct.length - 2} more kind(s))` : ''}`
158
+ + (fed === 0 ? '. Nothing was learned; the queue is KEPT for retry.' : `. ${fed} succeeded.`));
124
159
  }
125
160
  // Whatever the deadline cut off is WORK, not waste: it goes back on the front of the queue so the
126
161
  // next flush continues from there. Dropping it would turn a time limit into the same silent data
@@ -0,0 +1,89 @@
1
+ // project-identity.mjs — ONE answer to "which directory is this, and have I seen it already?"
2
+ //
3
+ // WHY THIS EXISTS. Two user-filed bugs, #85 and #107, are the same defect: the product derives a
4
+ // project's location independently at each site and the sites then disagree.
5
+ //
6
+ // #85 The PreCompact producer wrote its receipt under `CLAUDE_PROJECT_DIR`; the Console probe
7
+ // that reports whether a receipt exists looked under `process.cwd()`. Launch the Console
8
+ // from a subdirectory of the project and it warns "no supported PreCompact snapshot found"
9
+ // about a snapshot it had itself just written. Nothing was broken except the agreement.
10
+ //
11
+ // #107 `candidateRoots()` returned BOTH `~/Code` and `~/code`, which on APFS are one directory
12
+ // with one inode. Every project under them was scanned, counted and summed twice, so the
13
+ // machine-wide memory total read exactly 2x — silently, confidently, in the one figure the
14
+ // Console exists to make trustworthy. `path.resolve()` was the guard, and it normalises
15
+ // `.`/`..` only: it case-folds nothing and resolves no symlinks, which the guard's own
16
+ // comment ("e.g. a symlink") shows it was believed to do.
17
+ //
18
+ // WHY DEVICE+INODE RATHER THAN LOWER-CASING. Lower-casing is a guess about the filesystem, and it
19
+ // is wrong on the machines that would suffer most: Linux, and case-sensitive APFS volumes, where
20
+ // `Code` and `code` really are two projects and folding them would DELETE one from the report.
21
+ // `st_dev` + `st_ino` is not a guess — it is the filesystem's own answer, correct in both
22
+ // directions on every volume, and it settles symlinks and bind mounts in the same stroke. The
23
+ // case-insensitivity probe the reporter suggested (write a temp file, stat the other spelling)
24
+ // would also work, but it writes to the user's disk to learn something `stat` already knows.
25
+ //
26
+ // `fs.realpathSync.native` is the OS canonicaliser: it resolves symlinks AND returns the case as
27
+ // stored on disk. Plain `fs.realpathSync` is a JavaScript reimplementation that does symlinks only
28
+ // — measured on this repo's own machine, `realpathSync('~/code')` stays `~/code` while
29
+ // `realpathSync.native('~/code')` returns `~/Code`. That one missing word is the whole of #107.
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+
33
+ const defaultRealpath = (value) => fs.realpathSync.native(value);
34
+ const defaultStat = (value) => fs.statSync(value, { bigint: true });
35
+
36
+ /** The operating system's own spelling of an existing path, or null when it cannot be resolved. */
37
+ export function canonicalPath(value, { realpath = defaultRealpath } = {}) {
38
+ if (typeof value !== 'string' || !value) return null;
39
+ try { return realpath(value); } catch { return null; }
40
+ }
41
+
42
+ /**
43
+ * A key that is equal for two names of one directory and different for two directories.
44
+ * Falls back to the canonical path when the volume reports no usable inode (some Windows and
45
+ * network mounts report 0) — never worse than the raw string compare it replaces.
46
+ */
47
+ export function pathIdentity(value, { realpath = defaultRealpath, stat = defaultStat } = {}) {
48
+ const canonical = canonicalPath(value, { realpath });
49
+ if (!canonical) return null;
50
+ try {
51
+ const info = stat(canonical);
52
+ if (info?.ino) return `${info.dev}:${info.ino}`;
53
+ } catch { /* unreadable — the canonical spelling is still a better key than the raw one */ }
54
+ return canonical;
55
+ }
56
+
57
+ /** True when both names denote the same existing directory or file. */
58
+ export function sameLocation(a, b, options = {}) {
59
+ const left = pathIdentity(a, options);
60
+ return left !== null && left === pathIdentity(b, options);
61
+ }
62
+
63
+ /** True when `child` is `root` or lies beneath it, compared canonically rather than by raw string. */
64
+ export function contains(root, child, options = {}) {
65
+ const canonicalRoot = canonicalPath(root, options);
66
+ const canonicalChild = canonicalPath(child, options);
67
+ if (!canonicalRoot || !canonicalChild) return false;
68
+ if (sameLocation(canonicalRoot, canonicalChild, options)) return true;
69
+ const relative = path.relative(canonicalRoot, canonicalChild);
70
+ return Boolean(relative) && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
71
+ }
72
+
73
+ /**
74
+ * THE project directory. The PreCompact snapshot producer and the Console probe that detects the
75
+ * snapshot both call this, so they cannot disagree about where the project is (#85).
76
+ *
77
+ * `CLAUDE_PROJECT_DIR` wins only when the current directory actually lies inside it. Containment,
78
+ * not mere presence, is what makes the two sides agree by construction: a hook and a Console
79
+ * launched anywhere within one project resolve to that project's root, while a caller that hands
80
+ * over an unrelated directory (a test fixture, an explicit `--project`) is never overruled by an
81
+ * environment variable it knows nothing about.
82
+ */
83
+ export function projectDirectory({ env = process.env, cwd = process.cwd(), ...options } = {}) {
84
+ const here = canonicalPath(cwd, options) || path.resolve(cwd);
85
+ const declared = typeof env.CLAUDE_PROJECT_DIR === 'string' && env.CLAUDE_PROJECT_DIR.trim()
86
+ ? canonicalPath(env.CLAUDE_PROJECT_DIR, options)
87
+ : null;
88
+ return declared && contains(declared, here, options) ? declared : here;
89
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * ruflo-bin.mjs — the ONE place this repo decides WHERE the global `ruflo` binary is.
3
+ *
4
+ * WHY THIS FILE EXISTS. Issues #99 and #105 are one defect filed twice: `scripts/distill-project.mjs`
5
+ * and `plugin/scripts/learn-flush.mjs` each hardcoded `~/.npm-global/bin/ruflo` — the owner's npm
6
+ * prefix, nobody else's. On a Homebrew, nvm, Volta, or plain `npm -g` install that path does not
7
+ * exist, so #99 died with "ruflo is not at ~/.npm-global/bin/ruflo" and #105 failed every feed
8
+ * silently — both on machines where ruflo was installed and sitting on PATH the whole time.
9
+ *
10
+ * `health-repair.mjs` had already fixed exactly this, and its header says why: "Telling someone their
11
+ * tool is missing when it is on their PATH is the product lying, and it is unfalsifiable from their
12
+ * side: they cannot see why we looked in one place." That fix lived as a private function inside one
13
+ * file, so its two siblings kept the bug — the identical shape ADR-021 was written about ("One class,
14
+ * fixed in one file, regenerated in the sibling"). So the resolver moves here instead of being pasted
15
+ * a third time.
16
+ *
17
+ * MECHANISM — the hardened form, not the first draft. Two resolvers already existed in this repo:
18
+ * • health-repair.mjs (2026-07-21) preferred path, then `sh -lc 'command -v ruflo'`
19
+ * • capability-registry.mjs (2026-07-22) preferred path, then a plain PATH walk, NO shell
20
+ * The second IS the first one, hardened, and it says so in place: `-l` sources the user's entire
21
+ * profile — every export, shim, and one-off line anyone has ever pasted into .profile — as the price
22
+ * of answering "where is ruflo?". Resolving a name against directories is all `command -v` was ever
23
+ * wanted for here, and it needs no shell at all. This module takes that version. Adopting the older
24
+ * mechanism would mean re-introducing, in a shared module, a bug the repo had already fixed one file
25
+ * over.
26
+ *
27
+ * ORDER, and each step earns its place:
28
+ * 1. RUFLO_BIN, if set, is AUTHORITATIVE — returned as given, whether or not it exists. An explicit
29
+ * override that quietly falls back to some other ruflo is not an override, and the caller's
30
+ * "not at <path>" message has to name the path the user actually asked for.
31
+ * 2. ~/.npm-global/bin/ruflo — Rule 21's ONE global binary, checked first so a machine that has it
32
+ * never depends on PATH ordering.
33
+ * 3. A PATH walk — the entire point of #99/#105: an installed ruflo living anywhere else.
34
+ * 4. null — "I could not find it", said plainly. Never guess a path and then blame the user for it.
35
+ *
36
+ * Rule 21 is untouched by this: still ONE ruflo, still the global one, never `npx ruflo@latest`. This
37
+ * resolves WHERE that one global binary is rather than assuming everyone's prefix matches the owner's.
38
+ */
39
+ import fs from 'node:fs';
40
+ import os from 'node:os';
41
+ import path from 'node:path';
42
+
43
+ /**
44
+ * Locate the global ruflo. LOCATES, NEVER EXECUTES — no shell, no daemon, no side effects.
45
+ *
46
+ * @param {{ env?: Record<string, string|undefined>, home?: string }} [opts]
47
+ * @returns {string|null} path to ruflo, or null when it is genuinely not on this machine.
48
+ */
49
+ export function resolveRuflo({ env = process.env, home = os.homedir() } = {}) {
50
+ if (env.RUFLO_BIN) return env.RUFLO_BIN;
51
+
52
+ // On Windows a global npm ruflo is `ruflo.cmd`; the extensionless sibling is a POSIX shell wrapper
53
+ // that Node cannot exec (and refuses to spawn without a shell since CVE-2024-27980).
54
+ const exts = process.platform === 'win32' ? ['.cmd', '.exe', ''] : [''];
55
+
56
+ // The preferred path needs that SAME rule. It first checked a bare `ruflo` only, so on Windows it
57
+ // either missed the real `ruflo.cmd` entirely or returned the POSIX wrapper the comment above
58
+ // says is unrunnable — and the caller then reported "ruflo is not at ~/.npm-global/bin/ruflo" to
59
+ // someone who had ruflo installed. That is the exact complaint #99 and #105 were filed about,
60
+ // reintroduced one platform over.
61
+ const preferredDir = path.join(home, '.npm-global', 'bin');
62
+ for (const ext of exts) {
63
+ const cand = path.join(preferredDir, `ruflo${ext}`);
64
+ try { if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; } catch { /* unreadable */ }
65
+ }
66
+ for (const dir of String(env.PATH || '').split(path.delimiter)) {
67
+ if (!dir) continue;
68
+ for (const ext of exts) {
69
+ const cand = path.join(dir, `ruflo${ext}`);
70
+ try { if (fs.existsSync(cand) && fs.statSync(cand).isFile()) return cand; } catch { /* unreadable PATH entry */ }
71
+ }
72
+ }
73
+ return null;
74
+ }
75
+
76
+ /**
77
+ * What to say when resolveRuflo() returns null. ONE wording, so every caller reports the same thing
78
+ * and names both places it looked — the diagnostic #99 and #105 never gave anyone.
79
+ */
80
+ export const RUFLO_MISSING = 'ruflo was not found in ~/.npm-global/bin or anywhere on your PATH'
81
+ + ' — install it with `npm i -g ruflo@latest`, or set RUFLO_BIN to its full path';
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { createSessionSnapshot } from './session-snapshot-contract.mjs';
4
+ import { projectDirectory } from './project-identity.mjs';
4
5
 
5
6
  function regularOrAbsent(file) {
6
7
  try {
@@ -30,5 +31,7 @@ export function writeSessionSnapshot(projectDir, event) {
30
31
  }
31
32
 
32
33
  if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
33
- writeSessionSnapshot(process.env.CLAUDE_PROJECT_DIR || process.cwd(), process.argv[2] || 'SessionEnd');
34
+ // projectDirectory() is the SAME derivation the Console's detector uses. Deriving it here
35
+ // independently is what let this hook write a receipt the Console then reported as missing (#85).
36
+ writeSessionSnapshot(projectDirectory(), process.argv[2] || 'SessionEnd');
34
37
  }