ruvnet-brain 3.6.1-dev β†’ 3.9.50-dev

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 3.6.1-dev β€” updated 2026-07-21 06:00 EDT](https://img.shields.io/badge/version_3.6.1--dev-updated_2026--07--21_06:00_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 3.9.50-dev β€” updated 2026-07-23 04:06 EDT](https://img.shields.io/badge/version_3.9.50--dev-updated_2026--07--23_04:06_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
 
@@ -56,7 +56,70 @@
56
56
 
57
57
  ---
58
58
 
59
- ## What's new in 3.6 β€” it can finally show you what you own
59
+ ## What's new in 3.9 β€” it anticipates, and it learns whether it was right
60
+
61
+ **Building toward L4/L5 (3.9.x, dev).** The mechanisms for the top two rungs of the proactivity
62
+ ladder are built and wired β€” but they are **not yet verified to 4.0's bar**, which requires all five
63
+ of ADR-028's test classes green under independent grading. Until then, [ADR-028](docs/adr/0028-what-proactive-means.md)
64
+ grades the shipped, *verified* state at **L2–L3**, and `-dev` is the honest version. 4.0 is claimed
65
+ only when L3+L4+L5 are each measured, not when the code merely exists.
66
+
67
+ - **L4 Anticipatory (mechanism built).** It infers what you're *trying to do* and names the capability
68
+ that serves that goal β€” before you hit the wall. Built to stay quiet: a match needs **two
69
+ independent cues** (the problem AND that the topic is your AI workflow), so near-misses get
70
+ silence. Measured on the negative table: **0 false positives**; fires on a genuine match. What's
71
+ still owed for L4: the full five-class verification, independently graded.
72
+ - **L5 Compounding (mechanism built, metric now live).** Advocacy records whether it was right β€”
73
+ precision = acted-on Γ· offered, ADR-028's line between advocating and nagging. The numerator is
74
+ now **derived from an observed state transition** (offered β†’ later switched on = applied) and
75
+ surfaced in the console; it reads an honest `null` until enough offers resolve, never a fabricated
76
+ score. A dismissal is evidence about **fit, not importance** β€” asymmetric budget: a suggestion dies
77
+ on one dismissal, an important finding needs three. What's still owed for L5: real precision data
78
+ from use, and the cross-project promotion test.
79
+ - **The admin page is rebuilt around deltas**, not lifetime totals β€” and the humans who wrote in
80
+ are promoted to the top, ranked by recency rather than lifetime count.
81
+
82
+ <details>
83
+ <summary><b>Earlier &#8212; what 3.8 shipped</b> &#183; the gate that fires when you stop. <i>Expand.</i></summary>
84
+
85
+ ## 3.8 β€” the gate that fires when you stop
86
+
87
+ **Shipped 2026-07-22.** Every gate in this project fires on an **action** β€” a write, a push, a
88
+ claim. That is what makes it enforceable. But **stopping is the absence of an action**, so the most
89
+ expensive failure of the night had no trigger at all, and a system built to prevent it did not.
90
+
91
+ - **`continuation-gate.mjs`** runs when a turn ends. If work you committed to is unfinished, it
92
+ makes that the last thing in context. It cannot force another turn β€” claiming otherwise would be
93
+ the fabrication this project exists to kill β€” but it removes the silence that let a stop pass
94
+ unnoticed.
95
+ - **Project-scoped ledgers.** Three simultaneous projects never see each other's commitments; a
96
+ "you didn't finish X" fired in a repo that never heard of X is a false alarm, and ADR-028 fixes
97
+ the false-alarm rate at zero.
98
+ - **Installed machine-wide**, merged into existing hooks rather than replacing them, with a backup
99
+ taken first β€” that file governs every project on the machine.
100
+
101
+ <details>
102
+ <summary><b>Earlier &#8212; what 3.7 shipped</b> &#183; your lessons can now refuse your work. <i>Expand.</i></summary>
103
+
104
+ ## 3.7 β€” your lessons can now refuse your work
105
+
106
+ **Shipped 2026-07-22.** 3.6 made the machine legible. 3.7 closes the loop: a lesson you taught is
107
+ read by a gate at the moment it applies, and β€” once you ratify it β€” **refuses the action**.
108
+
109
+ - **`lesson-gate.mjs`** β€” a gate asks the store "what applies right now?" and gets your own words
110
+ back, with the evidence and how many times you had to say it. Wired into the real pre-push gate.
111
+ - **`lesson-ratify.mjs`** β€” see every lesson, ratify it, or delete it. **Demotion is sticky**: it
112
+ survives every future mining run, because a reject the nightly quietly undoes is worse than none.
113
+ - **The model cannot ratify its own rules.** Lessons it inferred about itself are quarantined and
114
+ can never block, no matter what is asked of them β€” closing an injection path an adversarial
115
+ review found, where a hallucinated session summary could have become a blocking gate.
116
+ - Proven end to end: before ratification the ship gate informs (exit 0); after ratifying the
117
+ version-discipline lesson it **refuses** (exit 1). Same wire, no code change β€” enforcement is data.
118
+
119
+ <details>
120
+ <summary><b>Earlier &#8212; what 3.6 shipped</b> &#183; it could finally show you what you own. <i>Expand.</i></summary>
121
+
122
+ ## 3.6 β€” it can finally show you what you own
60
123
 
61
124
  **Shipped 2026-07-22.** 3.5 made the brain speak. 3.6 makes it *legible*: a console panel that
62
125
  answers the question the owner has been asking for weeks β€” **"what is actually turned on?"**
@@ -79,9 +142,12 @@ answers the question the owner has been asking for weeks β€” **"what is actually
79
142
  had zero call sites; the client referenced it only in comments. Built-tested-unwired is this
80
143
  project's signature failure, and it happened three times in one night.
81
144
 
82
- Honest limit: this is **3.6, not 4.0**. 4.0 requires levels 3–5 of ADR-028's proactivity ladder
83
- (contextual, anticipatory, compounding) and all three remain unbuilt β€” see `docs/4.0-READINESS.md`,
84
- which grades the current state at **L2** with evidence for every mark.
145
+ Honest limit: this is still a dev release, **not 4.0**. 4.0 requires levels 3–5 of ADR-028's ladder
146
+ (contextual, anticipatory, compounding) **shipped and independently graded β‰₯95**. The mechanisms now
147
+ exist β€” L3's delivery seam (the chokepoint), an L4 goal surface, and L5 cross-project promotion (lessons
148
+ promoted to your global brain, survival proven by isolation) β€” but the ladder still grades **L2–L3**,
149
+ because L3 is built rather than live (deploy-gated) and the five acceptance metrics aren't all measured.
150
+ See `docs/4.0-READINESS.md` and `docs/4.0-EXECUTIVE-BRIEFING.md` (last independent grade: 70/100 overall).
85
151
 
86
152
  <details>
87
153
  <summary><b>Earlier &#8212; what 3.5 shipped</b> &#183; it stopped waiting to be asked. <i>Expand for the receipts.</i></summary>
@@ -297,6 +363,9 @@ claude plugin install ruvnet-brain@ruvnet-brain --scope user
297
363
 
298
364
  Registers the `search_ruvnet` MCP tool, the grounding skill, and the `UserPromptSubmit` enforcement hook β€” globally, at user scope. The plugin expects the brain at `~/.cache/ruvnet-brain/kb` (or point `RUVNET_BRAIN_KB` at your own copy). The first install may show a one-time trust prompt for the hook.
299
365
 
366
+ </details>
367
+ </details>
368
+ </details>
300
369
  </details>
301
370
  </details>
302
371
  </details>
@@ -340,7 +409,7 @@ Plus: the **β€œtake the wheel” behavioral pipeline** (below), a **4-level beha
340
409
 
341
410
  ## How it works
342
411
 
343
- The expensive work happens **once, at build time**: every covered repo is deep-walked (whole files, full function bodies, plus a symbol index), embedded into **two** vector variants (MiniLM-384 for edge/portability, bge-768 for depth) stored on-disk in **RVF / HNSW**, and distilled into a concepts + capability layer of per-repo primers and cards. That's **149,720 source chunks**. At **query time**, `search_ruvnet` searches every repo's store at once, pools the hits, and runs them through **one cross-encoder rerank** on a common scale β€” so the truly relevant file wins regardless of which repo it lives in β€” then returns whole source files, each labeled by repo and path.
412
+ The expensive work happens **once, at build time**: every covered repo is deep-walked (whole files, full function bodies, plus a symbol index), embedded into **two** vector variants (MiniLM-384 for edge/portability, bge-768 for depth) stored on-disk in **RVF / HNSW**, and distilled into a concepts + capability layer of per-repo primers and cards. That's **149,729 source chunks**. At **query time**, `search_ruvnet` searches every repo's store at once, pools the hits, and runs them through **one cross-encoder rerank** on a common scale β€” so the truly relevant file wins regardless of which repo it lives in β€” then returns whole source files, each labeled by repo and path.
344
413
 
345
414
  ![RuvNet Brain architecture pipeline](assets/diagrams/architecture-pipeline.svg)
346
415
 
@@ -450,7 +519,7 @@ node forge-ask-all.mjs --dir . --q "How does RuVector implement HNSW vector sear
450
519
 
451
520
  This project versions in the open (see the live badge up top for the exact plugin version; the downloadable knowledge bundle is a separate track) β€” we don't claim β€œdone,” β€œcomplete,” or β€œzero hallucinations.” Where it stands:
452
521
 
453
- - βœ… **The grounding brain is real and proven** β€” 54 public stores Β· 149,720 public source chunks (57 built stores incl. private), dual embeddings, cross-encoder rerank, plugin (MCP tool + enforcement hook + skill), all re-runnable.
522
+ - βœ… **The grounding brain is real and proven** β€” 54 public stores Β· 149,729 public source chunks (57 built stores incl. private), dual embeddings, cross-encoder rerank, plugin (MCP tool + enforcement hook + skill), all re-runnable.
454
523
  - βœ… **Code-level depth** β€” the code-rich repos are indexed to full function bodies; β€œhow is it implemented?” returns the implementation. Verified in the shipped bundle (clean-room 3/3).
