ruvnet-brain 4.4.1 → 4.5.1

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.
Files changed (102) hide show
  1. package/README.md +3 -3
  2. package/bin/install.mjs +612 -113
  3. package/console/app.js +178 -81
  4. package/console/index.html +1 -1
  5. package/console/install-architecture.html +1 -0
  6. package/console/scope.css +4 -1
  7. package/console/style.css +13 -0
  8. package/console/tips.html +4 -4
  9. package/kb/brain-profile.mjs +1 -0
  10. package/kb/corpus-release-identity.mjs +1 -1
  11. package/kb/forge-update.mjs +41 -17
  12. package/kb/model-requirements.mjs +4 -1
  13. package/kb/update-storage-transaction.mjs +79 -0
  14. package/kb/zip-extract.mjs +22 -0
  15. package/package.json +1 -1
  16. package/plugin/.claude-plugin/plugin.json +1 -1
  17. package/plugin/.codex-plugin/plugin.json +1 -1
  18. package/plugin/commands/brain-console.md +5 -4
  19. package/plugin/commands/configure.md +5 -4
  20. package/plugin/commands/rnb-brief.md +41 -0
  21. package/plugin/commands/rnb.md +80 -0
  22. package/plugin/commands/rnbc.md +80 -0
  23. package/plugin/commands/rvbc.md +5 -4
  24. package/plugin/commands/rvcb.md +5 -4
  25. package/plugin/commands/whats-new.md +4 -4
  26. package/plugin/mcp/server.mjs +10 -2
  27. package/plugin/scripts/advocacy-route.mjs +59 -23
  28. package/plugin/scripts/anticipate.sh +4 -0
  29. package/plugin/scripts/brain-confirmation.mjs +263 -0
  30. package/plugin/scripts/brain-footprint.mjs +494 -0
  31. package/plugin/scripts/brain-location.mjs +47 -0
  32. package/plugin/scripts/capability-registry.mjs +11 -1
  33. package/plugin/scripts/continuity-brief.mjs +324 -0
  34. package/plugin/scripts/continuity-events.mjs +327 -0
  35. package/plugin/scripts/continuity-journal.mjs +500 -0
  36. package/plugin/scripts/decision-gate.mjs +56 -3
  37. package/plugin/scripts/footprint-io.mjs +186 -0
  38. package/plugin/scripts/ground-before-write.sh +8 -1
  39. package/plugin/scripts/ground-ruvnet.sh +103 -12
  40. package/plugin/scripts/grounding-answer.mjs +2 -1
  41. package/plugin/scripts/grounding-stamp.sh +3 -0
  42. package/plugin/scripts/grounding-substance.mjs +1 -1
  43. package/plugin/scripts/grounding-turn-evidence.mjs +17 -2
  44. package/plugin/scripts/hook-input.mjs +78 -4
  45. package/plugin/scripts/kb-copy-proof.mjs +148 -0
  46. package/plugin/scripts/lesson-bridge.mjs +6 -2
  47. package/plugin/scripts/nightly-controller.mjs +8 -1
  48. package/plugin/scripts/node-sqlite.mjs +41 -0
  49. package/plugin/scripts/package-cards.json +797 -0
  50. package/plugin/scripts/package-cards.rvf +0 -0
  51. package/plugin/scripts/package-cards.rvf.idmap.json +1 -0
  52. package/plugin/scripts/package-cards.rvf.meta.json +1 -0
  53. package/plugin/scripts/package-recommender-client.mjs +138 -0
  54. package/plugin/scripts/package-recommender-flag.mjs +30 -0
  55. package/plugin/scripts/package-recommender.mjs +391 -0
  56. package/plugin/scripts/project-progression-outbox.mjs +26 -8
  57. package/plugin/scripts/project-progression-reader.mjs +4 -2
  58. package/plugin/scripts/protect-brain-state.sh +4 -1
  59. package/plugin/scripts/session-snapshot-hook.mjs +27 -3
  60. package/plugin/scripts/session-start-budget.mjs +1 -0
  61. package/plugin/scripts/session-start-core.mjs +34 -4
  62. package/plugin/scripts/session-start-health.mjs +7 -1
  63. package/plugin/scripts/session-start-update-plane.mjs +35 -0
  64. package/plugin/scripts/turn-outcome-capture.mjs +12 -1
  65. package/plugin/scripts/unprompted-runtime.mjs +2 -2
  66. package/plugin/skills/brain-console/SKILL.md +3 -3
  67. package/plugin/skills/rnbc/SKILL.md +24 -0
  68. package/plugin/skills/rvbc/SKILL.md +2 -2
  69. package/scripts/approved-runtime.mjs +2 -2
  70. package/scripts/ci/warm-brain-models.mjs +28 -0
  71. package/scripts/codex-hook-trust.mjs +94 -0
  72. package/scripts/console-runtime-identity.mjs +5 -0
  73. package/scripts/corpus-canary.mjs +46 -6
  74. package/scripts/corpus-dispatch-decision.mjs +2 -2
  75. package/scripts/corpus-promotion.mjs +1 -1
  76. package/scripts/hook-qualify-hosts.mjs +15 -3
  77. package/scripts/host-install-matrix.mjs +63 -2
  78. package/scripts/human-approval-phrases.mjs +46 -0
  79. package/scripts/installed-brain-health.mjs +53 -0
  80. package/scripts/move-brain.mjs +310 -0
  81. package/scripts/onboarding-console.mjs +93 -10
  82. package/scripts/oracle/abstain-threshold-sweep.mjs +62 -0
  83. package/scripts/oracle/abstain-trace.mjs +139 -0
  84. package/scripts/oracle/doc2query-generate.mjs +162 -0
  85. package/scripts/oracle/doc2query-reach.mjs +110 -0
  86. package/scripts/oracle/judge-train.mjs +158 -0
  87. package/scripts/oracle/need-set-split.mjs +48 -0
  88. package/scripts/oracle/sona-query-adapter-eval.mjs +139 -0
  89. package/scripts/package-cards.mjs +374 -0
  90. package/scripts/publication-receipt.mjs +37 -9
  91. package/scripts/recommendation-e2e.mjs +110 -0
  92. package/scripts/recommendation-eval.mjs +105 -0
  93. package/scripts/recommendation-floor.mjs +56 -0
  94. package/scripts/recommendation-judge-score.mjs +74 -0
  95. package/scripts/recommendation-latency.mjs +95 -0
  96. package/scripts/recommendation-real-host-score.mjs +76 -0
  97. package/scripts/recommendation-real-host.mjs +137 -0
  98. package/scripts/release-channel-kind.mjs +1 -1
  99. package/scripts/release-environment-policy.mjs +33 -0
  100. package/scripts/single-source-check.mjs +15 -10
  101. package/scripts/sync-commands.mjs +5 -2
  102. package/scripts/wired-check.mjs +17 -2
