ruvnet-brain 4.4.1 → 4.5.0

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 +258 -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
@@ -48,7 +48,7 @@ import { inspectSessionSnapshots } from './session-snapshot-contract.mjs';
48
48
  import { learnings } from './learnings.mjs';
49
49
  import { gatesSurvey } from './gates.mjs';
50
50
  // The write-safety primitives, borrowed rather than re-implemented. See saveConfig for why.
51
- import { withLock, writeAtomic, LOCK_WAIT_MS, loadSettings, saveSettings, SETTINGS_SCHEMA as USER_SETTINGS_SCHEMA } from './user-settings.mjs';
51
+ import { withLock, writeAtomic, LOCK_WAIT_MS, loadSettings, saveSettings, revertSettings, SETTINGS_SCHEMA as USER_SETTINGS_SCHEMA } from './user-settings.mjs';
52
52
  // The brain on/off switch (ADR-054). The sentinel is the enforcement artifact; settings.json holds
53
53
  // only a mirror. The console is the ONE surface allowed to flip it — protect-brain-state.sh walls
54
54
  // the file off from agent edits — so both halves of the write live here, in saveBrainPower().
@@ -68,6 +68,7 @@ import {
68
68
  openRouterCredentialStatus,
69
69
  saveOpenRouterCredential,
70
70
  learnerCwd,
71
+ loadRuntimePreferences,
71
72
  } from '../plugin/scripts/runtime-preferences.mjs';
72
73
  import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
73
74
  // One canonical answer to "which directory is this, and have I counted it already?" — shared with
@@ -770,6 +771,30 @@ function gatherInventory() {
770
771
  };
771
772
  }
772
773
 
774
+ /**
775
+ * Keys a project-level <project>/.swarm/ruvnet-brain-settings.json overrides FOR THE RUNTIME THAT
776
+ * READS IT. runtime-preferences merges that file over the user-level choices, and route-cheap, the
777
+ * managed-CLI gate (routing, qeFleet) and learn-capture/learn-flush (learningScope) obey the merge.
778
+ * The console saves user-level values only, so without this a project seeded by "Apply these choices
779
+ * to new projects" silently ignored every later change made here (RNBC QA 2026-10-01). Only keys whose
780
+ * consumer honours the project file are reported — advocacy, autoApply and provider are read at user
781
+ * level by their consumers, so a project value for them changes nothing and is not claimed to.
782
+ */
783
+ const PROJECT_HONOURED_KEYS = Object.freeze(['routing', 'qeFleet', 'learningScope']);
784
+ function projectOverrides(keys) {
785
+ try {
786
+ const prefs = loadRuntimePreferences({ cwd: process.cwd() });
787
+ if (!prefs.projectInherited) return null;
788
+ const raw = readJSON(prefs.paths.project) || {};
789
+ const values = raw.values && typeof raw.values === 'object' ? raw.values : raw;
790
+ const out = {};
791
+ for (const key of keys) {
792
+ if (PROJECT_HONOURED_KEYS.includes(key) && Object.hasOwn(values, key) && prefs.values[key] === values[key]) out[key] = values[key];
793
+ }
794
+ return Object.keys(out).length ? { path: prefs.paths.project.replace(CONSOLE_ROOT, '~'), values: out } : null;
795
+ } catch { return null; }
796
+ }
797
+
773
798
  function gatherConfig() {
774
799
  const cfg = readJSON(CONFIG_PATH) || {};
775
800
  const credential = openRouterCredentialStatus({ cwd: process.cwd() });
@@ -797,6 +822,7 @@ function gatherConfig() {
797
822
  // What the project would pick FOR you, kept separate from what you actually picked. The form can
798
823
  // then say "recommended: on" without ever claiming that is the current state.
799
824
  defaults: { provider: 'auto', nightly: true, routing: 'auto', qeFleet: false },
825
+ projectOverrides: projectOverrides(CONFIG_SCHEMA.map((field) => field.key)),
800
826
  schema: CONFIG_SCHEMA.filter((field) =>
801
827
  !Object.hasOwn(CONFIG_CONTROL_SUPPORT, field.key)
802
828
  && (field.key !== 'nightly' || schedule.artifact.supported)),
@@ -841,6 +867,7 @@ function gatherAdvocacy() {
841
867
  Object.hasOwn(chosen, field.key) ? state.values[field.key] : null,
842
868
  ])),
843
869
  defaults: Object.fromEntries(LIVE_USER_FIELDS.map((field) => [field.key, field.default])),
870
+ projectOverrides: projectOverrides(LIVE_USER_SETTING_KEYS),
844
871
  schema: LIVE_USER_FIELDS,
845
872
  unavailable: [],
846
873
  };