455
524
  - βœ… **Routing holds** β€” named 47/48, described 26/28, scenario 7/8; behavioral L1–L4 all pass; private stores fenced out of the public bundle (zero-leak verified).
456
525
  - ⚠️ **Two routing residuals** (above) β€” surfaced, not hidden.
package/bin/install.mjs CHANGED
@@ -349,11 +349,18 @@ async function obtainBundle(release) {
349
349
  );
350
350
  }
351
351
  ok(`downloaded to ${tmp}`);
352
- // Best-effort fetch of the detached Ed25519 signature published alongside the asset (SEC-0010 #6).
353
- // If present we verify it before extracting; if absent (a pre-signing release) we warn but proceed
354
- // (transitional β€” see SIGNING_REQUIRED at the verify gate).
355
- try { await download(`${downloadUrl}.sig`, `${tmp}.sig`); } catch { /* no published sig yet */ }
356
- return { zipPath: tmp, tmpDir, downloaded: true };
352
+ // Fetch the detached Ed25519 signature published alongside the asset (SEC-0010 #6).
353
+ // We record WHY a fetch failed rather than discarding it. Swallowing the error made two very
354
+ // different worlds look identical: "this release genuinely has no signature" and "someone
355
+ // 404'd/reset/stripped the .sig so verification would be skipped." The second is the whole
356
+ // downgrade attack β€” strip one small file and 800MB+ of executable .mjs extracts unverified.
357
+ let sigError = null;
358
+ try {
359
+ await download(`${downloadUrl}.sig`, `${tmp}.sig`);
360
+ } catch (e) {
361
+ sigError = e && e.message ? e.message : String(e);
362
+ }
363
+ return { zipPath: tmp, tmpDir, downloaded: true, sigError };
357
364
  }
358
365
 
359
366
  // ── step: unzip into the cache dir (flattening the top-level ruvnet-brain/ folder) ───────────────
@@ -581,9 +588,13 @@ function verifyInstall(cacheDir) {
581
588
  }
582
589
 
583
590
  // ── step: warm the model + prove grounding with one real question (best-effort, never fatal) ──────
