ruvnet-brain 4.0.1 → 4.0.2

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 (185) hide show
  1. package/.claude-plugin/marketplace.json +1 -0
  2. package/README.md +4 -4
  3. package/bin/install.mjs +100 -5
  4. package/console/CONTRACT.md +172 -0
  5. package/console/activity.js +753 -0
  6. package/console/app.js +4189 -0
  7. package/console/architecture.html +1221 -0
  8. package/console/assets/depth-1.webp +0 -0
  9. package/console/assets/depth-2.webp +0 -0
  10. package/console/assets/depth-3.webp +0 -0
  11. package/console/assets/harness-vs-plain.svg +259 -0
  12. package/console/assets/hero.webp +0 -0
  13. package/console/assets/memory.webp +0 -0
  14. package/console/assets/metaharness.svg +247 -0
  15. package/console/index.html +777 -0
  16. package/console/install-architecture.html +162 -0
  17. package/console/install-mockup.html +543 -0
  18. package/console/style.css +2144 -0
  19. package/console/tips.css +926 -0
  20. package/console/tips.html +858 -0
  21. package/console/tips.js +128 -0
  22. package/docs/RELEASE-NOTES-4.0.md +88 -0
  23. package/kb/model-requirements.mjs +37 -6
  24. package/keys/ruvnet-brain-signing.pub.pem +3 -0
  25. package/package.json +8 -22
  26. package/plugin/.claude-plugin/marketplace.json +1 -0
  27. package/plugin/.claude-plugin/plugin.json +2 -3
  28. package/plugin/.codex-plugin/plugin.json +1 -1
  29. package/plugin/commands/brain-console.md +2 -2
  30. package/plugin/commands/configure.md +3 -2
  31. package/plugin/commands/rvbc.md +4 -3
  32. package/plugin/commands/rvcb.md +2 -2
  33. package/plugin/hooks/hooks.json +1 -2
  34. package/plugin/mcp/managed-cli-interface.mjs +47 -4
  35. package/plugin/mcp/server.mjs +21 -0
  36. package/plugin/scripts/detach.mjs +14 -0
  37. package/plugin/scripts/first-session-worker.mjs +38 -0
  38. package/plugin/scripts/ground-ruvnet.sh +16 -6
  39. package/plugin/scripts/hook-shim.mjs +7 -7
  40. package/plugin/scripts/learn-capture.sh +22 -3
  41. package/plugin/scripts/learn-flush.mjs +21 -4
  42. package/plugin/scripts/runtime-preferences.mjs +269 -0
  43. package/plugin/scripts/session-start-core.mjs +477 -0
  44. package/plugin/scripts/session-start.sh +3 -858
  45. package/plugin/skills/brain-console/SKILL.md +4 -2
  46. package/plugin/skills/release-proof/SKILL.md +81 -0
  47. package/plugin/skills/release-proof/agents/openai.yaml +4 -0
  48. package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
  49. package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
  50. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +5 -1
  51. package/plugin/skills/rvbc/SKILL.md +9 -6
  52. package/scripts/adr-backfill.mjs +107 -0
  53. package/scripts/advocacy-outcomes.mjs +808 -0
  54. package/scripts/agentdb-context.mjs +216 -0
  55. package/scripts/agentdb-fleet-doctor.mjs +101 -0
  56. package/scripts/ascii-drift.mjs +236 -0
  57. package/scripts/behavioral-l1-l4.mjs +210 -0
  58. package/scripts/brain-capability-check.mjs +72 -0
  59. package/scripts/brain-grade-groundtruth.mjs +100 -0
  60. package/scripts/brain-latency-50.mjs +227 -0
  61. package/scripts/brain-novice-50.mjs +189 -0
  62. package/scripts/brain-stamp.mjs +94 -0
  63. package/scripts/brain-state.mjs +212 -0
  64. package/scripts/build-bundle.mjs +522 -0
  65. package/scripts/build-concepts.mjs +132 -0
  66. package/scripts/build-l2.mjs +71 -0
  67. package/scripts/build-primer.mjs +73 -0
  68. package/scripts/build-symbols.mjs +68 -0
  69. package/scripts/calibrate-router.mjs +97 -0
  70. package/scripts/capability-audit.mjs +321 -0
  71. package/scripts/capability-registry.mjs +876 -0
  72. package/scripts/check-indexation.mjs +108 -0
  73. package/scripts/check-legibility.mjs +189 -0
  74. package/scripts/ci/build-fixture-kb.mjs +67 -0
  75. package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
  76. package/scripts/ci/learning-replay-recorder.mjs +59 -0
  77. package/scripts/ci/mutate-hook-timeout.mjs +70 -0
  78. package/scripts/ci/stranger-fixture-stage.mjs +17 -0
  79. package/scripts/ci/stranger-scenario.mjs +228 -0
  80. package/scripts/ci/stranger-timeout.mjs +25 -0
  81. package/scripts/ci-verdict.mjs +29 -0
  82. package/scripts/claims-verify.mjs +710 -0
  83. package/scripts/clear-claude-tmp.sh +31 -0
  84. package/scripts/console-engine.mjs +434 -0
  85. package/scripts/console-engine.test.mjs +125 -0
  86. package/scripts/corpus-qa.mjs +250 -0
  87. package/scripts/correction-detect-embed.mjs +346 -0
  88. package/scripts/correction-detect-measure.mjs +270 -0
  89. package/scripts/correction-detect.mjs +686 -0
  90. package/scripts/count-chunks.mjs +54 -0
  91. package/scripts/described-questions.json +30 -0
  92. package/scripts/design-grade.mjs +58 -0
  93. package/scripts/dev-plugin-link.sh +105 -0
  94. package/scripts/distill-project.mjs +200 -0
  95. package/scripts/doc-currency.mjs +801 -0
  96. package/scripts/eval-brain.mjs +244 -0
  97. package/scripts/fix-metaharness-memretrieve.mjs +121 -0
  98. package/scripts/full-hints.mjs +87 -0
  99. package/scripts/gate.sh +39 -0
  100. package/scripts/gates.mjs +146 -0
  101. package/scripts/gen-console-images.mjs +54 -0
  102. package/scripts/gen-images.mjs +47 -0
  103. package/scripts/git-clone-refresh.mjs +52 -0
  104. package/scripts/git-hooks/pre-push +126 -0
  105. package/scripts/goal-match.mjs +398 -0
  106. package/scripts/goldie-research.mjs +223 -0
  107. package/scripts/goldie-weekly.sh +67 -0
  108. package/scripts/health-repair.mjs +250 -0
  109. package/scripts/helix-scenario-questions.json +10 -0
  110. package/scripts/ingest-gists.mjs +230 -0
  111. package/scripts/ingest-meeting.mjs +115 -0
  112. package/scripts/ingest-repo.mjs +79 -0
  113. package/scripts/install-npx-witness.sh +49 -0
  114. package/scripts/issue-fix.mjs +639 -0
  115. package/scripts/issue-watch.mjs +276 -0
  116. package/scripts/issue4-close-note.md +31 -0
  117. package/scripts/key-canary.mjs +91 -0
  118. package/scripts/latency-to-surface.mjs +233 -0
  119. package/scripts/learning-enable.mjs +380 -0
  120. package/scripts/learning-replay.mjs +1570 -0
  121. package/scripts/learnings.mjs +62 -0
  122. package/scripts/lesson-gate.mjs +680 -0
  123. package/scripts/lesson-lifecycle.mjs +449 -0
  124. package/scripts/lesson-promote.mjs +262 -0
  125. package/scripts/lesson-ratify.mjs +98 -0
  126. package/scripts/lesson-seed.mjs +252 -0
  127. package/scripts/lesson-store.mjs +447 -0
  128. package/scripts/loop-checkpoint.mjs +86 -0
  129. package/scripts/memdb-health.sh +14 -0
  130. package/scripts/memory-doctor.mjs +271 -0
  131. package/scripts/model-catalog.mjs +79 -0
  132. package/scripts/nightly-controller.mjs +66 -0
  133. package/scripts/nightly-gists.sh +72 -0
  134. package/scripts/nightly-wrapper.sh +180 -0
  135. package/scripts/notify.sh +12 -0
  136. package/scripts/npx-witness.sh +56 -0
  137. package/scripts/onboarding-console.mjs +2749 -0
  138. package/scripts/private-fence.mjs +69 -0
  139. package/scripts/proactivity-metrics.mjs +118 -0
  140. package/scripts/proof-questions.json +56 -0
  141. package/scripts/prove.mjs +95 -0
  142. package/scripts/proxy/claude-proxied.sh +57 -0
  143. package/scripts/proxy/proxy-revert.sh +59 -0
  144. package/scripts/proxy/proxy-up.sh +60 -0
  145. package/scripts/proxy/proxy-verify.mjs +142 -0
  146. package/scripts/published-surface-probe.mjs +241 -0
  147. package/scripts/qe/card-lane-gate.mjs +162 -0
  148. package/scripts/qe/session-start-gate.mjs +229 -0
  149. package/scripts/qe/ux-suite.mjs +323 -0
  150. package/scripts/reconcile-project.mjs +0 -0
  151. package/scripts/record-lesson.mjs +113 -0
  152. package/scripts/refresh-model-catalog.mjs +99 -0
  153. package/scripts/release-proof.mjs +9 -0
  154. package/scripts/release-vector.mjs +281 -0
  155. package/scripts/release.mjs +395 -0
  156. package/scripts/remedy-registry.mjs +247 -0
  157. package/scripts/rerank-cap-eval.mjs +265 -0
  158. package/scripts/rerank-cap-warm-ab.mjs +129 -0
  159. package/scripts/route-cheap.mjs +20 -15
  160. package/scripts/router-utilization.mjs +182 -0
  161. package/scripts/routing-flywheel.mjs +596 -0
  162. package/scripts/rvf-generation.mjs +104 -0
  163. package/scripts/rvf-index-audit.mjs +138 -0
  164. package/scripts/self-update.mjs +508 -0
  165. package/scripts/selfcheck.mjs +7 -1
  166. package/scripts/sign-bundle.mjs +69 -0
  167. package/scripts/signal-watch.mjs +171 -0
  168. package/scripts/stack-sync.mjs +469 -0
  169. package/scripts/stamp-existing-rvf-generations.mjs +53 -0
  170. package/scripts/stamp-sweep.mjs +144 -0
  171. package/scripts/status-honesty.mjs +102 -0
  172. package/scripts/sync-version.mjs +217 -0
  173. package/scripts/token-report.mjs +102 -0
  174. package/scripts/top100-benchmark.mjs +479 -0
  175. package/scripts/top100-corpus.mjs +112 -0
  176. package/scripts/top100-semantic-assertions.mjs +449 -0
  177. package/scripts/update-apply.mjs +9 -0
  178. package/scripts/upgrade-notice.mjs +14 -0
  179. package/scripts/verify-bundle.mjs +51 -0
  180. package/scripts/verify-channels.mjs +184 -0
  181. package/scripts/verify-model-catalog.mjs +104 -0
  182. package/scripts/verify-nightly-close-issue4.sh +31 -0
  183. package/scripts/version.mjs +40 -0
  184. package/scripts/wired-check.mjs +864 -0
  185. package/plugin/scripts/finalize-token-meter.mjs +0 -25