package/console/app.js CHANGED
@@ -305,8 +305,7 @@ function updateVerdict() {
305
305
  const v = $('#verdict');
306
306
  if (!v || found.capsTotal == null) return; // nothing measured yet — say nothing
307
307
 
308
- const behind = (found.pkgTotal != null && found.pkgCurrent != null)
309
- ? found.pkgTotal - found.pkgCurrent : null;
308
+ const behind = found.pkgNeedsLook != null ? found.pkgNeedsLook : null;
310
309
 
311
310
  // What actually wants the reader's attention, in blast-radius order.
312
311
  const needsLook = [];
@@ -316,6 +315,7 @@ function updateVerdict() {
316
315
  const caveats = [];
317
316
  if (found.capsUnknown) caveats.push(`${found.capsUnknown} capabilit${found.capsUnknown === 1 ? "y" : "ies"} we could not check`);
318
317
  if (found.memNotTested) caveats.push(`${found.memNotTested} memory dimension${found.memNotTested === 1 ? '' : 's'} not probed this session`);
318
+ if (found.pkgUnchecked) caveats.push(`${found.pkgUnchecked} package${found.pkgUnchecked === 1 ? '' : 's'} whose version we could not check`);
319
319
 
320
320
  const good = !needsLook.length;
321
321
  const headline = good
@@ -323,8 +323,10 @@ function updateVerdict() {
323
323
  : `Mostly good — ${needsLook.join(' and ')}.`;
324
324
 
325
325
  const detail = `${found.capsOn} of ${found.capsTotal} capabilities are on`
326
+ + (found.capsIdle ? `, ${found.capsIdle} set up but not running` : '')
326
327
  + (found.capsAbsent ? `, ${found.capsAbsent} not installed` : '')
327
- + (behind === 0 ? ', and every package on your stack is current' : '')
328
+ + (behind === 0 && !found.pkgUnchecked ? ', and every package on your stack is current' : '')
329
+ + (behind === 0 && found.pkgUnchecked ? ', and no checked package is behind' : '')
328
330
  + '.';
329
331
 
330
332
  v.replaceChildren(
@@ -357,6 +359,7 @@ function renderHost(host, generatedAt) {
357
359
  BRAIN_INSTALLED_VERSION = String(host.brainVersion);
358
360
  const vc = $('#brain-ver');
359
361
  if (vc) { vc.textContent = `v${BRAIN_INSTALLED_VERSION}`; vc.hidden = false; }
362
+ if (LAST_TRUST) renderUpdateGong(LAST_TRUST);
360
363
  }
361
364
  const meta = $('#host-meta');
362
365
  if (meta) {
@@ -764,6 +767,12 @@ function renderStack(data) {
764
767
 
765
768
  found.pkgTotal = total;
766
769
  found.pkgCurrent = current;
770
+ // RNBC QA 2026-10-01: the verdict used to compute "behind" as total − current, so 34 plugin
771
+ // packages whose marketplace revision is simply not checked were announced as "34 packages
772
+ // behind" directly above a stack card saying "Nothing needs attention". Behind is what the audit
773
+ // MEASURED as behind or broken; not-checked is its own caveat.
774
+ found.pkgNeedsLook = behind + broken;
775
+ found.pkgUnchecked = Math.max(0, total - current - behind - broken - ahead);
767
776
  updateFoundStrip();
768
777
 
769
778
  pkgs.sort((a, b) =>
@@ -777,6 +786,8 @@ function renderStack(data) {
777
786
  ahead ? el('span', {}, ', ', el('b', {}, fmtInt(ahead)), ' ahead of the registry (which is legal)') : '',
778
787
  broken ? el('span', {}, ', ', el('b', {}, fmtInt(broken)), ' broken') : '',
779
788
  behind ? el('span', {}, ', ', el('b', {}, fmtInt(behind)), ' behind') : '',
789
+ found.pkgUnchecked ? el('span', {}, ', ', el('b', {}, fmtInt(found.pkgUnchecked)),
790
+ ' installed but not version-checked (plugin marketplace revisions are not compared)') : '',
780
791
  '.', infoBtn('Your stack', STACK_INFO)));
781
792
 
782
793
  if (pkgs.length) {
@@ -803,10 +814,17 @@ function renderStack(data) {
803
814
  // (the purge:shadows recommendation — evidence, cost, undo); the in-sync majority collapses
804
815
  // to a single verified line with a peel-back for whoever wants the full inventory.
805
816
  const staleRows = shadows.filter((s) => s.stale);
806
- const syncCount = shadows.length - staleRows.length;
817
+ // A cached copy with NO global install has nothing to "match" — RNBC QA 2026-10-01 measured
818
+ // four "cache 0.1.12 · global ?" rows each wearing a green "in sync" chip under "All 8 cached
819
+ // copies match your installs". They are counted and labelled for what they are.
820
+ const orphanRows = shadows.filter((s) => !s.stale && !s.global);
821
+ const syncCount = shadows.length - staleRows.length - orphanRows.length;
822
+ const orphanNote = orphanRows.length
823
+ ? ` ${fmtInt(orphanRows.length)} more ${orphanRows.length === 1 ? 'is a cached copy' : 'are cached copies'} of a package with no global install to compare against.`
824
+ : '';
807
825
  main.push(el('aside', { class: 'shadows' },
808
826
  el('p', { class: 'shadows-title' }, 'Shadow copies in the npx cache',
809
- staleRows.length ? chip(`${staleRows.length} stale`, 'warn') : chip('all in sync', 'green')),
827
+ staleRows.length ? chip(`${staleRows.length} stale`, 'warn') : chip(orphanRows.length ? 'none stale' : 'all in sync', 'green')),
810
828
  staleRows.length ? el('p', { class: 'shadows-sub' },
811
829
  'npx keeps private copies in ', el('code', {}, '~/.npm/_npx'),
812
830
  '. A stale one quietly answers instead of your newer install — every command still “works”, which is exactly why it’s invisible. These need dealing with:') : null,
@@ -822,18 +840,18 @@ function renderStack(data) {
822
840
  el('span', { class: 'shadow-dir' }, s.dir || ''),
823
841
  )),
824
842
  el('p', { class: 'shadows-ok' },
825
- staleRows.length
826
- ? `The other ${fmtInt(syncCount)} cached ${syncCount === 1 ? 'copy matches' : 'copies match'} your installs — re-checked on every audit.`
843
+ (staleRows.length || orphanRows.length
844
+ ? `${staleRows.length ? 'The other ' : ''}${fmtInt(syncCount)} cached ${syncCount === 1 ? 'copy matches' : 'copies match'} your installs — re-checked on every audit.`
827
845
  : shadows.length === 1
828
846
  ? 'The 1 cached copy matches your install — re-checked on every audit; nothing is hiding stale.'
829
- : `All ${fmtInt(shadows.length)} cached copies match your installs — re-checked on every audit; nothing is hiding stale.`),
847
+ : `All ${fmtInt(shadows.length)} cached copies match your installs — re-checked on every audit; nothing is hiding stale.`) + orphanNote),
830
848
  el('details', { class: 'sub' },
831
849
  el('summary', {}, `Peel it back — ${shadows.length === 1 ? 'the 1 cached copy' : `all ${fmtInt(shadows.length)} cached copies`}`),
832
850
  el('div', { class: 'sub-body' },
833
851
  shadows.map((s) => el('div', { class: 'shadow-row' },
834
852
  el('span', { class: 'shadow-name' }, s.name || '—'),
835
853
  el('span', { class: 'shadow-vers' }, `cache ${s.version ?? '?'} · global `, el('b', {}, s.global ?? '?')),
836
- s.stale ? chip('stale', 'warn') : chip('in sync', 'green'),
854
+ s.stale ? chip('stale', 'warn') : s.global ? chip('in sync', 'green') : chip('no global install', 'grey', 'Nothing installed globally to compare this cached copy with'),
837
855
  el('span', { class: 'shadow-dir' }, s.dir || ''),
838
856
  ))))));
839
857
  }
@@ -1100,7 +1118,14 @@ function capRow(row) {
1100
1118
  checkboxEligible
1101
1119
  ? capCheckbox(row)
1102
1120
  : wantsAdvice
1103
- ? (turnOn
1121
+ ? (row.setting
1122
+ ? el('p', { class: 'cap-turnon' },
1123
+ el('span', { class: 'cap-turnon-lb' }, 'to turn it on'),
1124
+ 'use the switch in Settings — ',
1125
+ el('button', { class: 'btn btn-ghost btn-sm cap-setting-jump', type: 'button', onclick: () => jumpToSetting(String(row.setting)) },
1126
+ 'take me to it'),
1127
+ ' (saved with an undo)')
1128
+ : turnOn
1104
1129
  ? el('p', { class: 'cap-turnon' },
1105
1130
  el('span', { class: 'cap-turnon-lb' }, 'to turn it on'),
1106
1131
  String(turnOn.human || 'run'), ' — ', el('code', {}, String(turnOn.cmd)))
@@ -1218,7 +1243,7 @@ function renderCapabilities(data) {
1218
1243
  setChips('chips-capabilities', chips);
1219
1244
  // Feed the verdict line. It needs the same counts this card just derived, and deriving them a
1220
1245
  // second time somewhere else is how two surfaces start disagreeing about one machine.
1221
- found.capsOn = on; found.capsOff = off; found.capsAbsent = absent; found.capsUnknown = unknown;
1246
+ found.capsOn = on; found.capsOff = off; found.capsAbsent = absent; found.capsUnknown = unknown; found.capsIdle = idle;
1222
1247
  found.capsTotal = rows.length;
1223
1248
  updateFoundStrip();
1224
1249
 
@@ -1237,6 +1262,9 @@ function renderCapabilities(data) {
1237
1262
  const counts = [
1238
1263
  on ? el('span', {}, el('b', {}, fmtInt(on)), ' on') : null,
1239
1264
  off ? el('span', {}, el('b', {}, fmtInt(off)), ' off') : null,
1265
+ // RNBC QA 2026-10-01: idle rows were in the chips but missing from this sentence, so "11
1266
+ // capabilities: 7 on, 1 not installed, and 1 we could not check" silently lost two.
1267
+ idle ? el('span', {}, el('b', {}, fmtInt(idle)), ' set up but not running') : null,
1240
1268
  absent ? el('span', {}, el('b', {}, fmtInt(absent)), ' not installed') : null,
1241
1269
  unknown ? el('span', {}, el('b', {}, fmtInt(unknown)), ' we could not check') : null,
1242
1270
  ].filter(Boolean);
@@ -1723,7 +1751,10 @@ function addRecommendations(recs, source) {
1723
1751
  * A full grouped-sections layout is a deliberate follow-up: its group order and whether user/machine
1724
1752
  * split into two visible groups are product decisions the owner reserved. */
1725
1753
  const REC_SCOPE_LABEL = {
1726
- project: { text: 'Just this project', tone: 'cyan', title: 'Applying this changes only the project you are in right now.' },
1754
+ // "One project", not "Just this project": a reconcile proposal names and changes a DIFFERENT project
1755
+ // from the one the console is serving (RNBC QA 2026-10-01 — the pill said "the project you are in
1756
+ // right now" over a card that rewires npx-project while the console served qa-project).
1757
+ project: { text: 'One project only', tone: 'cyan', title: 'Applying this changes only the one project this proposal names — nothing machine-wide.' },
1727
1758
  user: { text: 'Every project · your account', tone: 'amber', title: 'Applying this changes behaviour for every project under your user account.' },
1728
1759
  machine: { text: 'Every project · this machine', tone: 'amber', title: 'Applying this changes behaviour for every project on this computer.' },
1729
1760
  };
@@ -2641,7 +2672,7 @@ function fieldBeats(f) {
2641
2672
  * same widget — same markup, same "not chosen" honesty rule, same info-bubble mechanics — as every
2642
2673
  * config.json field, rather than a bespoke control invented for one setting.
2643
2674
  */
2644
- function buildSettingsField(f, values, defaults, refreshDirty) {
2675
+ function buildSettingsField(f, values, defaults, refreshDirty, overrides = null) {
2645
2676
  const labId = `lab-${f.key}`;
2646
2677
  const helpId = `help-${f.key}`;
2647
2678
  const ctl = el('div', { class: 'field-ctl' });
@@ -2690,20 +2721,27 @@ function buildSettingsField(f, values, defaults, refreshDirty) {
2690
2721
  // that this is a recommendation and not their current setting.
2691
2722
  const chosen = values[f.key] === true || values[f.key] === false;
2692
2723
  const rec = defaults[f.key] === true;
2693
- const input = el('input', { type: 'checkbox', 'aria-labelledby': labId, 'aria-describedby': helpId, onchange: refreshDirty });
2724
+ // A NOT-CHOSEN field is sent only once the person has touched it. RNBC QA 2026-10-01: every field
2725
+ // was posted on every save, so changing ONLY "Your model house" on a fresh machine also saved the
2726
+ // pre-filled recommendation nightly:true — and saveConfig() installed the nightly LaunchAgent the
2727
+ // person never touched. A recommendation is not a choice until someone makes it.
2728
+ let touched = false;
2729
+ const input = el('input', { type: 'checkbox', 'aria-labelledby': labId, 'aria-describedby': helpId,
2730
+ onchange: () => { touched = true; refreshDirty(); } });
2694
2731
  input.checked = chosen ? values[f.key] === true : rec;
2695
2732
  initialValue = input.checked;
2696
2733
  ctl.append(el('label', { class: 'switch' }, input, el('span', { class: 'track', 'aria-hidden': 'true' })));
2697
2734
  if (!chosen) ctl.append(notChosenField(rec ? 'on' : 'off'));
2698
- collector = () => ({ include: true, value: input.checked });
2735
+ collector = () => ({ include: chosen || touched, touched: !chosen && touched, value: input.checked });
2699
2736
  } else if (f.type === 'enum' && Array.isArray(f.options)) {
2700
2737
  const name = `seg-${f.key}`;
2701
2738
  const seg = el('div', { class: 'seg', role: 'radiogroup', 'aria-labelledby': labId, 'aria-describedby': helpId });
2702
2739
  const inputs = [];
2703
2740
  const chosen = f.options.some((opt) => Object.is(values[f.key], opt));
2704
2741
  const rec = f.options.includes(defaults[f.key]) ? defaults[f.key] : f.options[0];
2742
+ let touched = false;
2705
2743
  for (const opt of f.options) {
2706
- const input = el('input', { type: 'radio', name, value: String(opt), onchange: refreshDirty });
2744
+ const input = el('input', { type: 'radio', name, value: String(opt), onchange: () => { touched = true; refreshDirty(); } });
2707
2745
  input.optionValue = opt;
2708
2746
  input.checked = chosen ? Object.is(values[f.key], opt) : Object.is(opt, rec);
2709
2747
  inputs.push(input);
@@ -2720,7 +2758,7 @@ function buildSettingsField(f, values, defaults, refreshDirty) {
2720
2758
  }
2721
2759
  ctl.append(seg);
2722
2760
  if (!chosen) ctl.append(notChosenField(segLabel(f.key, rec)));
2723
- collector = () => ({ include: true, value: inputs.find((i) => i.checked)?.optionValue });
2761
+ collector = () => ({ include: chosen || touched, touched: !chosen && touched, value: inputs.find((i) => i.checked)?.optionValue });
2724
2762
  } else {
2725
2763
  const input = el('input', {
2726
2764
  type: 'text', class: 'text-input', 'aria-labelledby': labId, 'aria-describedby': helpId, oninput: refreshDirty,
@@ -2731,6 +2769,15 @@ function buildSettingsField(f, values, defaults, refreshDirty) {
2731
2769
  collector = () => ({ include: true, value: input.value });
2732
2770
  }
2733
2771
 
2772
+ // A project file that overrides this key for the runtime that reads it (see projectOverrides()
2773
+ // on the server). Saving here still changes the user-level choice; the note says why this project
2774
+ // will not follow it.
2775
+ const ov = overrides && overrides.values && Object.hasOwn(overrides.values, f.key) ? overrides : null;
2776
+ if (ov) {
2777
+ ctl.append(el('p', { class: 'field-override bp-warn', role: 'note' },
2778
+ 'This project overrides this setting: ', el('b', {}, String(typeof ov.values[f.key] === 'boolean' ? (ov.values[f.key] ? 'on' : 'off') : segLabel(f.key, ov.values[f.key]))),
2779
+ ' (from ', el('code', {}, ov.path), '). A change saved here applies to your other projects, not this one.'));
2780
+ }
2734
2781
  const row = el('div', { class: 'field', id: `field-${f.key}` },
2735
2782
  el('div', {},
2736
2783
  el('span', { class: 'field-label', id: labId }, f.label || f.key, infoBtn(f.label || f.key, fieldBeats(f))),
@@ -2746,6 +2793,11 @@ function buildSettingsField(f, values, defaults, refreshDirty) {
2746
2793
  * single advocacy field backed by user-settings.mjs — same widget, same save/undo/error handling,
2747
2794
  * only the endpoint and the file it names in its own copy differ.
2748
2795
  */
2796
+ // The last save's undo, per form, kept OUTSIDE the form so it survives a repaint. RNBC QA 2026-10-01:
2797
+ // the form is rebuilt whenever a background measurement lands, and the "Undo save" button vanished
2798
+ // with it — the undo was still in the server's journal, with no way left to reach it.
2799
+ const PENDING_SAVE_UNDO = new Map();
2800
+
2749
2801
  function buildSettingsForm(cfg, { endpoint }) {
2750
2802
  const values = cfg.values || {};
2751
2803
  // What the project would pick FOR you, kept strictly apart from what you actually picked. The
@@ -2762,14 +2814,20 @@ function buildSettingsForm(cfg, { endpoint }) {
2762
2814
  for (const [key, get] of Object.entries(collectors)) {
2763
2815
  const g = get();
2764
2816
  if (g.secret) { if (g.include) return true; continue; }
2817
+ if (g.touched) return true;
2765
2818
  if (g.value !== initial[key]) return true;
2766
2819
  }
2767
2820
  return false;
2768
2821
  }
2769
- function refreshDirty() { if (saveBtn) saveBtn.disabled = !isDirty(); }
2822
+ function refreshDirty() {
2823
+ const dirty = isDirty();
2824
+ if (saveBtn) saveBtn.disabled = !dirty;
2825
+ // read by renderSettings: a repaint must not discard choices the person has not saved yet
2826
+ form.dataset.dirty = dirty ? '1' : '';
2827
+ }
2770
2828
 
2771
2829
  for (const f of cfg.schema) {
2772
- const { row, collector, initialValue } = buildSettingsField(f, values, defaults, refreshDirty);
2830
+ const { row, collector, initialValue } = buildSettingsField(f, values, defaults, refreshDirty, cfg.projectOverrides);
2773
2831
  collectors[f.key] = collector;
2774
2832
  if (f.type !== 'secret' && !f.secret) initial[f.key] = initialValue;
2775
2833
  form.append(row);
@@ -2783,6 +2841,45 @@ function buildSettingsForm(cfg, { endpoint }) {
2783
2841
  ' in your user folder. Each choice is enforced by its named runtime; encrypted secrets and scheduler state never live in this browser response.')),
2784
2842
  resultSlot);
2785
2843
 
2844
+ // Renders the saved note and its Undo. Called on a successful save AND whenever this form is
2845
+ // rebuilt while an undo is still pending, so a repaint cannot strand the undo.
2846
+ function showSaved(data) {
2847
+ // Not every store behind this form has an undo token — so the button only appears when one exists.
2848
+ const undoBtn = data.undoToken ? el('button', {
2849
+ class: 'btn btn-undo btn-sm', type: 'button',
2850
+ // try/catch, because this is an async onclick with a network call in it. The server's own
2851
+ // explanation is shown verbatim on failure: "this undo has already been used" and "your
2852
+ // settings were saved again after this point" are the two cases people will actually hit.
2853
+ onclick: async (ev) => {
2854
+ const btn = ev.currentTarget;
2855
+ btn.disabled = true;
2856
+ try {
2857
+ const r = await postJSON('/api/undo', { undoToken: data.undoToken });
2858
+ const ok = r.ok && r.data?.ok;
2859
+ if (!ok) btn.disabled = false;
2860
+ if (ok || /already been used|saved again after/.test(r.data?.log || '')) PENDING_SAVE_UNDO.delete(endpoint);
2861
+ resultSlot.replaceChildren(el('div', { class: `form-note ${ok ? 'n-ok' : 'n-err'}`, role: 'status' },
2862
+ ok
2863
+ ? 'Settings restored from the backup. Reload to see the restored values.'
2864
+ : (r.status === 403 ? TOKEN_MSG
2865
+ : `Undo didn’t complete — ${r.data?.log || 'the backup file still exists, nothing is lost.'}`)));
2866
+ } catch (err) {
2867
+ btn.disabled = false;
2868
+ resultSlot.replaceChildren(el('div', { class: 'form-note n-err', role: 'alert' },
2869
+ `Undo couldn’t reach the console server: ${err.message || err}. Nothing was changed.`));
2870
+ }
2871
+ },
2872
+ }, 'Undo save') : null;
2873
+ resultSlot.replaceChildren(el('div', { class: 'form-note n-ok', role: 'status' },
2874
+ frag(BADGE_OK),
2875
+ el('div', { class: 'fn-body' },
2876
+ el('b', {}, 'Saved.'), ' Your choices are in ',
2877
+ el('span', { class: 'fn-path' }, cfg.path || 'your user folder'), '.',
2878
+ data.backup ? el('span', {}, ' Backup kept at ', el('span', { class: 'fn-path' }, data.backup), '.') : ''),
2879
+ undoBtn));
2880
+ }
2881
+ if (PENDING_SAVE_UNDO.has(endpoint)) showSaved(PENDING_SAVE_UNDO.get(endpoint));
2882
+
2786
2883
  form.addEventListener('submit', async (e) => {
2787
2884
  e.preventDefault();
2788
2885
  const out = {};
@@ -2799,46 +2896,12 @@ function buildSettingsForm(cfg, { endpoint }) {
2799
2896
  if (code === 403) {
2800
2897
  resultSlot.replaceChildren(el('div', { class: 'form-note n-err', role: 'alert' }, TOKEN_MSG));
2801
2898
  } else if (data && data.ok) {
2802
- // Not every store behind this form has an undo token — saveAdvocacy() (user-settings.mjs)
2803
- // returns a real backup path but no journalled undoToken the way saveConfig() does, so this
2804
- // button simply does not appear for that form rather than pretending a capability exists.
2805
- const undoBtn = data.undoToken ? el('button', {
2806
- class: 'btn btn-undo btn-sm', type: 'button',
2807
- // try/catch, because this is an async onclick with a network call in it. Without one, a
2808
- // dropped connection rejected the promise, left the button permanently disabled and printed
2809
- // NOTHING — the user is looking at a dead undo button with no idea whether it ran. The
2810
- // server's own explanation is shown verbatim on failure: "this undo has already been used"
2811
- // and "your settings were saved again after this point" are the two cases people will
2812
- // actually hit, and both are worth reading.
2813
- onclick: async (ev) => {
2814
- const btn = ev.currentTarget;
2815
- btn.disabled = true;
2816
- try {
2817
- const r = await postJSON('/api/undo', { undoToken: data.undoToken });
2818
- const ok = r.ok && r.data?.ok;
2819
- if (!ok) btn.disabled = false;
2820
- resultSlot.replaceChildren(el('div', { class: `form-note ${ok ? 'n-ok' : 'n-err'}`, role: 'status' },
2821
- ok
2822
- ? 'Settings restored from the backup. Reload to see the restored values.'
2823
- : (r.status === 403 ? TOKEN_MSG
2824
- : `Undo didn’t complete — ${r.data?.log || 'the backup file still exists, nothing is lost.'}`)));
2825
- } catch (err) {
2826
- btn.disabled = false;
2827
- resultSlot.replaceChildren(el('div', { class: 'form-note n-err', role: 'alert' },
2828
- `Undo couldn’t reach the console server: ${err.message || err}. Nothing was changed.`));
2829
- }
2830
- },
2831
- }, 'Undo save') : null;
2832
- resultSlot.replaceChildren(el('div', { class: 'form-note n-ok', role: 'status' },
2833
- frag(BADGE_OK),
2834
- el('div', { class: 'fn-body' },
2835
- el('b', {}, 'Saved.'), ' Your choices are in ',
2836
- el('span', { class: 'fn-path' }, cfg.path || 'your user folder'), '.',
2837
- data.backup ? el('span', {}, ' Backup kept at ', el('span', { class: 'fn-path' }, data.backup), '.') : ''),
2838
- undoBtn));
2899
+ if (data.undoToken) PENDING_SAVE_UNDO.set(endpoint, data); else PENDING_SAVE_UNDO.delete(endpoint);
2900
+ showSaved(data);
2839
2901
  for (const [key, get] of Object.entries(collectors)) {
2840
2902
  const g = get(); if (!g.secret) initial[key] = g.value;
2841
2903
  }
2904
+ form.dataset.dirty = ''; // saved: nothing left to protect from a repaint
2842
2905
  announce('Settings saved.');
2843
2906
  } else {
2844
2907
  // The server says WHY — a rejected value names itself ("routing: expected one of auto, off").
@@ -2878,14 +2941,26 @@ function nightlyFactsLine(f) {
2878
2941
  const run = f.lastRun === 'ok' ? (f.lastRunAt ? fmtDate(f.lastRunAt) : 'completed')
2879
2942
  : f.lastRun === 'never-ran' ? 'never'
2880
2943
  : f.lastRun === 'failed' ? 'failed' : String(f.lastRun || 'unknown');
2944
+ // Each fact carries ITS OWN evidence. RNBC QA 2026-10-01: the agent's evidence ("No LaunchAgent
2945
+ // plist at …") was printed after "last completed run: failed", reading as the reason the run
2946
+ // failed — while the run's real failure (a preflight refusal, a week earlier) was never shown.
2947
+ const runWhen = f.lastRun === 'failed' && f.lastRunAt ? ` ${fmtDate(f.lastRunAt)}` : '';
2881
2948
  return el('p', { class: `fineprint field-facts${f.agree === false ? ' bp-warn' : ''}` },
2882
- 'switch: ', el('b', {}, choice), ' · agent: ', el('b', {}, agent), ' · last completed run: ', el('b', {}, run),
2883
- f.agree === false ? ' — these disagree, so the toggle above shows no single state until they do.' : '',
2884
- f.enforcementEvidence ? el('span', { class: 'muted' }, ` (${f.enforcementEvidence})`) : '');
2949
+ 'switch: ', el('b', {}, choice),
2950
+ ' · agent: ', el('b', {}, agent),
2951
+ f.enforcementEvidence ? el('span', { class: 'muted nf-agent-ev' }, ` (${f.enforcementEvidence})`) : '',
2952
+ ' · last completed run: ', el('b', {}, run + runWhen),
2953
+ f.lastRunEvidence && f.lastRun !== 'ok' ? el('span', { class: 'muted nf-run-ev' }, ` (${f.lastRunEvidence})`) : '',
2954
+ f.agree === false ? ' — these disagree, so the toggle above shows no single state until they do.' : '');
2885
2955
  }
2886
2956
 
2887
2957
  function renderSettings(cfg, us, bp) {
2888
2958
  const body = $('#body-settings');
2959
+ // UNSAVED CHOICES SURVIVE A REPAINT. RNBC QA 2026-10-01: a background measurement landing while
2960
+ // someone was mid-way through the Settings form rebuilt it and silently threw their selections
2961
+ // away. While any form here has unsaved changes the card is left exactly as the person has it;
2962
+ // the next repaint after they save (or undo their edits) brings it current.
2963
+ if (body && body.querySelector('form.settings-form[data-dirty="1"]')) return;
2889
2964
  // `bp` is NOT counted here any more: its field is rendered by #card-brain at the top of the page,
2890
2965
  // and counting a control this card does not show would make the "N options" chip a lie by one.
2891
2966
  const groups = [cfg, us].filter((c) => c && Array.isArray(c.schema) && c.schema.length);
@@ -2986,15 +3061,21 @@ function renderInventory(inv) {
2986
3061
  inv && inv.reason ? inv.reason : 'The console could not read RVF-GENERATIONS.json from the installed brain, so it is not guessing a list.'));
2987
3062
  return;
2988
3063
  }
2989
- const chips = [chip(`${fmtInt(inv.count)} repositories`, 'green')];
3064
+ // "stores", not "repositories": gist and concept stores are in this list too (RNBC QA 2026-10-01).
3065
+ const chips = [chip(`${fmtInt(inv.count)} stores`, 'green')];
2990
3066
  if (inv.privateCount > 0) chips.push(chip(`${fmtInt(inv.privateCount)} private`, 'cyan', 'Fenced private stores — never leave this machine'));
2991
3067
  if (inv.releaseTag) chips.push(chip(`generation ${inv.releaseTag}`, 'grey'));
2992
3068
  setChips('chips-inventory', chips);
2993
3069
 
2994
- const mb = inv.totalBytes ? `${(inv.totalBytes / 1e6).toFixed(0)} MB on disk` : null;
3070
+ // totalBytes sums each store's .rvf only. It was labelled "on disk" beside a Brain-power card
3071
+ // reporting the same 207 stores as 1.25 GB installed (RVF + passages + sidecars) — both true, but
3072
+ // only one of them is the disk footprint. Say which bytes this is.
3073
+ const mb = inv.totalBytes ? `${(inv.totalBytes / 1e6).toFixed(0)} MB of vector files` : null;
2995
3074
  const summary = el('p', { class: 'muted' },
2996
3075
  `${fmtInt(inv.count)} searchable stores`,
2997
- inv.withProvenance != null ? `, ${fmtInt(inv.withProvenance)} with a recorded upstream commit` : '',
3076
+ // withProvenance counts rows whose SOURCE.json names an upstream repository — not commits (nearly
3077
+ // every row shows its build commit below).
3078
+ inv.withProvenance != null ? `, ${fmtInt(inv.withProvenance)} with a named upstream repository` : '',
2998
3079
  mb ? `, ${mb}.` : '.');
2999
3080
 
3000
3081
  const rows = (inv.stores || []).map((s) => el('li', { class: 'inv-row', 'data-name': s.name.toLowerCase() },
@@ -3281,11 +3362,16 @@ function bpParts(bp) {
3281
3362
  'best for cross-repository reasoning; RuVector Only is the compact choice for people who only ',
3282
3363
  'need the RuVector source. Nightly updates preserve whichever profile is selected.'),
3283
3364
 
3284
- el('p', { class: 'fineprint' },
3285
- 'There are three more switches in the settings file — what it learns from, whether it may act ',
3286
- 'on its own, and whether new projects inherit these choices. They are deliberately not shown ',
3287
- 'as controls here: nothing enforces them yet, and a switch that governs nothing would make ',
3288
- 'every real switch on this page worth less.'));
3365
+ // RNBC QA 2026-10-01: this used to say these switches were "deliberately not shown … nothing
3366
+ // enforces them yet" — while the Settings card rendered all of them and learn-capture/learn-flush,
3367
+ // the console's guarded remedy loop and SessionStart seeding each read one. A sentence about the
3368
+ // page that the page itself contradicts is the failure this card exists to prevent.
3369
+ el('p', { class: 'fineprint bp-more-settings' },
3370
+ 'Four more choices shape what the brain does while it is on — what it learns from, how much it ',
3371
+ 'jumps in, whether it may act on its own, and whether new projects inherit these choices. They ',
3372
+ 'are in ',
3373
+ el('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => jumpToSetting('learningScope') }, 'Settings'),
3374
+ ', and each one names the runtime that enforces it.'));
3289
3375
  }
3290
3376
 
3291
3377
  /* -------------------------------------------------- section 7: trust & provenance
@@ -3298,12 +3384,12 @@ const TRUST_INFO = {
3298
3384
  signature: [
3299
3385
  { k: 'What is this?', t: 'The sha256 fingerprint of the release bundle, published as its own asset on every GitHub release.' },
3300
3386
  { k: 'Why does it matter?', t: 'If your download’s fingerprint matches the published one, the bundle is byte-identical to what was released — nothing altered, nothing truncated.' },
3301
- { k: 'How do I use it?', t: 'Run shasum -a 256 ruvnet-brain.zip on your download and compare. v3.3 adds a one-click local check right here.' },
3387
+ { k: 'How do I use it?', t: 'Run shasum -a 256 ruvnet-brain.zip on your download and compare. A one-click local check here is planned.' },
3302
3388
  ],
3303
3389
  sbom: [
3304
3390
  { k: 'What is this?', t: 'A Software Bill of Materials — the complete, machine-readable list of every package inside the bundle.' },
3305
3391
  { k: 'Why does it matter?', t: 'You can see what’s in the box without unzipping it, and scanners can watch it for known vulnerabilities.' },
3306
- { k: 'How does it help me?', t: 'v3.3 attaches a CycloneDX SBOM to every release; this row will then show its package count and digest, measured from the published asset.' },
3392
+ { k: 'How does it help me?', t: 'Attaching a CycloneDX SBOM to every release is planned; this row will then show its package count and digest, measured from the published asset.' },
3307
3393
  ],
3308
3394
  channel: [
3309
3395
  { k: 'What is this?', t: 'How your plugin updates: riding the latest release, or pinned to a version you chose.' },
@@ -3340,7 +3426,7 @@ function renderTrust(t) {
3340
3426
  rel.ok ? chip('sha256 published ✓', 'green', 'The release bundle’s fingerprint is published and was read live this session')
3341
3427
  : chip('digest unreachable', 'warn', 'Couldn’t read the published digest this session'),
3342
3428
  sb.present ? chip(`SBOM · ${sb.componentCount} component${sb.componentCount === 1 ? '' : 's'} ✓`, 'green', 'A local CycloneDX SBOM was found and read live this session')
3343
- : chip('SBOM — v3.3', 'coming', 'A CycloneDX SBOM ships with every release from v3.3'),
3429
+ : chip('SBOM — planned', 'coming', 'Releases do not carry a CycloneDX SBOM yet; it is planned'),
3344
3430
  ch.installed ? chip(ch.channel === 'pinned' ? 'pinned' : 'latest channel', 'cyan') : chip('no plugin install', 'grey'),
3345
3431
  ]);
3346
3432
 
@@ -3370,7 +3456,7 @@ function renderTrust(t) {
3370
3456
  /* 2 · SBOM — real once `npm run sbom` has been run locally; honest empty state until then */
3371
3457
  rows.push(trustRow({
3372
3458
  name: 'SBOM', info: TRUST_INFO.sbom, coming: !sb.present,
3373
- status: sb.present ? chip(`${sb.componentCount} component${sb.componentCount === 1 ? '' : 's'}`, 'green') : chip('coming · v3.3', 'coming'),
3459
+ status: sb.present ? chip(`${sb.componentCount} component${sb.componentCount === 1 ? '' : 's'}`, 'green') : chip('planned', 'coming'),
3374
3460
  value: sb.present ? [
3375
3461
  el('p', {}, 'A ', el('b', {}, `CycloneDX ${sb.specVersion || ''}`.trim()), ' SBOM exists on this machine: ',
3376
3462
  el('b', {}, `${sb.componentCount} component${sb.componentCount === 1 ? '' : 's'}`),
@@ -3383,9 +3469,8 @@ function renderTrust(t) {
3383
3469
  el('p', {}, sb.error
3384
3470
  ? `Found sbom/ruvnet-brain.cdx.json but couldn’t read it (${sb.error}).`
3385
3471
  : 'Not generated yet on this machine — nothing to show, so nothing is shown.'),
3386
- el('p', {}, 'Run ', el('code', {}, 'npm run sbom'),
3387
- ' to produce a CycloneDX SBOM of the shipped dependency tree right now. From ', el('b', {}, 'v3.3'),
3388
- ' every published release carries one too, measured from the release asset itself.'),
3472
+ el('p', {}, 'Published releases do not carry an SBOM yet — attaching one, measured from the release asset itself, is planned. ',
3473
+ 'From a source checkout, ', el('code', {}, 'npm run sbom'), ' produces a CycloneDX SBOM of the shipped dependency tree.'),
3389
3474
  ],
3390
3475
  }));
3391
3476
 
@@ -3451,7 +3536,7 @@ function renderTrust(t) {
3451
3536
  el('button', { class: 'adv-opt on', type: 'button', title: 'Your current behavior — consent-gated changes with undo' },
3452
3537
  el('span', { class: 'pc-dot', 'aria-hidden': 'true' }), 'Full — recommended'),
3453
3538
  advisorBtn,
3454
- el('span', { class: 'preview-tag' }, 'preview · v3.3')),
3539
+ el('span', { class: 'preview-tag' }, 'preview · planned')),
3455
3540
  advNote,
3456
3541
  ],
3457
3542
  }));
@@ -3459,7 +3544,7 @@ function renderTrust(t) {
3459
3544
  body.replaceChildren(
3460
3545
  el('p', { class: 'lead-stat' },
3461
3546
  'Provenance you can check, not take on faith — ', el('b', {}, String(liveCount)),
3462
- ` measurement${liveCount === 1 ? ' is' : 's are'} live today; the rest of this card names exactly what v3.3 will measure.`,
3547
+ ` measurement${liveCount === 1 ? ' is' : 's are'} live today; the rest of this card names exactly what is planned and not yet measured.`,
3463
3548
  infoBtn('Trust & provenance', TRUST_CARD_INFO)),
3464
3549
  el('div', { class: 'trust-list', 'data-trust-ready': '1' }, ...rows),
3465
3550
  );
@@ -3476,6 +3561,10 @@ function trustSkeleton() {
3476
3561
  /* ---------------------------------------------- header update gong (owner, 2026-07-24) */
3477
3562
 
3478
3563
  let BRAIN_INSTALLED_VERSION = null; // set by renderHost from /api/state's host.brainVersion
3564
+ // The last /api/trust payload. State and trust load in parallel; when trust lands first the gong is
3565
+ // decided with no installed version, so renderHost decides it again once the version is known
3566
+ // (RNBC 2026-10-01: under load the gong never appeared).
3567
+ let LAST_TRUST = null;
3479
3568
 
3480
3569
  // Numeric x.y.z only; -dev/-rc suffixes are deliberately ignored so a tie is NEVER "newer" —
3481
3570
  // a false gong is the nag this page promises not to be. Latest unreachable (null) is UNKNOWN,
@@ -3495,10 +3584,15 @@ function renderUpdateGong(t) {
3495
3584
  const latest = t && t.release && t.release.tag ? String(t.release.tag).replace(/^v/, '') : null;
3496
3585
  if (!BRAIN_INSTALLED_VERSION || !latest || cmpVer(latest, BRAIN_INSTALLED_VERSION) <= 0) { btn.hidden = true; return; }
3497
3586
  const cmd = 'npx ruvnet-brain --update';
3498
- btn.textContent = `⟳ update available · v${latest} — click to update`;
3587
+ // SHORT ON PURPOSE. RNBC QA 2026-10-01: the long label ("⟳ update available · vX — click to update",
3588
+ // then "copied — paste in any terminal: npx …") overflowed the brand block and sat UNDER the
3589
+ // "what's in the brain" link at 1440px, so clicking the gong navigated to the scope page instead.
3590
+ // The full instruction lives in the title and the live region.
3591
+ btn.textContent = `⟳ v${latest} available`;
3592
+ btn.title = `A newer release (v${latest}) is out — click to copy the update command: ${cmd}`;
3499
3593
  btn.onclick = async () => {
3500
- try { await navigator.clipboard.writeText(cmd); btn.textContent = `copied — paste in any terminal: ${cmd}`; }
3501
- catch { btn.textContent = `run in any terminal: ${cmd}`; }
3594
+ try { await navigator.clipboard.writeText(cmd); btn.textContent = '✓ update command copied'; announce(`Copied: ${cmd} — paste it in any terminal.`); }
3595
+ catch { btn.textContent = `run: ${cmd}`; announce(`Run in any terminal: ${cmd}`); }
3502
3596
  };
3503
3597
  btn.hidden = false;
3504
3598
  }
@@ -3506,6 +3600,7 @@ function renderUpdateGong(t) {
3506
3600
  async function loadTrust() {
3507
3601
  try {
3508
3602
  const t = await getJSON('/api/trust');
3603
+ LAST_TRUST = t;
3509
3604
  renderTrust(t);
3510
3605
  renderUpdateGong(t);
3511
3606
  } catch (err) {
@@ -4288,8 +4383,10 @@ function renderLessons(data) {
4288
4383
  chip(r.origin, r.userStated ? 'green' : 'nt',
4289
4384
  r.userStated
4290
4385
  ? 'You said this. Only lessons you stated yourself are allowed to reach the strongest level.'
4291
- : r.quarantined
4292
- ? 'This came from bundled maintainer or demonstration history. It is not your statement and cannot be turned into your personal policy.'
4386
+ : (r.sourceClass === 'imported-owner' || r.sourceClass === 'demonstration')
4387
+ ? (r.quarantined
4388
+ ? 'This came from bundled maintainer or demonstration history. It is not your statement and cannot be turned into your personal policy.'
4389
+ : 'This came from imported maintainer history and was ratified, so it is in force here. You can switch it off; it cannot be raised to "Stops me".')
4293
4390
  : 'I inferred this from what happened. A lesson I inferred can never be raised to "Stops me", however often it fires — the model does not get to ratify its own rules.'),
4294
4391
  r.taughtCount ? chip(`taught ${r.taughtCount}×`, 'grey') : null,
4295
4392
  r.awaitingYou ? chip('awaiting your decision', 'amber', 'Recorded, but you have not agreed to it yet.') : null,
@@ -477,7 +477,7 @@
477
477
  card in this section is L1 doing its job; anything with an undo behind it is L2. Full definitions
478
478
  and the tests each level has to pass: <code>docs/adr/0028</code>.</p>
479
479
  <p class="fineprint">The one thing on this list you can actually set is the volume on advocacy
480
- itself: the <a href="#field-advocacy"><b>“How much it volunteers”</b></a> dial, in the Settings
480
+ itself: the <a href="#field-advocacy"><b>“How much it jumps in”</b></a> dial, in the Settings
481
481
  card in this same section. Everything above is a description of the software, not a switch.</p>
482
482
  </div>
483
483
  </details>
@@ -4,6 +4,7 @@
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
6
  <title>RuvNet Brain — install architecture</title>
7
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 64 64%22><rect x=%222%22 y=%222%22 width=%2260%22 height=%2260%22 rx=%2216%22 fill=%22%230b0d12%22 stroke=%22%232c3850%22 stroke-width=%221.5%22/><path d=%22M25 48 C17 48 11.5 41.5 13.5 34.5 C10 29 13 21 19 19 C21 12.5 29 9.5 35 12 C43 9 50.5 14.5 50.5 22 C55 26 55 34.5 50 38 C49 43.5 43 47.5 37 46.5 L31 47.5 Z%22 fill=%22none%22 stroke=%22%23f0a830%22 stroke-width=%223.4%22 stroke-linejoin=%22round%22/><path d=%22M31 47 L31 33 M31 33 L24 27 M31 33 L40 24.5 M31 33 L41 36.5%22 fill=%22none%22 stroke=%22%23f0a830%22 stroke-width=%222.4%22 stroke-linecap=%22round%22 opacity=%22.9%22/><circle cx=%2224%22 cy=%2227%22 r=%223.4%22 fill=%22%235ad6ff%22/><circle cx=%2240%22 cy=%2224.5%22 r=%223.4%22 fill=%22%23f4b13a%22/><circle cx=%2241%22 cy=%2236.5%22 r=%223.4%22 fill=%22%235fd38a%22/></svg>">
7
8
  <style>
8
9
  :root {
9
10
  --bg: #f4f1ea; --card: #fbfaf6; --ink: #26221b; --ink-2: #5b5648; --muted: #8a8474;
package/console/scope.css CHANGED
@@ -100,8 +100,11 @@ table.scope thead th {
100
100
  position: sticky; top: 58px; z-index: 2;
101
101
  background: color-mix(in srgb, var(--surface-2) 92%, var(--bg));
102
102
  font-family: var(--mono); font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--muted);
103
- cursor: pointer; user-select: none; white-space: nowrap;
103
+ user-select: none; white-space: nowrap;
104
104
  }
105
+ /* Only sortable headers look clickable (RNBC QA 2026-10-01: the commits/revisions headers wore a
106
+ pointer cursor and did nothing when clicked). */
107
+ table.scope thead th[data-key] { cursor: pointer; }
105
108
  table.scope thead th[aria-sort="descending"]::after { content: " ▾"; color: var(--amber-text); }
106
109
  table.scope thead th[aria-sort="ascending"]::after { content: " ▴"; color: var(--amber-text); }
107
110
  table.scope tbody tr:nth-child(even) td { background: color-mix(in srgb, var(--surface-2) 40%, transparent); }
package/console/style.css CHANGED
@@ -229,6 +229,12 @@ p:last-child { margin-bottom: 0; }
229
229
  }
230
230
 
231
231
  .head-right { display: inline-flex; align-items: center; gap: 10px; }
232
+ /* The header is full at desktop widths. When the update gong appears it needs ~155px; the host chip
233
+ (also shown in the intro's host line) gives way so the gong never slides under the header links
234
+ (RNBC QA 2026-10-01: it did, and clicking it opened the scope page). The brand text truncates. */
235
+ .brand > * { flex: none; }
236
+ .brand > .brand-text { flex: 0 1 auto; min-width: 0; }
237
+ .head-inner:has(#brain-update:not([hidden])) .host-chip { display: none; }
232
238
  .host-chip {
233
239
  font-size: 12.5px; color: var(--ink-2);
234
240
  border: 1px solid var(--hair); border-radius: 100px; padding: 4px 11px;
@@ -247,6 +253,9 @@ p:last-child { margin-bottom: 0; }
247
253
  color: #7c3f00; background: rgba(217, 119, 6, .14);
248
254
  border: 1px solid rgba(217, 119, 6, .55); border-radius: 100px; padding: 3px 10px;
249
255
  margin-left: 6px; animation: gong-pulse 2.2s ease-in-out infinite;
256
+ /* Kept short in app.js and never squeezed: the brand text truncates before the gong does. A long
257
+ gong once overflowed under the header links, so clicking it opened the scope page. */
258
+ flex: none;
250
259
  }
251
260
  :root[data-theme="dark"] .update-gong { color: #fbbf24; background: rgba(217, 119, 6, .18); }
252
261
  .update-gong:hover { animation-play-state: paused; }
@@ -1137,6 +1146,10 @@ details.sub .sub-body > *:first-child { margin-top: 4px; }
1137
1146
  page-wide horizontal scrollbar. Cap + ellipsis keeps the click target and the full text in the
1138
1147
  title tooltip; nothing about the update claim is hidden, only its overflow. */
1139
1148
  .update-gong { max-width: 46vw; overflow: hidden; text-overflow: ellipsis; }
1149
+ /* RNBC QA 2026-10-01, measured at 390px: the header did not fit one row — the version chip sat
1150
+ under the scope link and the theme button was pushed off-screen. Let it wrap to two rows. */
1151
+ .head-inner { flex-wrap: wrap; height: auto; min-height: 58px; row-gap: 6px; padding-block: 8px; }
1152
+ .head-right { flex-wrap: wrap; justify-content: flex-end; row-gap: 6px; margin-left: auto; }
1140
1153
  .fact { grid-template-columns: minmax(0, 1fr); gap: 3px; }
1141
1154
  .dim { grid-template-columns: auto minmax(0, 1fr); }
1142
1155
  .dim-ded { grid-row: auto; grid-column: 2; }
package/console/tips.html CHANGED
@@ -492,11 +492,11 @@
492
492
  <div class="peg">
493
493
  <h3 class="peg-cap mono">slash commands</h3>
494
494
  <ul class="peg-list">
495
- <li><code>/ruvnet-brain:rvbc</code></li>
496
- <li><code>/rvcb</code></li>
497
- <li><code>/ruvnet-brain:configure</code></li>
495
+ <li><code>/rnbc</code></li>
496
+ <li><code>/rnb</code></li>
497
+ <li><code>/rvbc</code></li>
498
498
  </ul>
499
- <p class="peg-note">All three open the same place — the console, the live mirror of your stack.</p>
499
+ <p class="peg-note">All three open the same place — the console, the live mirror of your stack (so do <code>/brain-console</code> and <code>/ruvnet-brain:configure</code>).</p>
500
500
  </div>
501
501
  <div class="peg">
502
502
  <h3 class="peg-cap mono">skills</h3>