@@ -880,8 +907,19 @@ function saveAdvocacy(values) {
880
907
  const result = saveSettings(supplied);
881
908
  if (!result.ok) return { ok: false, rejected: result.errors || [], log: result.log };
882
909
  publishSettingsToCache();
910
+ // THE SAVE IS REVERSIBLE FROM WHERE IT WAS MADE. The Settings card promises "every save is
911
+ // reversible", and saveSettings already writes the backup and the existedBefore flag its own
912
+ // revertSettings() consumes — but this form returned no undo token, so the console showed an Undo
913
+ // button for config.json and none here (RNBC QA 2026-10-01). Journalled only after the write.
914
+ const undoToken = journalUndo({
915
+ kind: 'restore-user-settings',
916
+ file: result.file,
917
+ backup: result.backup,
918
+ existedBefore: result.existedBefore,
919
+ });
883
920
  return {
884
921
  ok: true,
922
+ undoToken,
885
923
  backup: result.backup ? result.backup.replace(CONSOLE_ROOT, '~') : null,
886
924
  values: Object.fromEntries(LIVE_USER_SETTING_KEYS.map((key) => [key, result.values[key]])),
887
925
  log: result.log,
@@ -1113,6 +1151,14 @@ function publishSettingsToCache() {
1113
1151
  if (!c || !c.data || !c.data.sections) return;
1114
1152
  c.data.sections.config = gatherConfig();
1115
1153
  c.data.sections.userSettings = gatherAdvocacy();
1154
+ // The Savings card reads two of the same choices (routing, and whether an OpenRouter key exists).
1155
+ // Patching only Settings left the Savings card saying "No key added" / the old routing state on
1156
+ // the next reload, beside a Settings card saying the opposite (RNBC QA 2026-10-01).
1157
+ if (c.data.sections.savings) {
1158
+ const cfgNow = readJSON(CONFIG_PATH) || {};
1159
+ c.data.sections.savings.routing = cfgNow.routing === 'off' ? 'off' : cfgNow.routing === 'auto' ? 'auto' : null;
1160
+ try { c.data.sections.savings.routerEngine = gatherRouterEngine(); } catch { /* the refresh replaces it */ }
1161
+ }
1116
1162
  writeCache(STATE_CACHE, new Date(0).toISOString(), c.data, c.scope ?? null);
1117
1163
  } catch { /* the authoritative stores are already correct; refresh will replace an unreadable cache */ }
1118
1164
  }
@@ -1199,7 +1245,13 @@ function gatherLessons() {
1199
1245
  const trig = TRIGGER_BY_KEY.get(l.trigger);
1200
1246
  const meaning = ENFORCEMENT_MEANING[l.enforcement] || { label: l.enforcement, detail: '' };
1201
1247
  const userStated = l.origin === ORIGIN.USER_STATED && l.sourceClass === SOURCE_CLASS.CURRENT_USER;
1202
- const quarantined = l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION;
1248
+ // Quarantine is about whether history can BECOME policy, so it applies to an imported row that
1249
+ // was never ratified. A row that WAS ratified is delivered by lessonsFor() today whatever its
1250
+ // source class — RNBC QA 2026-10-01 measured 12 such rows filed here as "quarantined, cannot be
1251
+ // switched on" behind a checked, disabled box while the gate enforced every one of them. A rule in
1252
+ // force must be reported in force and must keep a working off switch.
1253
+ const importedClass = l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION;
1254
+ const quarantined = importedClass && l.status !== STATUS.RATIFIED && l.status !== STATUS.ACTIVE;
1203
1255
  const origin = l.sourceClass === SOURCE_CLASS.CURRENT_USER
1204
1256
  ? 'you taught me this'
1205
1257
  : l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER
@@ -1234,7 +1286,7 @@ function gatherLessons() {
1234
1286
  // Honest ceiling: ratifying a model-inferred lesson can NOT raise it to block
1235
1287
  // (lesson-store.mjs:380). Say so before they click, not after.
1236
1288
  canReachBlock: userStated,
1237
- canRatify: !quarantined,
1289
+ canRatify: !importedClass,
1238
1290
  intendedEnforcement: l.intendedEnforcement || null,
1239
1291
  };
1240
1292
  });