@@ -121,7 +121,7 @@ check_memory_db() {
121
121
  for _db in .swarm/agentdb-memory.db .swarm/memory.db .claude/memory.db; do
122
122
  check_memory_db "$_db" && break
123
123
  done
124
- if [ "$MEM_STATE" = "off" ]; then
124
+ if [ "$MEM_STATE" != "on" ]; then
125
125
  _common_git=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
126
126
  case "$_common_git" in
127
127
  */.git)
@@ -136,6 +136,15 @@ if [ "$MEM_STATE" = "off" ]; then
136
136
  ;;
137
137
  esac
138
138
  fi
139
+ if [ "$MEM_STATE" != "on" ] && [ -n "${HOME:-}" ] && [ "$PWD" != "$HOME" ]; then
140
+ for _db in \
141
+ "$HOME/.swarm/agentdb-memory.db" \
142
+ "$HOME/.swarm/memory.db" \
143
+ "$HOME/.claude/memory.db"
144
+ do
145
+ check_memory_db "$_db" && break
146
+ done
147
+ fi
139
148
  unset _db
140
149
  unset _common_git _primary_root
141
150
  # RUNNING version (this session's loaded plugin) vs STAGED version (marketplace copy on disk).
@@ -162,11 +171,10 @@ if [ "$RUFLO_STATE" = "yes" ] && [ "$MEM_STATE" = "off" ]; then
162
171
  This project runs the Ruflo stack but AgentDB persistent project memory is NOT set up (.swarm/memory.db does not exist) — decisions made here are being lost between sessions. rUv's default is memory ON. If you have not already offered this session, offer ONCE, plainly and warmly: "One thing I noticed: this project doesn't have persistent memory turned on — AgentDB would let me carry decisions and context across sessions instead of starting cold each time. Want me to turn it on and wire it up?" On a yes, set it up with the ruflo tools you have (a first memory_store write creates the store) and confirm with the real file path. If they decline, respect it for the rest of the session — the status line keeps them informed without nagging.