584
- // ── BEGIN GENERATED: verify-citation.mjs (node scripts/embed-verifier.mjs) ──
585
- const VERIFY_CITATION_B64 = 'IyEvdXNyL2Jpbi9lbnYgbm9kZQovLyB2ZXJpZnktY2l0YXRpb24ubWpzIOKAlCBkZWNpZGUgd2hldGhlciBhbiBhbnN3ZXIgaXMgR1JPVU5ERUQsIGJ5IGdyb3VuZCB0cnV0aCByYXRoZXIgdGhhbiBieSB2aWJlcy4KLy8KLy8gV0hZIFRISVMgRVhJU1RTCi8vIC0tLS0tLS0tLS0tLS0tLQovLyBUaGUgb2xkIGdyb3VuZGluZyBjaGVjayBhc2tlZDogZG9lcyB0aGUgYW5zd2VyIGNvbnRhaW4gdGhlIHN0cmluZyAicnZmIiBvciAicnV2ZWN0b3IiPyBBIG1vZGVsCi8vIHRoYXQgaGFsbHVjaW5hdGVkICJqdXN0IHVzZSBSVkYhIiB3aXRoIHplcm8gc291cmNlcyBwYXNzZWQuIFNvIGRpZCBhbiBhbnN3ZXIgY2l0aW5nIGEgZmlsZSB0aGF0Ci8vIGRvZXMgbm90IGV4aXN0LiBLZXl3b3JkIHByZXNlbmNlIGlzIG5vdCBldmlkZW5jZSDigJQgYW4gTExNIHBhbmVsIG9uY2Ugc2NvcmVkIGEgemVyby1jaXRhdGlvbgovLyBhbnN3ZXIgOTgvMTAwIG9uIHRoaXMgcmVwby4KLy8KLy8gQSBjaXRhdGlvbiBpcyBvbmx5IHJlYWwgaWYgaXQgUkVTT0xWRVM6IHRoZSByZXBvIG11c3QgYmUgYW4gaW5kZXhlZCBzdG9yZSBvbiBkaXNrLCBhbmQgdGhlIGNpdGVkCi8vIGRvY3VtZW50IHBhdGggbXVzdCBhcHBlYXIgYXMgdGhlIGBwYXRoYCBvZiBhbiBhY3R1YWwgcGFzc2FnZSBpbnNpZGUgdGhhdCBzdG9yZSdzIHBhc3NhZ2VzIGZpbGUuCi8vIFRoYXQgaXMgY2hlY2thYmxlIHdpdGhvdXQgYSBtb2RlbCwgd2l0aG91dCB0aGUgbmV0d29yaywgYW5kIHdpdGhvdXQgdHJ1c3RpbmcgYW55dGhpbmcgdGhlIG1vZGVsCi8vIHNhaWQuIFRoaXMgbW9kdWxlIGRvZXMgZXhhY3RseSB0aGF0IGFuZCBub3RoaW5nIGVsc2UuCi8vCi8vIFRoZSByZWFkZXIgKGBmb3JnZS1hc2stYWxsLm1qc2ApIHByaW50cyBlYWNoIGhpdCBhczoKLy8gICAgICMxICByZXBvPWNvbmNlcHRzICBjZT0wLjIwMSAgdmVjPTAuODY4NiAga2luZD1kb2MKLy8gICAgIHBhdGggOiBjb25jZXB0cy9ydXZlY3Rvci9DQVJEL3J1dmVjdG9yLWNhcmQKLy8gICAgIHRpdGxlOiBydXZlY3RvciDigJQgQ2FwYWJpbGl0eQovLyBOb3RlIHRoZSBwcmludGVkIHBhdGggaXMgYDxyZXBvPi88ZG9jUGF0aD5gOyBpbnNpZGUgYGNvbmNlcHRzLnBhc3NhZ2VzLmpzb25sYCB0aGUgc3RvcmVkIGBwYXRoYAovLyBpcyBqdXN0IGBydXZlY3Rvci9DQVJEL3J1dmVjdG9yLWNhcmRgIChvcHRpb25hbGx5IHN1ZmZpeGVkIGAjMGAsIGAjMWAsIOKApiB3aGVuIGNodW5rZWQpLgoKaW1wb3J0IGZzIGZyb20gJ25vZGU6ZnMnOwppbXBvcnQgcGF0aCBmcm9tICdub2RlOnBhdGgnOwppbXBvcnQgcmVhZGxpbmUgZnJvbSAnbm9kZTpyZWFkbGluZSc7CgovKiogUGFyc2UgdGhlIHJlYWRlcidzIHN0ZG91dCBpbnRvIHN0cnVjdHVyZWQgY2l0YXRpb25zLiBOZXZlciB0aHJvd3M7IHVucGFyc2VhYmxlIGlucHV0IOKGkiBbXS4gKi8KZXhwb3J0IGZ1bmN0aW9uIHBhcnNlQ2l0YXRpb25zKHN0ZG91dCkgewogIGNvbnN0IG91dCA9IFtdOwogIGNvbnN0IHRleHQgPSBTdHJpbmcoc3Rkb3V0ID8/ICcnKTsKICBjb25zdCBibG9ja1JlID0gL14jKFxkKylccytyZXBvPShcUyspKD86XHMrY2U9KC0/W1xkLl0rKSk/KD86XHMrdmVjPSgtP1tcZC5dKykpPyg/OlxzK2tpbmQ9KFxTKykpPy9nbTsKICBsZXQgbTsKICB3aGlsZSAoKG0gPSBibG9ja1JlLmV4ZWModGV4dCkpICE9PSBudWxsKSB7CiAgICBjb25zdCByZXN0ID0gdGV4dC5zbGljZShtLmluZGV4KTsKICAgIGNvbnN0IHBhdGhNID0gL15wYXRoXHMqOlxzKiguKykkL20uZXhlYyhyZXN0KTsKICAgIGNvbnN0IHRpdGxlTSA9IC9edGl0bGVccyo6XHMqKC4rKSQvbS5leGVjKHJlc3QpOwogICAgaWYgKCFwYXRoTSkgY29udGludWU7CiAgICBjb25zdCByZXBvID0gbVsyXTsKICAgIGNvbnN0IGZ1bGxQYXRoID0gcGF0aE1bMV0udHJpbSgpOwogICAgLy8gU3RyaXAgdGhlIHJlcG8gcHJlZml4IHRoZSByZWFkZXIgYWRkcywgc28gdGhlIHJlbWFpbmRlciBjYW4gYmUgbWF0Y2hlZCBhZ2FpbnN0IHRoZSBzdG9yZS4KICAgIGNvbnN0IGRvY1BhdGggPSBmdWxsUGF0aC5zdGFydHNXaXRoKGAke3JlcG99L2ApID8gZnVsbFBhdGguc2xpY2UocmVwby5sZW5ndGggKyAxKSA6IGZ1bGxQYXRoOwogICAgb3V0LnB1c2goewogICAgICByYW5rOiBOdW1iZXIobVsxXSksCiAgICAgIHJlcG8sCiAgICAgIGNlOiBtWzNdICE9PSB1bmRlZmluZWQgPyBOdW1iZXIobVszXSkgOiBudWxsLAogICAgICB2ZWM6IG1bNF0gIT09IHVuZGVmaW5lZCA/IE51bWJlcihtWzRdKSA6IG51bGwsCiAgICAgIGtpbmQ6IG1bNV0gPz8gbnVsbCwKICAgICAgZnVsbFBhdGgsCiAgICAgIGRvY1BhdGgsCiAgICAgIHRpdGxlOiB0aXRsZU0gPyB0aXRsZU1bMV0udHJpbSgpIDogbnVsbCwKICAgIH0pOwogIH0KICByZXR1cm4gb3V0Owp9CgovKiogVGhlIHBhc3NhZ2VzIGZpbGVzIHRoYXQgY291bGQgaG9sZCBhIHJlcG8ncyBkb2N1bWVudHMg4oCUIHRoZSBzbGltIHN0b3JlIGFuZCB0aGUgZGVlcCBgLmJpZ2Agb25lLiAqLwpleHBvcnQgZnVuY3Rpb24gcGFzc2FnZXNGaWxlc0ZvcihyZXBvLCBrYkRpcikgewogIHJldHVybiBbcGF0aC5qb2luKGtiRGlyLCBgJHtyZXBvfS5wYXNzYWdlcy5qc29ubGApLCBwYXRoLmpvaW4oa2JEaXIsIGAke3JlcG99LmJpZy5wYXNzYWdlcy5qc29ubGApXQogICAgLmZpbHRlcigocCkgPT4gZnMuZXhpc3RzU3luYyhwKSk7Cn0KCi8qKiBUcnVlIHdoZW4gYHN0b3JlZGAgaXMgdGhlIGNpdGVkIGRvYywgYWxsb3dpbmcgZm9yIHRoZSBgI05gIGNodW5rIHN1ZmZpeCB0aGUgYnVpbGRlciBhcHBlbmRzLiAqLwpmdW5jdGlvbiBzYW1lUGF0aChzdG9yZWQsIGRvY1BhdGgpIHsKICByZXR1cm4gc3RvcmVkID09PSBkb2NQYXRoIHx8IHN0b3JlZC5zdGFydHNXaXRoKGAke2RvY1BhdGh9I2ApOwp9CgovKioKICogRG9lcyB0aGlzIGNpdGF0aW9uIHBvaW50IGF0IGEgcGFzc2FnZSB0aGF0IHJlYWxseSBleGlzdHMgb24gZGlzaz8KICogU3RyZWFtcyB0aGUgZmlsZSBhbmQgc3RvcHMgYXQgdGhlIGZpcnN0IG1hdGNoLCBzbyBhIDUwME1CIGAuYmlnYCBzdG9yZSBjb3N0cyBvbmx5IGFzIG11Y2ggYXMgaXQKICogdGFrZXMgdG8gcmVhY2ggdGhlIGhpdC4gQSBtYWxmb3JtZWQgSlNPTiBsaW5lIGlzIHNraXBwZWQsIG5ldmVyIGZhdGFsLgogKi8KZXhwb3J0IGFzeW5jIGZ1bmN0aW9uIGNpdGF0aW9uUmVzb2x2ZXMoY2l0YXRpb24sIGtiRGlyKSB7CiAgY29uc3QgZmlsZXMgPSBwYXNzYWdlc0ZpbGVzRm9yKGNpdGF0aW9uLnJlcG8sIGtiRGlyKTsKICBpZiAoIWZpbGVzLmxlbmd0aCkgcmV0dXJuIHsgcmVzb2x2ZWQ6IGZhbHNlLCByZWFzb246ICduby1zdG9yZScsIGZpbGU6IG51bGwsIHN0b3JlZFBhdGg6IG51bGwgfTsKICBmb3IgKGNvbnN0IGZpbGUgb2YgZmlsZXMpIHsKICAgIGNvbnN0IHJsID0gcmVhZGxpbmUuY3JlYXRlSW50ZXJmYWNlKHsgaW5wdXQ6IGZzLmNyZWF0ZVJlYWRTdHJlYW0oZmlsZSksIGNybGZEZWxheTogSW5maW5pdHkgfSk7CiAgICB0cnkgewogICAgICBmb3IgYXdhaXQgKGNvbnN0IGxpbmUgb2YgcmwpIHsKICAgICAgICBpZiAoIWxpbmUpIGNvbnRpbnVlOwogICAgICAgIGxldCByZWM7CiAgICAgICAgdHJ5IHsgcmVjID0gSlNPTi5wYXJzZShsaW5lKTsgfSBjYXRjaCB7IGNvbnRpbnVlOyB9CiAgICAgICAgaWYgKHR5cGVvZiByZWM/LnBhdGggPT09ICdzdHJpbmcnICYmIHNhbWVQYXRoKHJlYy5wYXRoLCBjaXRhdGlvbi5kb2NQYXRoKSkgewogICAgICAgICAgcmV0dXJuIHsgcmVzb2x2ZWQ6IHRydWUsIHJlYXNvbjogJ29rJywgZmlsZTogcGF0aC5iYXNlbmFtZShmaWxlKSwgc3RvcmVkUGF0aDogcmVjLnBhdGggfTsKICAgICAgICB9CiAgICAgIH0KICAgIH0gZmluYWxseSB7CiAgICAgIHJsLmNsb3NlKCk7CiAgICB9CiAgfQogIHJldHVybiB7IHJlc29sdmVkOiBmYWxzZSwgcmVhc29uOiAncGF0aC1ub3QtaW4tc3RvcmUnLCBmaWxlOiBudWxsLCBzdG9yZWRQYXRoOiBudWxsIH07Cn0KCi8qKgogKiBUaGUgZ2F0ZS4gQW4gYW5zd2VyIGlzIGdyb3VuZGVkIG9ubHkgd2hlbiBpdCBjaXRlcyBhdCBsZWFzdCBvbmUgcGFzc2FnZSB0aGF0IHJlc29sdmVzIG9uIGRpc2suCiAqIFJldHVybnMgdGhlIHJlY2VpcHQgc28gYSBjYWxsZXIgY2FuIFBSSU5UIHRoZSBldmlkZW5jZSBpbnN0ZWFkIG9mIGFzc2VydGluZyBhIGNvbmNsdXNpb24uCiAqLwpleHBvcnQgYXN5bmMgZnVuY3Rpb24gdmVyaWZ5R3JvdW5kaW5nKHN0ZG91dCwga2JEaXIpIHsKICBjb25zdCBjaXRhdGlvbnMgPSBwYXJzZUNpdGF0aW9ucyhzdGRvdXQpOwogIGlmICghY2l0YXRpb25zLmxlbmd0aCkgewogICAgcmV0dXJuIHsgZ3JvdW5kZWQ6IGZhbHNlLCByZWFzb246ICduby1jaXRhdGlvbnMnLCBjaXRhdGlvbnM6IFtdLCByZWNlaXB0OiBudWxsIH07CiAgfQogIGZvciAoY29uc3QgY2l0YXRpb24gb2YgY2l0YXRpb25zKSB7CiAgICBjb25zdCByID0gYXdhaXQgY2l0YXRpb25SZXNvbHZlcyhjaXRhdGlvbiwga2JEaXIpOwogICAgaWYgKHIucmVzb2x2ZWQpIHsKICAgICAgcmV0dXJuIHsKICAgICAgICBncm91bmRlZDogdHJ1ZSwKICAgICAgICByZWFzb246ICdvaycsCiAgICAgICAgY2l0YXRpb25zLAogICAgICAgIHJlY2VpcHQ6IHsgcmVwbzogY2l0YXRpb24ucmVwbywgcGF0aDogY2l0YXRpb24uZnVsbFBhdGgsIHRpdGxlOiBjaXRhdGlvbi50aXRsZSwgZmlsZTogci5maWxlLCBzdG9yZWRQYXRoOiByLnN0b3JlZFBhdGggfSwKICAgICAgfTsKICAgIH0KICB9CiAgcmV0dXJuIHsgZ3JvdW5kZWQ6IGZhbHNlLCByZWFzb246ICdjaXRhdGlvbnMtZG8tbm90LXJlc29sdmUnLCBjaXRhdGlvbnMsIHJlY2VpcHQ6IG51bGwgfTsKfQo=';
586
- // ── END GENERATED ──
591
+ // The verifier ships as a real file (kb/verify-citation.mjs, in package.json files[]).
592
+ // It used to live here as a ~4KB base64 literal that was decoded at runtime, written to disk as
593
+ // .mjs, and then dynamically imported. That is the canonical staged-payload chain, and EDR
594
+ // behavioural engines score write-then-execute on a just-created file as high-confidence dropper
595
+ // activity β€” a benign explanation that arrives only AFTER the alarm. Copying a file we shipped is
596
+ // the same capability with none of the signature. (base64 was originally chosen to dodge escaping
597
+ // bugs β€” the module contains backticks, ${...} and backslashes β€” which shipping a file also solves.)
587
598
 
588
599
  // The verifier belongs next to the data it verifies, so it lives in the KB. But every bundle
589
600
  // published before 2026-07-09 predates it, and telling those users "grounding not verifiable β€”
@@ -593,9 +604,10 @@ const VERIFY_CITATION_B64 = 'IyEvdXNyL2Jpbi9lbnYgbm9kZQovLyB2ZXJpZnktY2l0YXRpb24
593
604
  function ensureVerifier(cacheDir) {
594
605
  const p = path.join(cacheDir, 'verify-citation.mjs');
595
606
  if (fs.existsSync(p)) return 'from-bundle';
596
- if (!VERIFY_CITATION_B64) return 'unavailable';
607
+ const shipped = path.join(REPO_ROOT, 'kb', 'verify-citation.mjs');
608
+ if (!fs.existsSync(shipped)) return 'unavailable';
597
609
  try {
598
- fs.writeFileSync(p, Buffer.from(VERIFY_CITATION_B64, 'base64').toString('utf8'), 'utf8');
610
+ fs.copyFileSync(shipped, p);
599
611
  return 'installed';
600
612
  } catch { return 'unavailable'; }
601
613
  }
@@ -699,7 +711,7 @@ const DEMO_QUESTIONS = [
699
711
  why: 'shows it grounding in Ruflo (the real orchestration engine) instead of guessing at a generic pattern',
700
712
  },
701
713
  ];