@@ -1969,8 +2021,12 @@ function gatherRouterEngine() {
1969
2021
  const cfg = readJSON(CONFIG_PATH) || {};
1970
2022
  // User-constraint detection (Brain-side by design — a fact about THIS user, not routing logic):
1971
2023
  // an OpenRouter key decides whether metered cross-provider candidates are even reachable.
1972
- let openrouterKey = !!process.env.OPENROUTER_API_KEY;
1973
- if (!openrouterKey) openrouterKey = !!(cfg.openrouterKey && String(cfg.openrouterKey).length > 8);
2024
+ // The SAME reader the Settings card uses (env → SOPS+age store → legacy plaintext). This read only
2025
+ // env + plaintext, so a key saved through Settings (encrypted, plaintext retired) showed "No key
2026
+ // added" here under a Settings row saying "•••• set" (RNBC QA 2026-10-01).
2027
+ let openrouterKey = false;
2028
+ try { openrouterKey = openRouterCredentialStatus({ cwd: process.cwd() }).configured === true; }
2029
+ catch { openrouterKey = !!process.env.OPENROUTER_API_KEY || !!(cfg.openrouterKey && String(cfg.openrouterKey).length > 8); }
1974
2030
  // House (issue #21): three mechanisms used to disagree — Settings wrote config.json's `provider`,
1975
2031
  // but the chip strip derived "yours" from whichever pool candidate happened to be
1976
2032
  // subscriptionCovered first, sourced from profile.json (a file nothing in the console writes). The
@@ -2383,7 +2439,7 @@ function gatherStack() {
2383
2439
  const rows = a.rows.map((r) => ({ name: r.name, installed: r.installed, target: r.target, tag: r.tag, state: r.state, source: r.source ?? 'npm-global', marketplace: r.marketplace ?? null }));
2384
2440
  const shadows = a.shadows.map((s) => ({ name: s.name, version: s.version, global: s.global, dir: String(s.dir).replace(SYSTEM_HOME, '~'), stale: !!(s.global && s.version !== s.global) }));
2385
2441
  const by = (st) => rows.filter((r) => r.state === st).length;
2386
- const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), shadows: shadows.length, stale: a.stale.length };
2442
+ const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), unverified: by('INSTALLED_UNVERIFIED'), shadows: shadows.length, stale: a.stale.length };
2387
2443
  const recommendations = buildStackRecommendations({ rows: a.rows, stale: a.stale });
2388
2444
  const result = { error: a.error, packages: rows, shadows, summary, recommendations };
2389
2445
  // Cache the last good audit so repeat page-loads render instantly ("as of HH:MM — re-checking").
@@ -2708,7 +2764,12 @@ function saveConfig(values) {
2708
2764
  credentialChange = saveOpenRouterCredential(requestedSecret, { cwd: process.cwd() });
2709
2765
  if (!credentialChange.ok) return { ok: false, rejected, log: credentialChange.log };
2710
2766
  }