163
172
  EOF
164
173
  fi
165
- if [ "$RUFLO_STATE" = "yes" ] && [ "$MEM_IDLE" = "1" ]; then
166
- cat <<'EOF'
167
- AgentDB memory exists here but has NOT been written in over 90 minutes. If meaningful decisions HAVE happened this session, the memory hooks may be miswired — do what Ruv would: probe it. Quietly store a session checkpoint via the ruflo memory tools, then verify .swarm/memory.db's mtime actually changed. If the write fails or the file doesn't move, tell the user plainly: "your project memory isn't capturing this session — the hooks look miswired; want me to fix them?" If this session genuinely hasn't produced decisions yet, stay silent — idle is normal at a session's start.
168
- EOF
169
- fi
174
+ # A stale mtime is not a user-facing event. SessionEnd/PreCompact own capture;
175
+ # their persistence receipt must fail closed at the write boundary. Emitting a
176
+ # UserPromptSubmit instruction here interrupted unrelated work and still could
177
+ # not prove that the store command wrote the canonical database.
170
178
 
171
179
  # ── Self-learning flywheel (ruflo ≥3.24, ADR-176) — OFFER it, never switch it on for them ────────
172
180
  # Opt-in is a single env var; `harnessLoopOptedIn()` in @claude-flow/cli reads process.env directly,
@@ -375,6 +383,7 @@ This task touches the RuvNet / rUv stack. Your training priors here are STALE an
375
383
  - You MUST call the `search_ruvnet` MCP tool and ground your answer in the cited source paths it returns BEFORE stating what any RuvNet tool can/cannot do or which one to use. Asserting a RuvNet capability from memory, without a cited source path, is an error — do not do it.
376
384
  - If a needed RuvNet repo isn't covered yet, ingest it first (`node scripts/ingest-repo.mjs --name <repo>`) rather than guessing.
377
385
  - SIGNAL that the brain is working so the user has confidence it's in charge: when you ground an answer, say so briefly (e.g. "Grounding in rUv's source…" → cite the path). Silent grounding leaves the user unsure it's even on.