702
- function runDemo() {
714
+ async function runDemo() {
703
715
  printBanner('demo');
704
716
  const cacheDir = process.env.RUVNET_BRAIN_KB || path.join(os.homedir(), '.cache', 'ruvnet-brain', 'kb');
705
717
  const ask = path.join(cacheDir, 'forge-ask-all.mjs');
@@ -750,6 +762,47 @@ function runDemo() {
750
762
  console.log(`\n${c.green('─'.repeat(64))}`);
751
763
  console.log(` ${c.bold('That\'s it β€” no cloud calls, no API key, just your local brain.')}`);
752
764
  console.log(`${c.green('─'.repeat(64))}`);
765
+ // ── SCOPE + UPGRADE CONVERSATION ────────────────────────────────────────────────────────────
766
+ //
767
+ // The owner, 2026-07-22: "Normally this happens on a per-user basis, which lets learning,
768
+ // intelligence, access and software versions stay updated across ALL your projects. Only choose
769
+ // per-project if this is something you absolutely only use in one place. Our strong
770
+ // recommendation is per-user β€” but we always want YOU to be the arbiter of how things run on
771
+ // your machine."
772
+ //
773
+ // And, critically, for people who already have it installed: "That's only going to help people
774
+ // newly installing. It needs to be smart enough when it comes up to say version 4 is here, here
775
+ // are your choices."
776
+ //
777
+ // Both modules were BUILT AND WIRED TO NOTHING until this was added β€” the seventh instance of
778
+ // built-tested-unwired in one session, which is why scripts/wired-check.mjs now gates the release.
779
+ //
780
+ // Read-only here by design: this INFORMS and never changes scope on its own. P3 (nudge, never
781
+ // force) and P4 (the user is the arbiter). Fail-silent, because an informational block must never
782
+ // break an install that otherwise succeeded.
783
+ try {
784
+ const { detectCurrentScope, explainChoice, RECOMMENDED } = await import(new URL('../scripts/install-scope.mjs', import.meta.url).href);
785
+ const current = detectCurrentScope();
786
+ if (current && current.scope !== RECOMMENDED) {
787
+ console.log(`\n${c.dim(' ── how this is set up on your machine ──')}`);
788
+ for (const line of String(explainChoice({ current: current.scope })).split('\n').slice(0, 12)) {
789
+ console.log(` ${line}`);
790
+ }
791
+ }
792
+ } catch { /* informational only β€” never break an install */ }
793
+
794
+ try {
795
+ const { shouldNotify, noticeFor } = await import(new URL('../scripts/upgrade-notice.mjs', import.meta.url).href);
796
+ const v = wrapperVersion();
797
+ if (v && shouldNotify(v)) {
798
+ const notice = noticeFor(v);
799
+ if (notice) {
800
+ console.log('');
801
+ for (const line of String(notice).split('\n').slice(0, 14)) console.log(` ${line}`);
802
+ }
803
+ }
804
+ } catch { /* informational only */ }
805
+
753
806
  console.log(`\n Now try it for real: open Claude Code in any project and ask it something about`);
754
807
  console.log(` RuVector, Ruflo, AgentDB, or SPARC β€” it'll ground the same way, automatically.`);
755
808
  console.log(` Run this demo again any time: ${c.bold('npx ruvnet-brain --demo')}`);
@@ -1166,10 +1219,15 @@ function enableNightly() {
1166
1219
  process.exit(1);
1167
1220
  }
1168
1221
 
1169
- // Template the plist to THIS user's kb dir + node binary. Quotes guard paths with spaces;
1170
- // xmlEscape guards the XML (>> and && must survive as shell operators after plist parsing).
1222
+ // Template the plist to THIS user's kb dir + node binary.
1223
+ //
1224
+ // No `/bin/sh -c` (ADR-038): a LaunchAgent whose ProgramArguments invoke a shell is the standard
1225
+ // macOS persistence pattern, and EDR persistence monitors score it well above a plist that execs a
1226
+ // binary directly. launchd provides everything the shell was doing here natively β€”
1227
+ // WorkingDirectory replaces `cd`, StandardOutPath/StandardErrorPath replace `>>` and `2>&1` β€” so
1228
+ // dropping the shell costs nothing and removes both a shell parse of interpolated paths and the
1229
+ // signature. Same schedule, same command, same log.
1171
1230
  const logPath = path.join(kbDir, 'update.log');
1172
- const shellCmd = `cd "${kbDir}" && "${process.execPath}" forge-update.mjs --apply >> "${logPath}" 2>&1`;
1173
1231
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
1174
1232
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1175
1233
  <plist version="1.0">
@@ -1178,10 +1236,16 @@ function enableNightly() {
1178
1236
  <string>${NIGHTLY_LABEL}</string>
1179
1237
  <key>ProgramArguments</key>
1180
1238
  <array>
1181
- <string>/bin/sh</string>
1182
- <string>-c</string>
1183
- <string>${xmlEscape(shellCmd)}</string>
1239
+ <string>${xmlEscape(process.execPath)}</string>
1240
+ <string>forge-update.mjs</string>
1241
+ <string>--apply</string>
1184
1242
  </array>
1243
+ <key>WorkingDirectory</key>
1244
+ <string>${xmlEscape(kbDir)}</string>
1245
+ <key>StandardOutPath</key>
1246
+ <string>${xmlEscape(logPath)}</string>
1247
+ <key>StandardErrorPath</key>
1248
+ <string>${xmlEscape(logPath)}</string>
1185
1249
  <key>StartCalendarInterval</key>
1186
1250
  <dict>
1187
1251
  <key>Hour</key>
@@ -1348,6 +1412,12 @@ function disableSpendGuard() {
1348
1412
  else ok('spend watchdog was already off β€” nothing to remove (safe to run any time)');
1349
1413
  }
1350
1414
 
1415
+ // Default STATE_PATH from scripts/upgrade-notice.mjs, duplicated (not imported β€” see the cmpTag()
1416
+ // comment above this file's own rule on why) so machineFootprint()/uninstallAll() can find and
1417
+ // remove it without a module this file may not statically depend on.
1418
+ const upgradeNoticeStatePath = () =>
1419
+ process.env.RUVNET_UPGRADE_NOTICE_FILE || path.join(os.homedir(), '.config', 'ruvnet-brain', 'upgrade-notice.json');
1420
+
1351
1421
  /**
1352
1422
  * Everything this installer can leave on a machine, DERIVED from disk β€” never asserted.
1353
1423
  *
@@ -1371,7 +1441,14 @@ export function machineFootprint() {
1371
1441
  if (cmds) items.push({
1372
1442
  label: 'Claude Code plugin',
1373
1443
  path: path.dirname(cmds),
1374
- undo: 'claude plugin uninstall ruvnet-brain@ruvnet-brain',
1444
+ // `claude plugin uninstall` removes the INSTALLED plugin only β€” it leaves the marketplace itself
1445
+ // registered (a separate git clone + registry entry: `claude plugin marketplace list` still
1446
+ // shows it, and wirePlugin() above is the thing that added it via `claude plugin marketplace add
1447
+ // stuinfla/ruvnet-brain`). Verified live against `claude plugin marketplace --help` (marketplace
1448
+ // name = "ruvnet-brain", from this repo's own .claude-plugin/marketplace.json) that `remove` takes
1449
+ // that same short name. Without this second command someone who ran every undo we printed would
1450
+ // still have us registered as a marketplace.
1451
+ undo: 'claude plugin uninstall ruvnet-brain@ruvnet-brain && claude plugin marketplace remove ruvnet-brain',
1375
1452
  });
1376
1453
  const cmdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
1377
1454
  try {
@@ -1393,12 +1470,21 @@ export function machineFootprint() {
1393
1470
  add('Status-bar preference', path.join(telemetryStateDir(), '.statusline-pref'), 'delete this file');
1394
1471
  add('Model-router files', path.join(os.homedir(), '.claude', 'model-router'),
1395
1472
  'rm -rf ~/.claude/model-router');
1396
- // Config entries live INSIDE files the user owns, so they are reported as edits to review rather
1397
- // than as paths to delete β€” deleting someone's settings.json over one key would be indefensible.
1473
+ // GAP FIX: this used to be a loose `.includes('ruvnet-brain')` substring test against the WHOLE
1474
+ // settings.json file β€” which (a) could also fire on something unrelated (e.g. a marketplace
1475
+ // autoUpdate setting that merely mentions our name) and get mislabeled as "the statusLine entry",
1476
+ // and (b) meant this was ALWAYS reported as a manual edit, never auto-removed, even though this
1477
+ // installer is the one that wrote the key and knows exactly what it wrote. detectStatusLine() now
1478
+ // checks the actual parsed statusLine.command against the exact string writeSettingsStatusLine()
1479
+ // writes β€” precise enough that removeSettingsStatusLine() (called from uninstallAll()) can safely
1480
+ // reverse just this key, the same way removeClaudeMdBlock() reverses just its own block. A status
1481
+ // line a user has since edited or folded into their own script no longer matches and is correctly
1482
+ // left off this list entirely (nothing to claim, nothing to undo).
1398
1483
  try {
1399
- const settings = path.join(os.homedir(), '.claude', 'settings.json');
1400
- if (fs.existsSync(settings) && fs.readFileSync(settings, 'utf8').includes('ruvnet-brain')) {
1401
- items.push({ label: 'A statusLine entry in your settings.json', path: settings, undo: 'remove the "statusLine" entry that points at ruvnet-brain' });
1484
+ const settings = settingsJsonPath();
1485
+ const detected = detectStatusLine(settings);
1486
+ if (detected.hasStatusLine && !detected.parseError && detected.command === `node "${statuslineHelperPath()}"`) {
1487
+ items.push({ label: 'The statusLine entry in settings.json', path: settings, undo: 'npx ruvnet-brain --uninstall (removes just this key; settings.json is backed up first)' });
1402
1488
  }
1403
1489
  } catch { /* unreadable β€” do not claim it */ }
1404
1490
  try {
@@ -1407,6 +1493,14 @@ export function machineFootprint() {
1407
1493
  items.push({ label: 'The search_ruvnet MCP server registration', path: claudeJson, undo: 'claude mcp remove ruvnet-brain --scope user' });
1408
1494
  }
1409
1495
  } catch { /* unreadable β€” do not claim it */ }
1496
+ // GAP FIX: recordNotified() (scripts/upgrade-notice.mjs, invoked from main() and runDemo() below)
1497
+ // writes this file the first time a "what's new" notice is shown β€” a real disk artifact this
1498
+ // installer's own code path creates, that was never listed here and never removed on --uninstall.
1499
+ // Path duplicated rather than imported (see the cmpTag() comment above: this file must not import
1500
+ // from scripts/ at module scope, since scripts/upgrade-notice.mjs isn't in the npm `files` list β€”
1501
+ // it only reaches a user via a repo clone or `npx github:...`, never a plain npm publish; harmless
1502
+ // no-op existence check either way). Env override name matches STATE_PATH there exactly.
1503
+ add('Upgrade-notice state (release-notice tracking)', upgradeNoticeStatePath(), 'delete this file');
1410
1504
 
1411
1505
  return items;
1412
1506
  }
@@ -1487,7 +1581,10 @@ function uninstallAll() {
1487
1581
  // ours to delete, so they are handed over as commands.
1488
1582
  const AUTO = new Set(['Brain bundle (knowledge base)', 'Nightly updater (LaunchAgent)',
1489
1583
  'Spend watchdog (LaunchAgent)', 'Spend watchdog script', 'CLAUDE.md block (6 lines, between markers)',
1490
- 'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference']);
1584
+ 'Model-router files', 'Status-bar version script', 'Status-bar preference', 'Usage-counts preference',
1585
+ // Two gaps closed here: the statusLine KEY is now removable in place (we know exactly what we
1586
+ // wrote β€” see removeSettingsStatusLine()), and the upgrade-notice tracker is a file we fully own.
1587
+ 'The statusLine entry in settings.json', 'Upgrade-notice state (release-notice tracking)']);
1491
1588
  const willRemove = before.filter((it) => AUTO.has(it.label));
1492
1589
  const manual = before.filter((it) => !AUTO.has(it.label));
1493
1590
 
@@ -1506,6 +1603,14 @@ function uninstallAll() {
1506
1603
  const claudeMd = removeClaudeMdBlock();
1507
1604
  if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
1508
1605
 
1606
+ // GAP FIX: this used to be permanently "manual" β€” machineFootprint() treated ANY settings.json edit
1607
+ // as something only the user could safely touch. That blanket rule was right for edits we cannot
1608
+ // attribute with confidence, but wrong for this one specific key: we wrote it ourselves and know
1609
+ // the exact string we wrote, so we can reverse exactly that (never a status line the user has since
1610
+ // customized β€” removeSettingsStatusLine() checks the live command before touching anything).
1611
+ const statusLine = removeSettingsStatusLine();
1612
+ if (statusLine === 'removed') ok('removed the statusLine entry from ~/.claude/settings.json (your other settings untouched, backup saved)');
1613
+
1509
1614
  // NEVER rm -rf A PATH WE HAVE NOT PROVEN IS OURS. resolvedKbDir() honours $RUVNET_BRAIN_KB, which
1510
1615
  // the docs encourage for custom install locations β€” so `RUVNET_BRAIN_KB=$HOME npx ruvnet-brain
1511
1616
  // --uninstall` would have recursively deleted the user's home directory. Found by adversarial
@@ -1536,6 +1641,11 @@ function uninstallAll() {
1536
1641
  ['status-bar script', path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ruvnet-brain-statusline.cjs')],
1537
1642
  ['status-bar preference', path.join(telemetryStateDir(), '.statusline-pref')],
1538
1643
  ['usage-counts preference', telemetryConsentPath()],
1644
+ // GAP FIX: written by recordNotified() (scripts/upgrade-notice.mjs) whenever the "what's new"
1645
+ // notice fires from main()/runDemo() below β€” a single-purpose file under a directory this
1646
+ // installer alone writes to, safe to remove outright (not the whole ~/.config/ruvnet-brain/ dir,
1647
+ // which can also hold lessons.json/settings.json this installer never creates and must not touch).
1648
+ ['upgrade-notice state', upgradeNoticeStatePath()],
1539
1649
  ]) {
1540
1650
  if (!fs.existsSync(target)) continue;
1541
1651
  try { fs.rmSync(target, { recursive: true, force: true }); ok(`removed the ${label}`); }
@@ -1868,6 +1978,91 @@ export async function offerTelemetry(cacheDir) {
1868
1978
  * So: persistent background jobs and global-config edits require their OWN explicit flag. There is
1869
1979
  * no combination of `-y` alone that installs a daemon.
1870
1980
  */
1981
+ /**
1982
+ * THE PLAN β€” everything this run may do, stated BEFORE the first thing is done.
1983
+ *
1984
+ * WHY THIS EXISTS (real user feedback, 2026-07-24, relayed by the owner): people were not running
1985
+ * `npx ruvnet-brain` *because they could not tell what it would do.* One of them, a sophisticated
1986
+ * user, put the general objection precisely: he dislikes "the virus/plugin approach… it all works in
1987
+ * memory, with invisible hooks and all."
1988
+ *
1989
+ * The installer already asked consent for every high-impact step β€” watchdog, nightly updates,
1990
+ * telemetry, stack tools, statusline. That was necessary and NOT sufficient: consent granted one
1991
+ * question at a time, after the run has already started, never tells you the SHAPE of what you
1992
+ * agreed to. You cannot decline a thing you have not yet been told is coming, and a person deciding
1993
+ * whether to paste a command into their terminal is deciding about the whole run, not about step 4.
1994
+ *
1995
+ * So: the whole list, up front, with what each one costs you and how to undo it. Steps marked [?] are
1996
+ * asked individually as before β€” this screen does not replace those prompts, it makes them
1997
+ * predictable. Nothing here mutates anything; it prints and waits.
1998
+ *
1999
+ * TRUTHFULNESS RULE: every line below names a real step this file performs and a real reversal
2000
+ * command. If a step is added to the installer and not to this list, the list becomes a lie about
2001
+ * the installer β€” which is worse than having no list. Keep them together.
2002
+ */
2003
+ async function printPlanAndConfirm() {
2004
+ const H = os.homedir();
2005
+ const short = (p) => p.replace(H, '~');
2006
+
2007
+ const steps = [
2008
+ { auto: true, name: 'Download the knowledge base',
2009
+ // short() already renders $HOME as "~"; prefixing another one produced "~~/.cache/…".
2010
+ cost: short(resolveCacheDir().cacheDir),
2011
+ what: 'Real rUv source, on your disk, so answers cite files instead of guessing.',
2012
+ undo: 'npx ruvnet-brain --uninstall' },
2013
+ { auto: true, name: 'Register the Claude Code plugin + MCP server',
2014
+ cost: `an entry in ${short(path.join(H, '.claude'))}`,
2015
+ what: 'Gives Claude a search_ruvnet tool. Adds no hooks you have not agreed to.',
2016
+ undo: 'npx ruvnet-brain --uninstall' },
2017
+ { ask: true, name: 'Add rUv tools you are missing',
2018
+ cost: 'npm installs, only the ones you pick',
2019
+ what: 'So the brain can build with them, not just answer questions about them.',
2020
+ undo: 'npm uninstall -g <tool>' },
2021
+ { ask: true, name: 'Nightly auto-updates',
2022
+ cost: 'one LaunchAgent',
2023
+ what: 'Keeps the KB and plugin current. Recommended β€” rUv ships fast, and a stale brain is the main way this stops being useful.',
2024
+ undo: 'npx ruvnet-brain --disable-nightly' },
2025
+ { ask: true, name: 'Spend watchdog',
2026
+ cost: 'one LaunchAgent',
2027
+ what: 'Warns you if an agent fleet starts burning API credit unexpectedly.',
2028
+ undo: 'npx ruvnet-brain --disable-spend-guard' },
2029
+ { ask: true, name: 'Status-bar version segment',
2030
+ cost: 'one line in settings.json',
2031
+ what: 'Shows which brain version is live while you work.',
2032
+ undo: 'npx ruvnet-brain --no-statusline, or delete the statusLine entry' },
2033
+ { ask: true, name: 'Anonymous usage counts',
2034
+ cost: 'a counter ping',
2035
+ what: 'Installs and searches only β€” never your queries, your code, or your paths.',
2036
+ undo: 'npx ruvnet-brain --no-telemetry' },
2037
+ ];
2038
+
2039
+ console.log(` ${c.bold('Here is everything this will do.')} Nothing has happened yet.\n`);
2040
+ for (const s of steps) {
2041
+ const mark = s.auto ? c.green('βœ“') : c.cyan('?');
2042
+ console.log(` ${mark} ${c.bold(s.name)} ${c.dim('Β· ' + s.cost)}`);
2043
+ console.log(` ${s.what}`);
2044
+ console.log(` ${c.dim('undo: ' + s.undo)}\n`);
2045
+ }
2046
+ console.log(` ${c.green('βœ“')} happens automatically. ${c.cyan('?')} is asked first β€” and "no" is a complete answer.`);
2047
+ console.log(` ${c.dim('Every step is reversible, and `npx ruvnet-brain --what-changed` lists everything it touched.')}\n`);
2048
+
2049
+ // FLAG_YES means the caller already decided; a plan screen that blocks automation would break
2050
+ // agentic-kit and every scripted install. Print it, then proceed β€” the information is the point,
2051
+ // the pause is a courtesy to humans.
2052
+ if (FLAG_YES || FLAG_AUTO || !process.stdin.isTTY) {
2053
+ console.log(c.dim(' (non-interactive β€” continuing)\n'));
2054
+ return;
2055
+ }
2056
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
2057
+ const a = await new Promise((r) => rl.question(` ${c.cyan('?')} Continue? ${c.dim('[Y/n]')} `, r));
2058
+ rl.close();
2059
+ if (/^n/i.test(String(a).trim())) {
2060
+ console.log(`\n Stopped. Nothing was changed.\n`);
2061
+ process.exit(0);
2062
+ }
2063
+ console.log('');
2064
+ }
2065
+
1871
2066
  function ask(question, def = false, { blanketYes = true } = {}) {
1872
2067
  if (FLAG_YES && blanketYes) return Promise.resolve(true);
1873
2068
  if (!process.stdin.isTTY) return Promise.resolve(def);
@@ -2160,6 +2355,33 @@ function writeSettingsStatusLine(detected, command) {
2160
2355
  return backup;
2161
2356
  }
2162
2357
 
2358
+ // The uninstall-side mirror of writeSettingsStatusLine() above β€” this installer is the ONLY writer
2359
+ // that can safely reverse this specific edit, because it is the only one that knows the EXACT string
2360
+ // it wrote. Match on that exact command (never a loose "mentions ruvnet-brain" guess β€” see the
2361
+ // machineFootprint() comment this replaces) so a status line the user has since folded their own
2362
+ // script into, or edited by hand, is left completely alone. Same "refuse rather than guess"
2363
+ // discipline removeClaudeMdBlock() already applies to CLAUDE.md, and the same backup-first courtesy.
2364
+ function removeSettingsStatusLine() {
2365
+ const settingsPath = settingsJsonPath();
2366
+ const detected = detectStatusLine(settingsPath);
2367
+ if (!detected.exists || detected.parseError || !detected.hasStatusLine) return 'absent';
2368
+ const ours = `node "${statuslineHelperPath()}"`;
2369
+ if (detected.command !== ours) return 'not-ours'; // never touch a status line we didn't write
2370
+ try {
2371
+ const backup = backupSettingsJson(settingsPath);
2372
+ const next = { ...(detected.json || {}) };
2373
+ delete next.statusLine;
2374
+ const tmp = `${settingsPath}.ruvnet-tmp`;
2375
+ fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
2376
+ fs.renameSync(tmp, settingsPath);
2377
+ info(c.dim(` your original is saved at ${backup}`));
2378
+ return 'removed';
2379
+ } catch (e) {
2380
+ warn(`couldn't remove the statusLine entry (${e.message}) β€” remove it yourself from ${settingsPath}`);
2381
+ return 'error';
2382
+ }
2383
+ }
2384
+
2163
2385
  // Only called after explicit consent. NEVER overwrites an existing statusLine β€” detectStatusLine()
2164
2386
  // is the single source of truth for "is one already there", checked fresh right before any write.
2165
2387
  function applyStatusline() {
@@ -2427,6 +2649,8 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
2427
2649
  }
2428
2650
  }
2429
2651
 
2652
+ await printPlanAndConfirm();
2653
+
2430
2654
  const { cacheDir, isCustom } = resolveCacheDir();
2431
2655
 
2432
2656
  // ── "ALREADY PRESENT" IS THE WRONG QUESTION β€” ask "already CURRENT" ──────────────────────────
@@ -2506,17 +2730,25 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
2506
2730
  FLAG_LOCAL || fs.existsSync(path.join(REPO_ROOT, 'dist', 'ruvnet-brain.zip'));
2507
2731
  // Reuse the staleness check's resolution when it already ran β€” one network round-trip, not two.
2508
2732
  const release = localZipPresent ? null : (resolvedRelease || await resolveRelease());
2509
- const { zipPath, tmpDir, downloaded } = await obtainBundle(release);
2733
+ const { zipPath, tmpDir, downloaded, sigError } = await obtainBundle(release);
2510
2734
  // Verify the Ed25519 signature BEFORE extracting a downloaded bundle into the user's config
2511
- // (SEC-0010 #6 β€” trust root = keys/ruvnet-brain-signing.pub.pem shipped inside this package).
2512
- // SIGNING_REQUIRED is transitional: while releases predate signing, a MISSING sig warns-and-proceeds
2513
- // but a PRESENT-but-INVALID sig ALWAYS fails closed. Flip to true once every release is signed.
2514
- const SIGNING_REQUIRED = false;
2735
+ // (SEC-0010 #6 β€” trust root = the pubkey EMBEDDED in this file, so an attacker who swaps the
2736
+ // bundle cannot also swap the key it is checked against).
2737
+ //
2738
+ // SIGNING_REQUIRED was `false` transitionally, for releases that predated signing. That is over:
2739
+ // every release from v2.0.0 on is signed, including the pinned offline fallback (RELEASE_VERSION).
2740
+ // Leaving it false left a real downgrade path β€” strip or 404 the small .sig file and the missing-
2741
+ // signature branch printed a warning and extracted 800MB+ of executable .mjs anyway. No alarm
2742
+ // fired, because no signature was ever obtained. Now a missing signature fails closed like an
2743
+ // invalid one, and --no-verify remains the single explicit, user-chosen override.
2744
+ const SIGNING_REQUIRED = true;
2515
2745
  if (downloaded && !FLAG_NO_VERIFY) {
2516
2746
  const sigPath = `${zipPath}.sig`;
2517
2747
  const hasSig = fs.existsSync(sigPath);
2518
- if (!hasSig && !SIGNING_REQUIRED) {
2519
- warn('this release is not signed yet β€” proceeding (bundle integrity not cryptographically verified)');
2748
+ if (!hasSig) {
2749
+ try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
2750
+ die(`no signature published alongside this release β€” refusing to extract an unverified bundle${sigError ? `\n (signature fetch failed: ${sigError})` : ''}`,
2751
+ `Every release from v2.0.0 on is signed, so a missing signature means the download was\nincomplete, blocked, or tampered with. Re-run to fetch a fresh copy. If it persists, report it.\n(Override at your own risk with ${c.bold('--no-verify')}.)`);
2520
2752
  } else {
2521
2753
  step('Verifying the bundle signature', 'so a tampered or MITM-swapped download can never be extracted');
2522
2754
  const { ok: valid, reason } = verifyBundle(zipPath, sigPath);
@@ -2570,6 +2802,39 @@ It is safe to re-run at any time. After installing, restart Claude Code so the g
2570
2802
  // out what we did β€” which is precisely the position the 2026-07-20 corporate-machine reporter was
2571
2803
  // left in. Derived from disk, so it can only ever describe what is actually there.
2572
2804
  try { printFootprint(); } catch { /* a summary must never break a finished install */ }
2805
+
2806
+ // ── SCOPE + UPGRADE, on the path a real user actually takes ─────────────────────────────────
2807
+ //
2808
+ // These two blocks were first added inside runDemo(), which only runs with --demo β€” so almost
2809
+ // nobody would have seen them. Caught by running the install path instead of reasoning about it
2810
+ // (P1: verify through the USER'S path, never your own). printFootprint() is the right neighbour:
2811
+ // it already exists to tell someone exactly what is now on their machine.
2812
+ //
2813
+ // Read-only and fail-silent. This INFORMS; it never changes scope on its own. P3 (nudge, never
2814
+ // force) and P4 (the user is the arbiter of their own machine).
2815
+ try {
2816
+ const { detectCurrentScope, explainChoice, RECOMMENDED } = await import(new URL('../scripts/install-scope.mjs', import.meta.url).href);
2817
+ const current = detectCurrentScope();
2818
+ if (current && current.scope !== RECOMMENDED) {
2819
+ console.log(`\n${c.dim(' ── how this is set up on your machine ──')}`);
2820
+ for (const line of String(explainChoice({ current: current.scope })).split('\n').slice(0, 12)) console.log(` ${line}`);
2821
+ }
2822
+ } catch { /* informational only */ }
2823
+
2824
+ try {
2825
+ const { shouldNotify, noticeFor, recordNotified } = await import(new URL('../scripts/upgrade-notice.mjs', import.meta.url).href);
2826
+ const v = wrapperVersion();
2827
+ if (v && shouldNotify(v)) {
2828
+ const notice = noticeFor(v);
2829
+ if (notice) {
2830
+ console.log('');
2831
+ for (const line of String(notice).split('\n').slice(0, 14)) console.log(` ${line}`);
2832
+ // Record it so this fires at most once per minor version β€” the anti-nag rule is only real
2833
+ // if the "already told them" state is actually written.
2834
+ try { recordNotified(v); } catch { /* best effort */ }
2835
+ }
2836
+ }
2837
+ } catch { /* informational only */ }
2573
2838
  })().catch((e) => {
2574
2839
  die(e && e.message ? e.message : String(e));
2575
2840
  });
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // verify-citation.mjs β€” decide whether an answer is GROUNDED, by ground truth rather than by vibes.
3
+ //
4
+ // WHY THIS EXISTS
5
+ // ---------------
6
+ // The old grounding check asked: does the answer contain the string "rvf" or "ruvector"? A model
7
+ // that hallucinated "just use RVF!" with zero sources passed. So did an answer citing a file that
8
+ // does not exist. Keyword presence is not evidence β€” an LLM panel once scored a zero-citation
9
+ // answer 98/100 on this repo.
10
+ //
11
+ // A citation is only real if it RESOLVES: the repo must be an indexed store on disk, and the cited
12
+ // document path must appear as the `path` of an actual passage inside that store's passages file.
13
+ // That is checkable without a model, without the network, and without trusting anything the model
14
+ // said. This module does exactly that and nothing else.
15
+ //
16
+ // The reader (`forge-ask-all.mjs`) prints each hit as:
17
+ // #1 repo=concepts ce=0.201 vec=0.8686 kind=doc
18
+ // path : concepts/ruvector/CARD/ruvector-card
19
+ // title: ruvector β€” Capability
20
+ // Note the printed path is `<repo>/<docPath>`; inside `concepts.passages.jsonl` the stored `path`
21
+ // is just `ruvector/CARD/ruvector-card` (optionally suffixed `#0`, `#1`, … when chunked).
22
+
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import readline from 'node:readline';
26
+
27
+ /** Parse the reader's stdout into structured citations. Never throws; unparseable input β†’ []. */
28
+ export function parseCitations(stdout) {
29
+ const out = [];
30
+ const text = String(stdout ?? '');
31
+ const blockRe = /^#(\d+)\s+repo=(\S+)(?:\s+ce=(-?[\d.]+))?(?:\s+vec=(-?[\d.]+))?(?:\s+kind=(\S+))?/gm;
32
+ let m;
33
+ while ((m = blockRe.exec(text)) !== null) {
34
+ const rest = text.slice(m.index);
35
+ const pathM = /^path\s*:\s*(.+)$/m.exec(rest);
36
+ const titleM = /^title\s*:\s*(.+)$/m.exec(rest);
37
+ if (!pathM) continue;
38
+ const repo = m[2];
39
+ const fullPath = pathM[1].trim();
40
+ // Strip the repo prefix the reader adds, so the remainder can be matched against the store.
41
+ const docPath = fullPath.startsWith(`${repo}/`) ? fullPath.slice(repo.length + 1) : fullPath;
42
+ out.push({
43
+ rank: Number(m[1]),
44
+ repo,
45
+ ce: m[3] !== undefined ? Number(m[3]) : null,
46
+ vec: m[4] !== undefined ? Number(m[4]) : null,
47
+ kind: m[5] ?? null,
48
+ fullPath,
49
+ docPath,
50
+ title: titleM ? titleM[1].trim() : null,
51
+ });
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** The passages files that could hold a repo's documents β€” the slim store and the deep `.big` one. */
57
+ export function passagesFilesFor(repo, kbDir) {
58
+ return [path.join(kbDir, `${repo}.passages.jsonl`), path.join(kbDir, `${repo}.big.passages.jsonl`)]
59
+ .filter((p) => fs.existsSync(p));
60
+ }
61
+
62
+ /** True when `stored` is the cited doc, allowing for the `#N` chunk suffix the builder appends. */
63
+ function samePath(stored, docPath) {
64
+ return stored === docPath || stored.startsWith(`${docPath}#`);
65
+ }
66
+
67
+ /**
68
+ * Does this citation point at a passage that really exists on disk?
69
+ * Streams the file and stops at the first match, so a 500MB `.big` store costs only as much as it
70
+ * takes to reach the hit. A malformed JSON line is skipped, never fatal.
71
+ */
72
+ export async function citationResolves(citation, kbDir) {
73
+ const files = passagesFilesFor(citation.repo, kbDir);
74
+ if (!files.length) return { resolved: false, reason: 'no-store', file: null, storedPath: null };
75
+ for (const file of files) {
76
+ const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
77
+ try {
78
+ for await (const line of rl) {
79
+ if (!line) continue;
80
+ let rec;
81
+ try { rec = JSON.parse(line); } catch { continue; }
82
+ if (typeof rec?.path === 'string' && samePath(rec.path, citation.docPath)) {
83
+ return { resolved: true, reason: 'ok', file: path.basename(file), storedPath: rec.path };
84
+ }
85
+ }
86
+ } finally {
87
+ rl.close();
88
+ }
89
+ }
90
+ return { resolved: false, reason: 'path-not-in-store', file: null, storedPath: null };
91
+ }
92
+
93
+ /**
94
+ * The gate. An answer is grounded only when it cites at least one passage that resolves on disk.
95
+ * Returns the receipt so a caller can PRINT the evidence instead of asserting a conclusion.
96
+ */
97
+ export async function verifyGrounding(stdout, kbDir) {
98
+ const citations = parseCitations(stdout);
99
+ if (!citations.length) {
100
+ return { grounded: false, reason: 'no-citations', citations: [], receipt: null };
101
+ }
102
+ for (const citation of citations) {
103
+ const r = await citationResolves(citation, kbDir);
104
+ if (r.resolved) {
105
+ return {
106
+ grounded: true,
107
+ reason: 'ok',
108
+ citations,
109
+ receipt: { repo: citation.repo, path: citation.fullPath, title: citation.title, file: r.file, storedPath: r.storedPath },
110
+ };
111
+ }
112
+ }
113
+ return { grounded: false, reason: 'citations-do-not-resolve', citations, receipt: null };
114
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "3.6.1-dev",
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.",
3
+ "version": "3.9.50-dev",
4
+ "description": "One-command installer for RuvNet Brain \u2014 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": {
7
7
  "ruvnet-brain": "bin/install.mjs"
@@ -22,8 +22,6 @@
22
22
  "eval": "node scripts/eval-brain.mjs",
23
23
  "eval:gate": "node scripts/eval-brain.mjs --gate",
24
24
  "eval:record": "node scripts/eval-brain.mjs --record",
25
- "embed:verifier": "node scripts/embed-verifier.mjs",
26
- "embed:check": "node scripts/embed-verifier.mjs --check",
27
25
  "gists:index": "node scripts/ingest-gists.mjs --index-only",
28
26
  "gists:sync": "node scripts/ingest-gists.mjs && node kb/forge-big.mjs both --dir kb --name ruv-gists",
29
27
  "test:integration": "vitest run tests/integration",
@@ -31,13 +29,17 @@
31
29
  "catalog:verify": "node scripts/verify-model-catalog.mjs",
32
30
  "catalog:refresh": "node scripts/refresh-model-catalog.mjs",
33
31
  "falsify": "node scripts/falsify.mjs",
34
- "sbom": "npx --yes @cyclonedx/cyclonedx-npm --omit dev --output-file sbom/ruvnet-brain.cdx.json --mc-type application --validate"
32
+ "sbom": "npx --yes @cyclonedx/cyclonedx-npm --omit dev --output-file sbom/ruvnet-brain.cdx.json --mc-type application --validate",
33
+ "wired:check": "node scripts/wired-check.mjs --check",
34
+ "doc:currency": "node scripts/doc-currency.mjs --check",
35
+ "status:check": "node scripts/status-honesty.mjs"
35
36
  },
36
37
  "files": [
37
38
  "bin/install.mjs",
38
39
  "README.md",
39
40
  "LICENSE",
40
- "config/",
41
+ "config/model-router/",
42
+ "kb/verify-citation.mjs",
41
43
  "scripts/model-router-engine.mjs",
42
44
  "scripts/model-router-setup.mjs",
43
45
  "scripts/model-router-status.mjs",
@@ -1,109 +0,0 @@
1
- #!/bin/bash
2
- # Ruvnet Ecosystem Auto-Update
3
- # Updated: 2026-05-07
4
- #
5
- # Runs every night at 03:30 via ~/Library/LaunchAgents/com.stuartkerr.ruflo-autoupdate.plist
6
- # Discovers all currently-installed Ruvnet ecosystem packages and refreshes them.
7
- #
8
- # Tag policy:
9
- # - ruflo, @claude-flow/cli β†’ @alpha (Stuart runs alpha track)
10
- # - agentdb β†’ RETIRED 2026-05-30: AgentDB is bundled inside
11
- # @claude-flow/memory (Ruflo); no standalone install.
12
- # - everything else (@ruvector/*, ruvector, @claude-flow/aidefence,
13
- # agent-browser, agentic-flow, flow-nexus)
14
- # β†’ @latest
15
-
16
- set -uo pipefail
17
-
18
- LOG="$HOME/Library/Logs/ruflo-autoupdate.log"
19
- PATH="$HOME/.npm-global/bin:/usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin"
20
- export PATH
21
-
22
- ts() { date -u +"[%Y-%m-%dT%H:%M:%SZ]"; }
23
-
24
- echo "$(ts) ──── Ruvnet ecosystem update starting ────" | tee -a "$LOG"
25
-
26
- # Packages known to fail on this platform β€” skipped from auto-update.
27
- # @ruvector/edge-net pulls wrtc which needs a darwin-arm64 prebuilt binary
28
- # from S3 that returns 404. Bug in upstream wrtc package, not actionable here.
29
- SKIP_PACKAGES="@ruvector/edge-net"
30
-
31
- is_skipped() {
32
- local pkg="$1"
33
- for sk in $SKIP_PACKAGES; do
34
- [ "$pkg" = "$sk" ] && return 0
35
- done
36
- return 1
37
- }
38
-
39
- # Discover all currently-installed packages of interest
40
- # Discover all currently-installed RuvNet packages.
41
- # 2026-07-13: the old filter listed only 7 names/prefixes, so agentic-qe, @metaharness/*, qudag,
42
- # ruv-swarm, ruvbot, ruvi, ruvector-extensions and agentic-robotics were installed but NEVER updated
43
- # β€” silently frozen at whatever version they landed on. Live drift found that day: agentic-qe
44
- # 3.11.5 vs 3.12.0, ruvbot 0.3.1 vs 0.3.2, ruvector-extensions 0.1.1 vs 0.1.2.
45
- # Principle: if it is an installed RuvNet package, it gets kept current. No arbitrary allowlist.
46
- # (@marketing/ai-swarms is a LOCAL npm link β€” never npm-install over it; it is excluded by name.)
47
- PACKAGES=$(npm ls -g --depth=0 --json 2>/dev/null | jq -r '
48
- .dependencies // {} | keys[] | select(
49
- startswith("@ruvector/") or
50
- startswith("@claude-flow/") or
51
- startswith("@metaharness/") or
52
- startswith("@agentic-robotics/") or
53
- . == "ruflo" or
54
- . == "ruvector" or
55
- . == "ruvector-extensions" or
56
- . == "agent-browser" or
57
- . == "agentic-flow" or
58
- . == "agentic-qe" or
59
- . == "agentic-robotics" or
60
- . == "flow-nexus" or
61
- . == "qudag" or
62
- . == "ruv-swarm" or
63
- . == "ruvbot" or
64
- . == "ruvi"
65
- ) | select(startswith("@marketing/") | not)
66
- ')
67
-
68
- if [ -z "$PACKAGES" ]; then
69
- echo "$(ts) ERROR: no Ruvnet packages found via 'npm ls -g'" | tee -a "$LOG"
70
- exit 1
71
- fi
72
-
73
- # Build install args with the right tag per package
74
- INSTALL_ARGS=""
75
- SKIPPED=""
76
- ALPHA_PACKAGES="ruflo @claude-flow/cli"
77
- for pkg in $PACKAGES; do
78
- if is_skipped "$pkg"; then
79
- SKIPPED="$SKIPPED $pkg"
80
- continue
81
- fi
82
- TAG="@latest"
83
- for alpha_pkg in $ALPHA_PACKAGES; do
84
- if [ "$pkg" = "$alpha_pkg" ]; then
85
- TAG="@alpha"
86
- break
87
- fi
88
- done
89
- INSTALL_ARGS="$INSTALL_ARGS ${pkg}${TAG}"
90
- done
91
-
92
- if [ -n "$SKIPPED" ]; then
93
- echo "$(ts) Skipping known-broken packages:$SKIPPED" | tee -a "$LOG"
94
- fi
95
-
96
- echo "$(ts) Updating: $INSTALL_ARGS" | tee -a "$LOG"
97
-
98
- # Run the install β€” keep going even if one package fails
99
- npm install -g $INSTALL_ARGS 2>&1 | tee -a "$LOG"
100
-
101
- EXIT_CODE=$?
102
- if [ $EXIT_CODE -eq 0 ]; then
103
- echo "$(ts) βœ“ auto-update finished cleanly" | tee -a "$LOG"
104
- else
105
- echo "$(ts) ⚠ auto-update finished with exit code $EXIT_CODE β€” check log for failed packages" | tee -a "$LOG"
106
- fi
107
-
108
- echo "" | tee -a "$LOG"
109
- exit $EXIT_CODE
@@ -1,119 +0,0 @@
1
- {
2
- "_why": "THE REGISTRY OF WHAT MUST BE RUNNING. Created 2026-07-13 after a failure that must never repeat: com.ruvnet.brain-nightly's launchd trigger had NEVER fired, and nothing noticed, because 'is it running?' was only ever answered by looking at a job's own exit code \u2014 and launchd reports exit 0 for a job that has never run, which is indistinguishable from success. Silence was being read as health.",
3
- "_how_it_works": "scripts/job-heartbeat.sh wraps every job and writes start/end/exit receipts that a dying job cannot forge or skip (trap-protected). scripts/nightly-watchdog.mjs compares THIS registry against reality: a job listed here that is not loaded, or loaded but has no fresh receipt, or has a receipt with a non-zero exit, is a VIOLATION and gongs the phone. Absence of evidence is failure, never 'probably fine'.",
4
- "_adding_a_job": "Add it here FIRST, then wrap its plist command in job-heartbeat.sh. A job not in this registry is unwatched by definition \u2014 that is the whole point of a registry rather than per-job good intentions.",
5
- "heartbeatDir": "~/.cache/ruvnet-brain/heartbeats",
6
- "jobs": [
7
- {
8
- "label": "com.ruvnet.brain-nightly",
9
- "what": "Refreshes every RuvNet repo, rebuilds the brain, publishes the GitHub Release",
10
- "schedule": "daily 03:15",
11
- "maxAgeHours": 26,
12
- "required": true,
13
- "legacyLog": "logs/nightly.log"
14
- },
15
- {
16
- "label": "com.ruvnet.brain-gists",
17
- "what": "Pulls rUv's new gists into the brain and re-embeds when they change",
18
- "schedule": "daily 21:47",
19
- "maxAgeHours": 26,
20
- "required": true,
21
- "legacyLog": "logs/gists-nightly.log"
22
- },
23
- {
24
- "label": "com.ruvnet.goldie-weekly",
25
- "what": "Re-researches the cheapest/best models and refreshes the router catalog",
26
- "schedule": "Mondays 07:30",
27
- "maxAgeHours": 192,
28
- "required": true,
29
- "legacyLog": "logs/goldie.log"
30
- },
31
- {
32
- "label": "com.ruvnet.model-refresh",
33
- "what": "Refreshes the live model list (~/.claude/models.json) that the router prices against",
34
- "schedule": "daily 09:00",
35
- "maxAgeHours": 26,
36
- "required": true
37
- },
38
- {
39
- "label": "com.ruvnet.npm-token-renew",
40
- "what": "Renews the npm publish token before it expires (silently lets publishing die if it stops)",
41
- "schedule": "daily 03:33",
42
- "maxAgeHours": 26,
43
- "required": true,
44
- "_was_blind": "THE PUREST CASE OF THE BUG. On a healthy day this script returns early and writes ZERO BYTES anywhere \u2014 its log and state file sat frozen 2+ days stale while it fired on schedule every night. 'Ran and did nothing (healthy)' was literally indistinguishable from 'never ran', ~76 days out of every 90. The heartbeat wrapper fixes this for free: the RECEIPT is written by the wrapper, so the job no longer has to remember to report."
45
- },
46
- {
47
- "label": "com.stuartkerr.api-spend-watchdog",
48
- "what": "Hourly guard against runaway API spend and agent bursts \u2014 the one that actually pages the phone",
49
- "schedule": "hourly",
50
- "maxAgeHours": 3,
51
- "required": true,
52
- "_note": "It has been pushing '4 scheduled jobs failing silently' every hour, correctly \u2014 but off launchctl's exit-code field, which CANNOT see a job that never ran. It alerts; it just can't see the hole. Now it is itself watched: nothing supervised the supervisor."
53
- },
54
- {
55
- "label": "com.stuartkerr.ruflo-autoupdate",
56
- "what": "Keeps the whole RuvNet/Ruflo npm stack current (npm i -g ~26 packages on @latest/@alpha)",
57
- "schedule": "daily 03:30",
58
- "maxAgeHours": 26,
59
- "required": true,
60
- "_note": "This is THE job that keeps rUv's stack fresh on this machine. It was unwatched until 2026-07-13 \u2014 the single most important update job here had no supervision at all."
61
- },
62
- {
63
- "label": "com.cognitum.ruvector-autoupdate",
64
- "what": "Every 6h: git pull of ~/RuVector_Clean (the RuVector source the Rust crates build from)",
65
- "schedule": "every 6h",
66
- "maxAgeHours": 8,
67
- "required": true,
68
- "_note": "Its git stash pop has silently failed once (orphaned stash@{0} from 2026-06-28 \u2014 it swallowed local edits and nothing said so). The heartbeat now catches a non-zero exit; the orphaned stash still needs a human decision."
69
- },
70
- {
71
- "label": "com.stuartkerr.clear-claude-tmp",
72
- "what": "Every 3h: purges Claude Code task-output temp files older than 2 days (the dir hits ~800MB)",
73
- "schedule": "every 3h",
74
- "maxAgeHours": 5,
75
- "required": true,
76
- "_was_lying": "Its log line had the date BAKED IN at plist-write time \u2014 all 43 entries since 2026-04-06 were byte-identical. It worked; its log was a lie. Now a real script with a real $(date) and a real delete count."
77
- },
78
- {
79
- "label": "com.ruvnet.nightly-watchdog",
80
- "what": "Watches all of the above. Listed here so that IT is watched too \u2014 a watchdog nobody watches is the same blind spot one level up",
81
- "schedule": "daily 09:00",
82
- "maxAgeHours": 26,
83
- "required": true
84
- },
85
- {
86
- "label": "com.ruvnet.issue-watch",
87
- "what": "Hourly GitHub-issues SLA watcher (stuinfla/ruvnet-brain) \u2014 pages ntfy when an open issue sits >4h with no comment from the repo owner",
88
- "schedule": "hourly",
89
- "maxAgeHours": 3,
90
- "required": true
91
- },
92
- {
93
- "label": "com.ruvnet.issue-fix",
94
- "what": "Every 30 min (was 10 \u2014 a real run takes ~13 min, so the 10-min schedule SIGTERMed its own overlapping runs, fixed 2026-07-18): auto-fixes newly opened GitHub issues (stuinfla/ruvnet-brain) \u2014 bounded headless `claude -p` per issue in a disposable git worktree, pushes an issue-fix/<N> branch + comment or posts an honest triage comment, never touches main, never closes an issue",
95
- "schedule": "every 10 min",
96
- "maxAgeHours": 1,
97
- "required": true
98
- },
99
- {
100
- "label": "com.ruvnet.routing-flywheel",
101
- "schedule": "nightly 04:45",
102
- "maxAgeHours": 26,
103
- "what": "nightly data-readiness DRY-RUN of the routing flywheel (the plist runs --dry-run: reads receipts + prints readiness, runs NO loop and writes NO receipt β€” honest label per F13 2026-07-18; switch the plist to --synthetic to run a real $0 loop)"
104
- },
105
- {
106
- "label": "com.ruvnet.npx-witness",
107
- "schedule": "WatchPaths on ~/.npm/_npx (event-driven, no cadence)",
108
- "maxAgeHours": 2160,
109
- "what": "records every npx-cache mutation to the witness log (pure observer). Event-driven: weeks of silence are NORMAL when nothing touches _npx β€” maxAgeHours is a 90-day tripwire against the job being unloaded, not a freshness SLA. Registered per F2 2026-07-18: it was loaded+wrapped but absent here, i.e. unwatched by the registry's own definition."
110
- }
111
- ],
112
- "_retired": [
113
- {
114
- "label": "io.ruv.auto-subscribe",
115
- "retired": "2026-07-14",
116
- "why": "archived as cruft (archive-cruft-2026-07-14/); hourly RuvNet package auto-install is superseded by com.stuartkerr.ruflo-autoupdate (daily) + com.cognitum.ruvector-autoupdate. Removed from watch registry 2026-07-18 so nightly-watchdog stops flagging a deliberately-killed job."
117
- }
118
- ]
119
- }