2711
- if (requestedNightly !== undefined) {
2767
+ // The page sends every field the person has ever chosen, so changing only the model house carries the
2768
+ // already-saved nightly value with it. Re-running the installer for a choice the scheduler already
2769
+ // satisfies rewrote the plist and re-registered the runner on every unrelated save (RNBC review
2770
+ // 2026-10-01). Only a request that differs from the measured scheduler state is a scheduler change; a
2771
+ // degraded or unknown state still goes to the installer, which is how it gets repaired.
2772
+ if (requestedNightly !== undefined && nightlyStatus().state !== (requestedNightly ? 'on' : 'off')) {
2712
2773
  nightlyChange = applyNightlyChoice(requestedNightly);
2713
2774
  if (!nightlyChange.ok) {
2714
2775
  rollbackCredential();
@@ -2809,6 +2870,10 @@ function undo(undoToken) {
2809
2870
  if (!fs.existsSync(UNDO_JOURNAL)) return { ok: false, log: 'no undo history' };
2810
2871
  const journal = readUndoJournal();
2811
2872
  const entry = journal.find((e) => e.token === undoToken);
2873
+ // "Saved again after this point" is decided by POSITION in the append-only journal, never by the `at`
2874
+ // stamp: it has millisecond resolution and two saves do land in the same millisecond (a Linux runner,
2875
+ // 2026-10-01), which made the later save invisible and let a stale undo wipe it.
2876
+ const savedLater = (kind) => journal.slice(journal.indexOf(entry) + 1).some((e) => e.kind === kind && e.token && e.token !== undoToken);
2812
2877
  if (!entry) return { ok: false, log: 'that undo token was not found' };
2813
2878
 
2814
2879
  // ONE UNDO, ONCE. The token was never consumed, so the same button replayed forever: clicking it
@@ -2829,7 +2894,7 @@ function undo(undoToken) {
2829
2894
  // An undo can only speak for the last write. If something was written after it, the honest answer
2830
2895
  // is to refuse and say so — restoring anyway would be destroying newer data while claiming to
2831
2896
  // protect older data.
2832
- const laterSave = journal.some((e) => e.kind === 'restore-config' && e.at > entry.at && e.token !== undoToken);
2897
+ const laterSave = savedLater('restore-config');
2833
2898
  if (laterSave) {
2834
2899
  return { ok: false, log: 'your settings were saved again after this point, so this undo would wipe out that newer save — nothing was changed. Use the undo from the most recent save, or restore a backup by hand.' };
2835
2900
  }
@@ -2869,6 +2934,19 @@ function undo(undoToken) {
2869
2934
  }
2870
2935
  return { ok: false, log: 'no backup available to restore' };
2871
2936
  }
2937
+ if (entry.kind === 'restore-user-settings') {
2938
+ // Same "an undo speaks only for the last write" rule as restore-config: a later save through this
2939
+ // form would be wiped out by restoring an older backup.
2940
+ const laterSave = savedLater('restore-user-settings');
2941
+ if (laterSave) {
2942
+ return { ok: false, log: 'your settings were saved again after this point, so this undo would wipe out that newer save — nothing was changed. Use the undo from the most recent save.' };
2943
+ }
2944
+ const r = revertSettings({ file: entry.file, backup: entry.backup || undefined, existedBefore: entry.existedBefore });
2945
+ if (!r.ok) return { ok: false, log: r.log };
2946
+ markUndoConsumed(undoToken);
2947
+ publishSettingsToCache();
2948
+ return { ok: true, log: entry.backup ? 'restored your previous settings' : 'removed the settings file (there was none before this save)' };
2949
+ }
2872
2950
  // EVERY branch below marks its token consumed on success, for the reason spelled out on the
2873
2951
  // restore-config branch above: these all copy a saved snapshot over a live file, so replaying one
2874
2952
  // re-applies an old state over whatever the user has done since. The replay guard at the top of
@@ -2953,7 +3031,7 @@ function undo(undoToken) {
2953
3031
  // The undo kinds this function actually implements. Exported so the closure test can check the
2954
3032
  // registry against the REAL handler set rather than a hand-copied list that would drift from it.
2955
3033
  export const HANDLED_UNDO_KINDS = Object.freeze([
2956
- 'restore-config', 'reinstall-version', 'restore-backup',
3034
+ 'restore-config', 'restore-user-settings', 'reinstall-version', 'restore-backup',
2957
3035
  'restore-memory-backup', 'restore-store-backups', 'restore-project-distill', 'auto-rebuild', 'none',
2958
3036
  ]);
2959
3037
 
@@ -2962,7 +3040,10 @@ const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; cha
2962
3040
  function serveStatic(req, res) {
2963
3041
  const rel = decodeURIComponent(req.url.split('?')[0]).replace(/^\/+/, '') || 'index.html';
2964
3042
  const file = path.join(CONSOLE_DIR, rel);
2965
- if (!file.startsWith(CONSOLE_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
3043
+ // Containment by path, not by string prefix: `startsWith(CONSOLE_DIR)` also accepted a sibling such
3044
+ // as `<root>/console-anything/…` reached with an encoded `..` (RNBC review 2026-10-01).
3045
+ const inside = path.relative(CONSOLE_DIR, file);
3046
+ if (!inside || inside === '..' || inside.startsWith(`..${path.sep}`) || path.isAbsolute(inside) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
2966
3047
  let body = fs.readFileSync(file);
2967
3048
  const ext = path.extname(file);
2968
3049
  if (ext === '.html') body = Buffer.from(String(body).replace('</head>', `<script>window.__CONSOLE_TOKEN__=${JSON.stringify(TOKEN)}</script></head>`));
@@ -3444,6 +3525,8 @@ export {
3444
3525
  saveBrainPower,
3445
3526
  gatherBrainProfile,
3446
3527
  saveBrainProfile,
3528
+ setLesson,
3529
+ gatherLessons,
3447
3530
  gatherRouterEngine,
3448
3531
  autoEligibleIds,
3449
3532
  gatherConfig,
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/oracle/abstain-threshold-sweep.mjs — what would moving the abstain threshold do?
4
+ *
5
+ * The product abstains when the top cross-encoder logit is below 0. The threshold is applied AFTER
6
+ * scoring, so replaying a measured run at another threshold t is exact for the top citation: no
7
+ * model is re-run. For each t it reports, with 95% Wilson intervals:
8
+ * (abstained = no citation, or a numeric top logit < t -- the rule both harnesses apply)
9
+ * needs confident hits (gold or pre-registered alternative within 5 AND top logit >= t),
10
+ * confident misses (top logit >= t but neither within 5), their precision, and how
11
+ * many confident answers at least cite the gold repository first
12
+ * offTopic adversarial questions still abstained (no citation, or top logit < t)
13
+ * heldOut routed passes of the named/described/scenario strata (grounded AND routed AND
14
+ * a citation with logit >= t), the gated eval-brain metric
15
+ *
16
+ * node scripts/oracle/abstain-threshold-sweep.mjs --needs <needs.json> --adversarial <adversarial.json>
17
+ * --heldout <heldout.json> [--thresholds 0,-1,-2,-3,-4] [--out <file>]
18
+ */
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { wilson } from '../eval-brain.mjs';
23
+
24
+ const rate = (k, n) => { const w = wilson(k, n); return { k, n, p: +w.p.toFixed(4), lo: +w.lo.toFixed(4), hi: +w.hi.toFixed(4) }; };
25
+ const within5 = (r) => (r.fileRank != null && r.fileRank <= 5) || (r.altFileRank != null && r.altFileRank <= 5);
26
+
27
+ export function sweep({ needs = [], adversarial = [], heldout = [] }, thresholds) {
28
+ return thresholds.map((t) => {
29
+ // The abstain rule both harnesses apply (measure-need-set scoreRow, eval-brain gradeQuestion): no
30
+ // citation, or a NUMERIC top logit below the threshold. A citation without a logit (a card answer)
31
+ // is not an abstention.
32
+ const answered = (cited, ce) => Boolean(cited) && !(typeof ce === 'number' && ce < t);
33
+ const confident = needs.filter((r) => answered(r.cited, r.topCe));
34
+ const hits = confident.filter(within5).length;
35
+ const routedRows = heldout.filter((r) => ['named', 'described', 'scenario'].includes(r.stratum));
36
+ return {
37
+ threshold: t,
38
+ needs: { confidentHit: rate(hits, needs.length), confidentMiss: rate(confident.length - hits, needs.length),
39
+ precision: rate(hits, confident.length),
40
+ // Weaker than a file hit: the top citation is at least from the gold repository.
41
+ confidentRightRepo: rate(confident.filter((r) => r.repoRank === 1).length, confident.length) },
42
+ offTopicAbstain: rate(adversarial.filter((r) => !answered(r.citedPath, r.ce)).length, adversarial.length),
43
+ heldOutRouted: rate(routedRows.filter((r) => r.grounded && r.routed && answered(r.citedPath, r.ce)).length, routedRows.length),
44
+ };
45
+ });
46
+ }
47
+
48
+ async function main() {
49
+ const args = process.argv.slice(2);
50
+ const arg = (n) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : undefined; };
51
+ const read = (f) => (f ? JSON.parse(fs.readFileSync(f, 'utf8')).rows : []);
52
+ const thresholds = String(arg('--thresholds') || '0,-0.5,-1,-1.5,-2,-3,-4').split(',').map(Number);
53
+ const out = { kind: 'ruvnet-brain-abstain-threshold-sweep',
54
+ rows: sweep({ needs: read(arg('--needs')), adversarial: read(arg('--adversarial')), heldout: read(arg('--heldout')) }, thresholds) };
55
+ if (arg('--out')) fs.writeFileSync(arg('--out'), `${JSON.stringify(out, null, 1)}\n`);
56
+ const f = (m) => `${m.k}/${m.n} [${(100 * m.lo).toFixed(1)}-${(100 * m.hi).toFixed(1)}]`;
57
+ for (const r of out.rows) {
58
+ console.log(`t=${r.threshold}: needs hit ${f(r.needs.confidentHit)} miss ${f(r.needs.confidentMiss)} precision ${f(r.needs.precision)} right-repo ${f(r.needs.confidentRightRepo)} | off-topic abstain ${f(r.offTopicAbstain)} | held-out routed ${f(r.heldOutRouted)}`);
59
+ }
60
+ }
61
+
62
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
@@ -0,0 +1,139 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/oracle/abstain-trace.mjs — WHY does the reranker abstain on a novice need whose gold
4
+ * repository WAS searched?
5
+ *
6
+ * For each need it measures, in the gold store, with the production models:
7
+ * poolRank rank of the gold file among the store's dense pool (searchKb, depth --pool)
8
+ * ceProduction cross-encoder logit on the text production reranks (the assembled document, which
9
+ * the reranker reads only through its first 3000 chars / 512 tokens)
10
+ * ceBestChunk best logit over the gold file's own sidecar chunks, each read on its own
11
+ * ceSpan logit on the verbatim gold span the question was written from (an upper bound:
12
+ * the answer text itself, nothing else)
13
+ * ceTopProd production's top logit for this need (from the measured needs run)
14
+ * and classifies the abstention:
15
+ * not-in-pool dense retrieval never surfaced the gold file
16
+ * window gold scores >= 0 on some chunk but < 0 on the text production reads
17
+ * calibration even the verbatim span scores < 0: the model, not the window, says "irrelevant"
18
+ * chunking span >= 0 but every stored chunk < 0 (the answer is split across chunks)
19
+ * outranked production text >= 0, yet the top citation is another file
20
+ *
21
+ * node scripts/oracle/abstain-trace.mjs --kb <kbDir> --set <need-set.json> --rows <needs.json>
22
+ * [--runtime <dir>] [--keyword 8] [--sample 20] [--pool 64] [--out <file>]
23
+ *
24
+ * --rows is a measure-need-set output (it supplies reposSearched and the production top logit);
25
+ * only needs whose gold repository was searched are traced. Nothing written contains a local path.
26
+ */
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import readline from 'node:readline';
30
+ import { fileURLToPath, pathToFileURL } from 'node:url';
31
+
32
+ const args = process.argv.slice(2);
33
+ const arg = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
34
+
35
+ export function classify({ poolRank, ceProduction, ceBestChunk, ceSpan }) {
36
+ if (poolRank == null) return 'not-in-pool';
37
+ if (ceProduction >= 0) return 'outranked';
38
+ if (ceBestChunk >= 0) return 'window';
39
+ if (ceSpan < 0) return 'calibration';
40
+ return 'chunking';
41
+ }
42
+
43
+ /** Deterministic sample: every step-th need of the eligible list, spread over the whole list. */
44
+ export function sampleEvenly(list, n) {
45
+ if (list.length <= n) return list;
46
+ const step = list.length / n;
47
+ return Array.from({ length: n }, (_, i) => list[Math.floor(i * step)]);
48
+ }
49
+
50
+ async function chunksOf(kb, store, file) {
51
+ const out = [];
52
+ for (const name of [`${store}.passages.jsonl`, `${store}.big.passages.jsonl`]) {
53
+ const p = path.join(kb, name);
54
+ if (!fs.existsSync(p)) continue;
55
+ const rl = readline.createInterface({ input: fs.createReadStream(p), crlfDelay: Infinity });
56
+ for await (const line of rl) {
57
+ if (!line.includes(file)) continue;
58
+ try { const r = JSON.parse(line); if (r.path === file) out.push(String(r.text || '')); } catch { /* skip */ }
59
+ }
60
+ if (out.length) break;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ async function main() {
66
+ const kb = arg('--kb');
67
+ const set = JSON.parse(fs.readFileSync(arg('--set'), 'utf8'));
68
+ const rows = JSON.parse(fs.readFileSync(arg('--rows'), 'utf8')).rows;
69
+ const poolDepth = Number(arg('--pool', 64));
70
+ const want = Number(arg('--sample', 20));
71
+ const byId = new Map(set.questions.map((q) => [q.id, q]));
72
+ // The runtime is the KB directory's own copy of the reader (as in production), unless --runtime
73
+ // names another directory holding forge-ask.mjs / forge-rerank.mjs and their node_modules.
74
+ const runtime = path.resolve(arg('--runtime', kb));
75
+ const { searchKb } = await import(pathToFileURL(path.join(runtime, 'forge-ask.mjs')).href);
76
+ const { rerankPairs } = await import(pathToFileURL(path.join(runtime, 'forge-rerank.mjs')).href);
77
+ // --keyword N also counts the keyword lane (keyword-lane.mjs in the runtime) as part of the pool.
78
+ const keywordTopN = Number(arg('--keyword', 0));
79
+ const keywordCandidates = keywordTopN > 0
80
+ ? (await import(pathToFileURL(path.join(runtime, 'keyword-lane.mjs')).href)).keywordCandidates : null;
81
+ const ce = async (query, texts) => {
82
+ if (!texts.length) return [];
83
+ const scored = await rerankPairs(query, texts.map((t, i) => ({ fullText: t, i })));
84
+ const out = new Array(texts.length);
85
+ for (const s of scored) out[s.i] = s.ceScore;
86
+ return out;
87
+ };
88
+ const eligible = rows.filter((r) => (r.reposSearched || []).some((s) => s.toLowerCase() === r.repo.toLowerCase()));
89
+ const traced = [];
90
+ for (const r of eligible) {
91
+ const q = byId.get(r.id);
92
+ const store = r.repo.toLowerCase();
93
+ const hits = await searchKb({ dir: kb, name: store, query: q.need, k: poolDepth, n: poolDepth });
94
+ const idx = hits.findIndex((h) => h.path === q.path);
95
+ let lane = idx < 0 ? null : 'dense';
96
+ let gold = idx < 0 ? null : hits[idx];
97
+ if (!gold && keywordCandidates) {
98
+ const kw = keywordCandidates(kb, store, q.need, { topN: keywordTopN, exclude: new Set(hits.map((h) => h.path)) });
99
+ const k = kw.findIndex((c) => c.path === q.path);
100
+ if (k >= 0) { gold = kw[k]; lane = 'keyword'; }
101
+ }
102
+ traced.push({ r, q, store, gold, lane, poolRank: idx < 0 ? (gold ? poolDepth + 1 : null) : idx + 1 });
103
+ process.stderr.write(`\r[abstain-trace] pool ${traced.length}/${eligible.length}`);
104
+ }
105
+ const inPool = traced.filter((t) => t.poolRank != null);
106
+ const sample = sampleEvenly(inPool, want);
107
+ const out = [];
108
+ for (const t of sample) {
109
+ const { gold } = t;
110
+ const chunks = await chunksOf(kb, t.store, t.q.path);
111
+ const [ceProduction] = await ce(t.q.need, [gold.fullText || gold.text || '']);
112
+ const chunkScores = await ce(t.q.need, chunks);
113
+ const [ceSpan] = await ce(t.q.need, [t.q.span || '']);
114
+ const row = {
115
+ id: t.q.id, repo: t.r.repo, need: t.q.need, poolRank: t.poolRank, lane: t.lane,
116
+ ceProduction: +ceProduction.toFixed(3),
117
+ ceBestChunk: chunkScores.length ? +Math.max(...chunkScores).toFixed(3) : null,
118
+ chunks: chunks.length, goldDocChars: (gold.fullText || '').length,
119
+ ceSpan: +ceSpan.toFixed(3), ceTopProd: t.r.topCe, prodTop: t.r.topPath, prodAbstained: t.r.abstained,
120
+ };
121
+ row.cause = classify(row);
122
+ out.push(row);
123
+ process.stderr.write(`\r[abstain-trace] ce ${out.length}/${sample.length} `);
124
+ }
125
+ const count = (k) => out.filter((x) => x.cause === k).length;
126
+ const report = {
127
+ kind: 'ruvnet-brain-abstain-trace', poolDepth,
128
+ eligible: eligible.length, goldInPool: inPool.length, goldViaKeyword: traced.filter((t) => t.lane === 'keyword').length, notInPool: traced.length - inPool.length, sampled: out.length,
129
+ causes: Object.fromEntries(['not-in-pool', 'window', 'calibration', 'chunking', 'outranked'].map((k) => [k, count(k)])),
130
+ rows: out,
131
+ poolRanks: traced.map((t) => ({ id: t.q.id, poolRank: t.poolRank })),
132
+ };
133
+ const file = arg('--out');
134
+ if (file) fs.writeFileSync(file, `${JSON.stringify(report, null, 1)}\n`);
135
+ console.log(JSON.stringify({ ...report, rows: undefined, poolRanks: undefined }, null, 1));
136
+ process.exit(0);
137
+ }
138
+
139
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * scripts/oracle/doc2query-generate.mjs — ADR-099 arm A, generation: newcomer-style questions per
4
+ * documentation file ("doc2query"), produced ONCE where the corpus is built, never on a customer
5
+ * machine.
6
+ *
7
+ * The generator sees only the file's title, path and opening text, never the need set. Each question
8
+ * must describe a NEED in plain words. Deterministic checks drop any question that:
9
+ * - names the product, the repository or a code identifier, or
10
+ * - shares more than 3 consecutive words with the excerpt
11
+ * (the need-set producer's leak rules). Output is append-only JSONL
12
+ * {store, path, questions[], rejected}, so a run can be resumed and interrupted runs keep their work.
13
+ *
14
+ * node scripts/oracle/doc2query-generate.mjs --kb <kbDir> --stores ruflo,ruvector,ruview --out <file.jsonl>
15
+ * [--kind md] [--per-file 3] [--batch 25] [--model haiku] [--limit N] [--conc 2]
16
+ *
17
+ * Children run with scripts/subscription-hosts.mjs#subscriptionOnlyEnv (no API billing keys) and the
18
+ * same minimised-context claude flags as scripts/oracle/producer-hosts.mjs.
19
+ */
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import os from 'node:os';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { claudeArgs, spawnHost, isQuotaRefusal } from './producer-hosts.mjs';
25
+ import { subscriptionOnlyEnv } from '../subscription-hosts.mjs';
26
+
27
+ export const EXCERPT_CHARS = 1500;
28
+ export const D2Q_SCHEMA = Object.freeze({
29
+ type: 'object', additionalProperties: false,
30
+ properties: { items: { type: 'array', items: { type: 'object', additionalProperties: false,
31
+ properties: { docId: { type: 'string' }, questions: { type: 'array', items: { type: 'string' } } },
32
+ required: ['docId', 'questions'] } } },
33
+ required: ['items'],
34
+ });
35
+ export const D2Q_SYSTEM = 'You write search questions for documentation. You see only the documents in the message, '
36
+ + 'you have no tools, and you use no outside knowledge. Return only the structured output.';
37
+
38
+ export function d2qPrompt(docs, perFile) {
39
+ return [
40
+ `For each DOC below write ${perFile} different questions that a newcomer might type into a search box, `
41
+ + 'where this document is what would help them.',
42
+ 'Rules for every question:',
43
+ '- 12-35 words, plain everyday language, describing the person\'s NEED or problem, not the document.',
44
+ '- The person has never heard of this project: no product, project, repository, package, library or tool names, no code, no identifiers, no file names.',
45
+ '- Do not copy more than 3 consecutive words from the document.',
46
+ '- The three questions should cover different things the document helps with.',
47
+ 'Return one item per DOC with its docId copied exactly.',
48
+ '',
49
+ ...docs.map((d) => `===DOC docId=${d.docId}\ntitle: ${d.title}\n${d.excerpt}\n===END`),
50
+ ].join('\n');
51
+ }
52
+
53
+ const PRODUCT_NAME = /^(?:ruv\w*|ruflo|rufl\w*|ruview|claudeflow|agentdb|agentic|sona|rvf|ruvllm|cognitum|densepose|metaharness|reasoningbank)$/;
54
+ const words = (s) => String(s).toLowerCase().match(/[a-z0-9]+/g) || [];
55
+ /** Longest run of consecutive words a question shares with the excerpt. */
56
+ export function sharedRun(question, excerpt) {
57
+ const q = words(question);
58
+ const grams = new Set();
59
+ const e = words(excerpt);
60
+ let best = 0;
61
+ for (let n = 1; n <= q.length; n++) {
62
+ grams.clear();
63
+ for (let i = 0; i + n <= e.length; i++) grams.add(e.slice(i, i + n).join(' '));
64
+ let found = false;
65
+ for (let i = 0; i + n <= q.length; i++) if (grams.has(q.slice(i, i + n).join(' '))) { found = true; break; }
66
+ if (!found) break;
67
+ best = n;
68
+ }
69
+ return best;
70
+ }
71
+
72
+ /** Keep a question only if it obeys the leak rules; return the reason when it does not. */
73
+ export function leakReason(question, { excerpt, store, path: p }) {
74
+ const q = String(question || '').trim();
75
+ const n = words(q).length;
76
+ if (n < 8 || n > 45) return 'length';
77
+ if (/[`{}<>=]|::|\w\(|\b[a-z]+[A-Z][A-Za-z]+\b|\b\w+\.(?:md|js|ts|rs|py|json|toml)\b|@[a-z0-9-]+\//.test(q)) return 'identifier';
78
+ // Product names: the store itself and the rUv family's own names (a newcomer has heard none of them).
79
+ if (words(q).some((w) => w === String(store).toLowerCase() || PRODUCT_NAME.test(w))) return 'names-source';
80
+ void p;
81
+ if (sharedRun(q, excerpt) > 3) return 'copies-source';
82
+ return null;
83
+ }
84
+
85
+ export function excerptOf(chunks) {
86
+ return chunks.join('\n\n').slice(0, EXCERPT_CHARS);
87
+ }
88
+
89
+ function readStoreDocs(kb, store, kind) {
90
+ const big = path.join(kb, `${store}.big.passages.jsonl`);
91
+ const file = fs.existsSync(big) ? big : path.join(kb, `${store}.passages.jsonl`);
92
+ const byPath = new Map();
93
+ for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
94
+ if (!line) continue;
95
+ let r;
96
+ try { r = JSON.parse(line); } catch { continue; }
97
+ if (kind === 'md' && !/\.md$/i.test(r.path)) continue;
98
+ if (!byPath.has(r.path)) byPath.set(r.path, { title: r.title || path.basename(r.path), chunks: [] });
99
+ const d = byPath.get(r.path);
100
+ if (d.chunks.join('').length < EXCERPT_CHARS) d.chunks.push(String(r.text || ''));
101
+ }
102
+ return [...byPath.entries()].sort(([a], [b]) => a.localeCompare(b))
103
+ .map(([p, d]) => ({ store, path: p, title: d.title, excerpt: excerptOf(d.chunks) }));
104
+ }
105
+
106
+ async function main() {
107
+ const args = process.argv.slice(2);
108
+ const arg = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
109
+ const kb = arg('--kb');
110
+ const out = arg('--out');
111
+ const perFile = Number(arg('--per-file', 3));
112
+ const batchSize = Number(arg('--batch', 25));
113
+ const conc = Number(arg('--conc', 2));
114
+ const model = arg('--model', 'haiku');
115
+ const done = new Set();
116
+ if (fs.existsSync(out)) {
117
+ for (const l of fs.readFileSync(out, 'utf8').split('\n')) { try { const r = JSON.parse(l); done.add(`${r.store}|${r.path}`); } catch { /* partial */ } }
118
+ }
119
+ let docs = String(arg('--stores')).split(',').flatMap((s) => readStoreDocs(kb, s.trim(), arg('--kind', 'md')))
120
+ .filter((d) => !done.has(`${d.store}|${d.path}`));
121
+ if (arg('--limit')) docs = docs.slice(0, Number(arg('--limit')));
122
+ const batches = [];
123
+ for (let i = 0; i < docs.length; i += batchSize) batches.push(docs.slice(i, i + batchSize));
124
+ const env = { ...subscriptionOnlyEnv(), CLAUDE_HOOK: '/usr/bin/true' };
125
+ const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'd2q-'));
126
+ let next = 0;
127
+ let stop = false;
128
+ let written = 0;
129
+ const worker = async () => {
130
+ while (!stop && next < batches.length) {
131
+ const b = batches[next++];
132
+ const withIds = b.map((d, i) => ({ ...d, docId: `d${i}` }));
133
+ const res = await spawnHost('claude', claudeArgs({ model, effort: 'low', schema: D2Q_SCHEMA, systemPrompt: D2Q_SYSTEM }),
134
+ { cwd, env, timeoutMs: 600_000 }, d2qPrompt(withIds, perFile));
135
+ let items = null;
136
+ try {
137
+ const env2 = JSON.parse(res.stdout);
138
+ items = (env2.structured_output || JSON.parse(env2.result)).items;
139
+ } catch { items = null; }
140
+ if (!items) {
141
+ if (isQuotaRefusal(res.stdout + res.stderr)) { stop = true; process.stderr.write('\n[d2q] quota refusal: stopping\n'); }
142
+ else process.stderr.write(`\n[d2q] batch failed (status ${res.status}${res.timedOut ? ', timeout' : ''})\n`);
143
+ continue;
144
+ }
145
+ const byId = new Map(items.map((it) => [it.docId, it.questions || []]));
146
+ const lines = withIds.map((d) => {
147
+ const qs = byId.get(d.docId) || [];
148
+ const kept = [];
149
+ const rejected = [];
150
+ for (const q of qs) { const why = leakReason(q, d); if (why) rejected.push({ q, why }); else kept.push(q); }
151
+ return JSON.stringify({ store: d.store, path: d.path, questions: kept, rejected });
152
+ });
153
+ fs.appendFileSync(out, `${lines.join('\n')}\n`);
154
+ written += lines.length;
155
+ process.stderr.write(`\r[d2q] ${written}/${docs.length} files`);
156
+ }
157
+ };
158
+ await Promise.all(Array.from({ length: conc }, worker));
159
+ console.log(JSON.stringify({ files: docs.length, written, stopped: stop }));
160
+ }
161
+
162
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await main();