386
+ - MEMORY DIAGNOSIS: never infer a broken write from DB/WAL mtime, semantic-search misses, daemon startup, or the CLI success line. Prove it against ONE canonical absolute path: unique-key `ruflo memory store --path <project>/.swarm/memory.db` → exact-key `ruflo memory retrieve --path ...` → exact SQL row. Only then call it miswired.
378
387
  EOF
379
388
  fi
380
389
 
@@ -398,6 +407,7 @@ This is a build / change request — run THE PLAYBOOK (the standing build playbo
398
407
  - Read the actual files in THEIR repo this touches — what pattern do they already use? what would duplicate?
399
408
  - Call `search_ruvnet` for what the feature technically DOES — never trust memory about what the corpus has.
400
409
  - Check project memory (ruflo memory search / AgentDB) for prior decisions on this area.
410
+ - If memory looks broken, use the exact-path store → exact-key retrieve → exact SQL proof; never diagnose from semantic search, mtime, daemon startup, or a success message.
401
411
  ⛔ NO SILENT SUBSTITUTION: use the real RuvNet tool, or say out loud that you're hand-rolling and why.
402
412
  Senior partner: one plan, momentum, end with real work.
403
413
  EOF
@@ -75,13 +75,13 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
75
75
  // stops a subagent fan-out inheriting an expensive model; design-wall stops an
76
76
  // ungraded surface shipping; protect-state guards the user's own consent record.
77
77
  // None becomes acceptable because retrieval is off.
78
- // 'partial' — the hook splits INTERNALLY. session-start still runs the auto-updater heartbeat, the
78
+ // 'partial' — the hook splits INTERNALLY. session-start-core still runs the auto-updater heartbeat, the
79
79
  // GONG health alarm and the SLA banner (an off machine must still receive fixes,
80
80
  // otherwise the fix for an off-state bug can never arrive) while suppressing every
81
81
  // advertising byte. The snapshot is forwarded as RUVNET_BRAIN_OFF so the body reads
82
82
  // ONE resolved answer instead of racing the filesystem again mid-run.
83
83
  const TABLE = {
84
- 'session-start': { file: 'session-start.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'partial' },
84
+ 'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
85
85
  'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
86
86
  'hijack-ruvnet': { file: 'hijack-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
87
87
  'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run' },
@@ -102,7 +102,7 @@ const TABLE = {
102
102
  // SEPARATE process, SEPARATE cadence) resolves later via `gh run list`. offBehavior 'silence' per
103
103
  // the ADR's explicit instruction: this plane observes and advises, it never guards money or
104
104
  // honesty on its own — the git-push debt it opens is only ever SURFACED (never gated) by
105
- // session-start.sh, and that surfacing already lives under session-start's own 'partial' contract.
105
+ // session-start-core.mjs, and that surfacing already lives under SessionStart's 'partial' contract.
106
106
  'signal-watch': { file: 'signal-watch.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
107
107
  'routing-outcome': { file: 'routing-outcome-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run' },
108
108
  // The unprompted-speech chokepoint (ADR-040 / DDD-0004). ONE runtime is the sole writer of
@@ -247,10 +247,10 @@ function runHook(file) {
247
247
  // The unprompted-speech chokepoint uses this to receive the CC event name:
248
248
  // `hook-shim.mjs unprompted-speech UserPromptSubmit` → `unprompted-runtime.mjs UserPromptSubmit`.
249
249
  const extraArgs = process.argv.slice(3);
250
- // Forward the ONE resolved OFF snapshot to a 'partial' body (ADR-054 §4). The body could stat the
251
- // sentinel itself — and session-start.sh still does, because it is also invoked directly, outside
252
- // this shim, by tests and by a bare install. Passing it means the two readings cannot disagree
253
- // within a single invocation if the user flips the switch while the hook is mid-run.
250
+ // Forward the ONE resolved OFF snapshot to a 'partial' body (ADR-054 §4). The native SessionStart
251
+ // core also reads the sentinel because the POSIX compatibility launcher and bare installs invoke
252
+ // it outside this shim. Passing the snapshot means the two readings cannot disagree within one
253
+ // invocation if the user flips the switch while the hook is mid-run.
254
254
  const env = (BRAIN_OFF && entry.offBehavior === 'partial')
255
255
  ? { ...process.env, RUVNET_BRAIN_OFF: '1' }
256
256
  : process.env;
@@ -9,6 +9,21 @@
9
9
 
10
10
  set -uo pipefail
11
11
 
12
+ # One policy source for both capture and flush. `off` means zero bytes written. `project` keeps the
13
+ # trajectory queue under this project's .swarm directory; `user` preserves the cross-project learner
14
+ # introduced by ADR-0017. Tests and managed hosts may pass an already-resolved snapshot in
15
+ # RUVNET_LEARNING_SCOPE so the two halves cannot disagree during one hook invocation.
16
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" 2>/dev/null && pwd)"
17
+ SCOPE="${RUVNET_LEARNING_SCOPE:-}"
18
+ if [ -z "$SCOPE" ] && [ -f "$HERE/runtime-preferences.mjs" ] && command -v node >/dev/null 2>&1; then
19
+ SCOPE=$(node "$HERE/runtime-preferences.mjs" --learning-scope 2>/dev/null) || SCOPE=""
20
+ fi
21
+ case "$SCOPE" in
22
+ off) exit 0 ;;
23
+ user|project) ;;
24
+ *) SCOPE="project" ;;
25
+ esac
26
+
12
27
  # BOUNDED READ. An unqualified `read` waits forever on a stdin that is opened and never closed, and
13
28
  # an unbounded accumulator turns a large payload into an unbounded regex scan. Claude Code always
14
29
  # writes the payload and closes, so neither costs a normal turn — which is exactly why a hook that
@@ -104,7 +119,11 @@ re_s='"session_id"[[:space:]]*:[[:space:]]*"([^"\]*)"'
104
119
  # keeping `.` would let a crafted id survive as `..`-shaped debris in a filename for no benefit.
105
120
  SID="${SID//[^A-Za-z0-9_-]/}" # a filename COMPONENT, never a path
106
121
  [ -n "$SID" ] || SID="default"
107
- DIR="$HOME/.cache/ruvnet-brain/learn"
122
+ if [ "$SCOPE" = "user" ]; then
123
+ DIR="$HOME/.cache/ruvnet-brain/learn"
124
+ else
125
+ DIR="$PWD/.swarm/ruvnet-brain-learn"
126
+ fi
108
127
  # Owner-only (0700 dir / 0600 file). This queue was 0644 inside a 0755 dir: on macOS every local
109
128
  # account is normally in `staff`, so any other user on a shared or corporate machine could read it.
110
129
  ( umask 077 && mkdir -p "$DIR" ) 2>/dev/null || exit 0
@@ -147,8 +166,8 @@ if [ "$LINES" -ge "$HEARTBEAT_EVERY" ]; then
147
166
  LAST=$(cat "$STAMP" 2>/dev/null || echo 0)
148
167
  if [ $((NOW - LAST)) -ge 60 ]; then
149
168
  echo "$NOW" > "$STAMP" 2>/dev/null || true
150
- FLUSH="${CLAUDE_PLUGIN_ROOT:-$HOME/.claude/plugins/marketplaces/ruvnet-brain/plugin}/scripts/learn-flush.mjs"
151
- [ -f "$FLUSH" ] && (nohup node "$FLUSH" >/dev/null 2>&1 &) || true
169
+ FLUSH="$HERE/learn-flush.mjs"
170
+ [ -f "$FLUSH" ] && (RUVNET_LEARNING_SCOPE="$SCOPE" nohup node "$FLUSH" >/dev/null 2>&1 &) || true
152
171
  fi
153
172
  fi
154
173
  exit 0
@@ -13,8 +13,15 @@ import os from 'node:os';
13
13
  import path from 'node:path';
14
14
  import { execFileSync } from 'node:child_process';
15
15
  import { readStdinBounded } from './hook-input.mjs';
16
+ import { loadRuntimePreferences } from './runtime-preferences.mjs';
16
17
 
17
18
  const HOME = os.homedir();
19
+ const PROJECT = process.env.RUVNET_BRAIN_PROJECT_DIR || process.cwd();
20
+ const configuredScope = process.env.RUVNET_LEARNING_SCOPE
21
+ || loadRuntimePreferences({ cwd: PROJECT }).values.learningScope;
22
+ const LEARNING_SCOPE = ['off', 'project', 'user'].includes(configuredScope)
23
+ ? configuredScope : 'project';
24
+ if (LEARNING_SCOPE === 'off') process.exit(0);
18
25
 
19
26
  // THE SESSION ID COMES OFF THE PAYLOAD, exactly as it does in learn-capture.sh (fixed 2026-07-27).
20
27
  //
@@ -37,8 +44,12 @@ async function payloadSessionId() {
37
44
  }
38
45
  // A filename COMPONENT, never a path — the payload is untrusted input.
39
46
  const SID = ((await payloadSessionId()) || process.env.CLAUDE_SESSION_ID || '').replace(/[^A-Za-z0-9_-]/g, '') || 'default';
40
- const QUEUE = process.env.LEARN_QUEUE || path.join(HOME, '.cache/ruvnet-brain/learn', `session-${SID}.jsonl`);
47
+ const QUEUE_ROOT = LEARNING_SCOPE === 'user'
48
+ ? path.join(HOME, '.cache', 'ruvnet-brain', 'learn')
49
+ : path.join(PROJECT, '.swarm', 'ruvnet-brain-learn');
50
+ const QUEUE = process.env.LEARN_QUEUE || path.join(QUEUE_ROOT, `session-${SID}.jsonl`);
41
51
  const RUFLO = path.join(HOME, '.npm-global/bin/ruflo');
52
+ const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
42
53
  const MAX_ACTIONS = 8; // bound the work so SessionEnd stays fast
43
54
 
44
55
  // THE DEADLINE. SessionEnd's registered timeout is 30s (plugin/hooks/hooks.json) and this hook fires
@@ -100,8 +111,14 @@ for (let i = 0; i < actions.length; i++) {
100
111
  ? ['hooks', 'post-command', '-c', s.action, '-s', 'true']
101
112
  : ['hooks', 'post-edit', '-f', s.action, '-s', 'true', '-o', 'session edit'];
102
113
  try {
103
- // cwd: HOME → writes the GLOBAL per-user learner (cross-project), not a project-local one.
104
- execFileSync(RUFLO, args, { cwd: HOME, stdio: 'ignore', timeout: Math.min(6000, remaining) });
114
+ // One command, two real Ruflo scopes: project cwd keeps patterns local; HOME retains the
115
+ // cross-project SONA learner for users who explicitly chose `user`.
116
+ execFileSync(RUFLO, args, {
117
+ cwd: LEARNING_SCOPE === 'user' ? HOME : PROJECT,
118
+ env: RUFLO_ENV,
119
+ stdio: 'ignore',
120
+ timeout: Math.min(6000, remaining),
121
+ });
105
122
  fed++;
106
123
  } catch { /* best-effort — one slow/failed record must not stall session end */ }
107
124
  }
@@ -129,7 +146,7 @@ if (fed > 0 || allDistinct.length === 0) {
129
146
  console.log(`learn-flush: 0/${actions.length} fed (ruflo hooks failing?) — queue KEPT for retry next session-end`);
130
147
  }
131
148
  if (process.argv.includes('--sync')) {
132
- console.log(`learn-flush: fed ${fed}/${actions.length} distinct actions to the global learner`
149
+ console.log(`learn-flush: fed ${fed}/${actions.length} distinct actions to the ${LEARNING_SCOPE} learner`
133
150
  // Say the deadline out loud when it fires. A budget that silently truncates reads as "that was
134
151
  // all there was", which is the same lie as the count cap that preceded it.
135
152
  + (stoppedAt < actions.length ? `; STOPPED at ${stoppedAt}/${actions.length} on the ${DEADLINE_MS}ms deadline` : '')
@@ -0,0 +1,269 @@
1
+ #!/usr/bin/env node
2
+ // runtime-preferences.mjs — one policy boundary for every live Brain control.
3
+ //
4
+ // The console records intent. Runtime code asks this module what is allowed. Keeping that read in
5
+ // one place prevents routing, QE, learning, and first-open initialization from each inventing a
6
+ // subtly different default. Secrets are deliberately separate: ordinary settings and their backup
7
+ // journal must never contain an API key.
8
+
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { spawnSync } from 'node:child_process';
13
+
14
+ const DEFAULT_HOME = os.homedir();
15
+ const PROJECT_FILE = '.swarm/ruvnet-brain-settings.json';
16
+ const PROJECT_KEYS = Object.freeze([
17
+ 'routing',
18
+ 'qeFleet',
19
+ 'learningScope',
20
+ 'autoApply',
21
+ 'advocacy',
22
+ 'provider',
23
+ ]);
24
+
25
+ const readJSON = (file) => {
26
+ try {
27
+ const value = JSON.parse(fs.readFileSync(file, 'utf8'));
28
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
29
+ } catch {
30
+ return null;
31
+ }
32
+ };
33
+
34
+ export function preferencePaths({ env = process.env, cwd = process.cwd() } = {}) {
35
+ const home = env.HOME || DEFAULT_HOME;
36
+ return {
37
+ config: env.RUVNET_BRAIN_CONFIG_FILE
38
+ || path.join(home, '.claude', 'ruvnet-brain', 'config.json'),
39
+ settings: env.RUVNET_SETTINGS_FILE
40
+ || path.join(home, '.config', 'ruvnet-brain', 'settings.json'),
41
+ secrets: env.RUVNET_BRAIN_SECRETS_FILE
42
+ || path.join(home, '.config', 'ruvnet-brain', 'secrets.enc.json'),
43
+ project: env.RUVNET_BRAIN_PROJECT_SETTINGS_FILE
44
+ || path.join(cwd, PROJECT_FILE),
45
+ ageIdentity: env.SOPS_AGE_KEY_FILE
46
+ || path.join(home, '.config', 'sops', 'age', 'keys.txt'),
47
+ };
48
+ }
49
+
50
+ function validConfig(raw = {}) {
51
+ return {
52
+ provider: ['auto', 'anthropic', 'openai', 'codex', 'google', 'xai'].includes(raw.provider)
53
+ ? raw.provider : null,
54
+ routing: raw.routing === 'auto' || raw.routing === 'off' ? raw.routing : null,
55
+ qeFleet: typeof raw.qeFleet === 'boolean' ? raw.qeFleet : null,
56
+ nightly: typeof raw.nightly === 'boolean' ? raw.nightly : null,
57
+ };
58
+ }
59
+
60
+ function validSettings(raw = {}) {
61
+ const source = raw.settings && typeof raw.settings === 'object' ? raw.settings : raw;
62
+ return {
63
+ learningScope: ['off', 'project', 'user'].includes(source.learningScope)
64
+ ? source.learningScope : 'project',
65
+ autoApply: source.autoApply === true,
66
+ newProjectDefaults: source.newProjectDefaults === true,
67
+ advocacy: Number.isInteger(source.advocacy) && source.advocacy >= 1 && source.advocacy <= 5
68
+ ? source.advocacy : 3,
69
+ };
70
+ }
71
+
72
+ function validProject(raw = {}) {
73
+ const out = {};
74
+ if (raw.routing === 'auto' || raw.routing === 'off') out.routing = raw.routing;
75
+ if (typeof raw.qeFleet === 'boolean') out.qeFleet = raw.qeFleet;
76
+ if (['off', 'project', 'user'].includes(raw.learningScope)) out.learningScope = raw.learningScope;
77
+ if (typeof raw.autoApply === 'boolean') out.autoApply = raw.autoApply;
78
+ if (Number.isInteger(raw.advocacy) && raw.advocacy >= 1 && raw.advocacy <= 5) out.advocacy = raw.advocacy;
79
+ if (['auto', 'anthropic', 'openai', 'codex', 'google', 'xai'].includes(raw.provider)) out.provider = raw.provider;
80
+ return out;
81
+ }
82
+
83
+ export function loadRuntimePreferences(options = {}) {
84
+ const paths = preferencePaths(options);
85
+ const config = validConfig(readJSON(paths.config) || {});
86
+ const settings = validSettings(readJSON(paths.settings) || {});
87
+ const projectRaw = readJSON(paths.project);
88
+ const project = projectRaw ? validProject(projectRaw.values || projectRaw) : {};
89
+ return {
90
+ values: { ...config, ...settings, ...project },
91
+ chosen: {
92
+ routing: config.routing !== null || Object.hasOwn(project, 'routing'),
93
+ qeFleet: config.qeFleet !== null || Object.hasOwn(project, 'qeFleet'),
94
+ nightly: config.nightly !== null,
95
+ },
96
+ paths,
97
+ projectInherited: !!projectRaw,
98
+ };
99
+ }
100
+
101
+ function commandExists(command, env) {
102
+ const probe = spawnSync(command, ['--version'], {
103
+ env,
104
+ encoding: 'utf8',
105
+ shell: false,
106
+ timeout: 3000,
107
+ });
108
+ return !probe.error && probe.status === 0;
109
+ }
110
+
111
+ function decryptSecrets(paths, env) {
112
+ if (!fs.existsSync(paths.secrets) || !commandExists('sops', env)) return {};
113
+ const run = spawnSync('sops', ['decrypt', '--input-type', 'json', '--output-type', 'json', paths.secrets], {
114
+ env,
115
+ encoding: 'utf8',
116
+ shell: false,
117
+ timeout: 10_000,
118
+ });
119
+ if (run.status !== 0) return {};
120
+ return readJSONString(run.stdout);
121
+ }
122
+
123
+ function readJSONString(text) {
124
+ try {
125
+ const value = JSON.parse(text);
126
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
127
+ } catch {
128
+ return {};
129
+ }
130
+ }
131
+
132
+ export function openRouterCredentialStatus(options = {}) {
133
+ const env = options.env || process.env;
134
+ if (typeof env.OPENROUTER_API_KEY === 'string' && env.OPENROUTER_API_KEY.trim()) {
135
+ return { configured: true, source: 'environment', legacyPlaintext: false };
136
+ }
137
+ const paths = preferencePaths(options);
138
+ const encrypted = decryptSecrets(paths, env);
139
+ if (typeof encrypted.OPENROUTER_API_KEY === 'string' && encrypted.OPENROUTER_API_KEY.trim()) {
140
+ return { configured: true, source: 'sops-age', legacyPlaintext: false };
141
+ }
142
+ const legacy = readJSON(paths.config);
143
+ return {
144
+ configured: typeof legacy?.openrouterKey === 'string' && legacy.openrouterKey.trim().length > 8,
145
+ source: legacy?.openrouterKey ? 'legacy-plaintext' : null,
146
+ legacyPlaintext: !!legacy?.openrouterKey,
147
+ };
148
+ }
149
+
150
+ export function runtimeChildEnv(options = {}) {
151
+ const env = { ...(options.env || process.env) };
152
+ if (typeof env.OPENROUTER_API_KEY === 'string' && env.OPENROUTER_API_KEY.trim()) return env;
153
+ const paths = preferencePaths({ ...options, env });
154
+ const encrypted = decryptSecrets(paths, env);
155
+ if (typeof encrypted.OPENROUTER_API_KEY === 'string' && encrypted.OPENROUTER_API_KEY.trim()) {
156
+ env.OPENROUTER_API_KEY = encrypted.OPENROUTER_API_KEY.trim();
157
+ return env;
158
+ }
159
+ // Read-only migration compatibility. The legacy value is consumed but never copied into a new
160
+ // file or returned to the caller; the console marks it for migration.
161
+ const legacy = readJSON(paths.config);
162
+ if (typeof legacy?.openrouterKey === 'string' && legacy.openrouterKey.trim()) {
163
+ env.OPENROUTER_API_KEY = legacy.openrouterKey.trim();
164
+ }
165
+ return env;
166
+ }
167
+
168
+ function ageRecipient(identityFile, env) {
169
+ if (!fs.existsSync(identityFile) || !commandExists('age-keygen', env)) return null;
170
+ const run = spawnSync('age-keygen', ['-y', identityFile], {
171
+ env,
172
+ encoding: 'utf8',
173
+ shell: false,
174
+ timeout: 5000,
175
+ });
176
+ return run.status === 0 ? run.stdout.trim() : null;
177
+ }
178
+
179
+ export function saveOpenRouterCredential(secret, options = {}) {
180
+ if (typeof secret !== 'string' || secret.trim().length < 9) {
181
+ return { ok: false, log: 'OpenRouter key was not saved — enter a complete key.' };
182
+ }
183
+ const env = options.env || process.env;
184
+ const paths = preferencePaths({ ...options, env });
185
+ if (!commandExists('sops', env) || !commandExists('age-keygen', env)) {
186
+ return { ok: false, log: 'OpenRouter key was not saved — SOPS and age are required for encrypted storage. OPENROUTER_API_KEY in your environment remains supported.' };
187
+ }
188
+ const recipient = ageRecipient(paths.ageIdentity, env);
189
+ if (!recipient) {
190
+ return { ok: false, log: 'OpenRouter key was not saved — no SOPS age identity is configured. Set SOPS_AGE_KEY_FILE or use OPENROUTER_API_KEY.' };
191
+ }
192
+ const input = JSON.stringify({ OPENROUTER_API_KEY: secret.trim() });
193
+ const run = spawnSync('sops', [
194
+ 'encrypt',
195
+ '--input-type', 'json',
196
+ '--output-type', 'json',
197
+ '--age', recipient,
198
+ '/dev/stdin',
199
+ ], {
200
+ env,
201
+ input,
202
+ encoding: 'utf8',
203
+ shell: false,
204
+ timeout: 10_000,
205
+ });
206
+ if (run.status !== 0 || !run.stdout.trim()) {
207
+ return { ok: false, log: 'OpenRouter key was not saved — SOPS encryption failed. The plaintext was not written to disk.' };
208
+ }
209
+ try {
210
+ fs.mkdirSync(path.dirname(paths.secrets), { recursive: true, mode: 0o700 });
211
+ let backup = null;
212
+ if (fs.existsSync(paths.secrets)) {
213
+ backup = `${paths.secrets}.bak-${new Date().toISOString().replace(/[:.]/g, '-')}`;
214
+ fs.copyFileSync(paths.secrets, backup, fs.constants.COPYFILE_EXCL);
215
+ fs.chmodSync(backup, 0o600);
216
+ }
217
+ const tmp = `${paths.secrets}.tmp-${process.pid}-${Date.now()}`;
218
+ fs.writeFileSync(tmp, run.stdout, { flag: 'wx', mode: 0o600 });
219
+ fs.renameSync(tmp, paths.secrets);
220
+ fs.chmodSync(paths.secrets, 0o600);
221
+ return {
222
+ ok: true,
223
+ log: 'OpenRouter key saved with SOPS+age encryption.',
224
+ path: paths.secrets,
225
+ backup,
226
+ existed: backup !== null,
227
+ };
228
+ } catch {
229
+ return { ok: false, log: 'OpenRouter key was encrypted but could not be saved. No plaintext file was created.' };
230
+ }
231
+ }
232
+
233
+ export function seedProjectDefaults(options = {}) {
234
+ const state = loadRuntimePreferences(options);
235
+ if (!state.values.newProjectDefaults) return { ok: true, action: 'disabled', path: state.paths.project };
236
+ const cwd = options.cwd || process.cwd();
237
+ const looksLikeProject = ['.git', 'package.json', 'Cargo.toml', 'pyproject.toml', 'go.mod']
238
+ .some((name) => fs.existsSync(path.join(cwd, name)));
239
+ if (!looksLikeProject && options.env?.RUVNET_ALLOW_NONPROJECT_DEFAULTS !== '1') {
240
+ return { ok: true, action: 'not-a-project', path: state.paths.project };
241
+ }
242
+ if (fs.existsSync(state.paths.project)) return { ok: true, action: 'already-initialized', path: state.paths.project };
243
+ const values = {};
244
+ for (const key of PROJECT_KEYS) {
245
+ const value = state.values[key];
246
+ if (value !== null && value !== undefined) values[key] = value;
247
+ }
248
+ const payload = {
249
+ version: 1,
250
+ inheritedAt: new Date().toISOString(),
251
+ source: 'user-defaults',
252
+ values,
253
+ };
254
+ try {
255
+ fs.mkdirSync(path.dirname(state.paths.project), { recursive: true, mode: 0o700 });
256
+ fs.writeFileSync(state.paths.project, `${JSON.stringify(payload, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
257
+ return { ok: true, action: 'created', path: state.paths.project, values };
258
+ } catch (error) {
259
+ if (error?.code === 'EEXIST') return { ok: true, action: 'already-initialized', path: state.paths.project };
260
+ return { ok: false, action: 'failed', path: state.paths.project, error: error?.message || String(error) };
261
+ }
262
+ }
263
+
264
+ if (process.argv.includes('--learning-scope')) {
265
+ process.stdout.write(`${loadRuntimePreferences().values.learningScope}\n`);
266
+ } else if (process.argv.includes('--seed-project')) {
267
+ const result = seedProjectDefaults();
268
+ if (!result.ok) process.exitCode = 1;
269
+ }