ruvnet-brain 4.0.1 → 4.0.4

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 (195) hide show
  1. package/.claude-plugin/marketplace.json +1 -0
  2. package/README.md +4 -4
  3. package/bin/install.mjs +303 -24
  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/commands/whats-new.md +6 -6
  34. package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
  35. package/plugin/hooks/hooks.json +1 -2
  36. package/plugin/mcp/managed-cli-interface.mjs +47 -4
  37. package/plugin/mcp/server.mjs +90 -32
  38. package/plugin/scripts/detach.mjs +14 -0
  39. package/plugin/scripts/first-session-worker.mjs +38 -0
  40. package/plugin/scripts/ground-ruvnet.sh +16 -6
  41. package/plugin/scripts/hook-shim.mjs +34 -29
  42. package/plugin/scripts/learn-capture.sh +22 -3
  43. package/plugin/scripts/learn-flush.mjs +21 -4
  44. package/plugin/scripts/runtime-preferences.mjs +269 -0
  45. package/plugin/scripts/session-start-core.mjs +503 -0
  46. package/plugin/scripts/session-start.sh +3 -858
  47. package/plugin/scripts/whats-new.mjs +42 -0
  48. package/plugin/skills/brain-console/SKILL.md +4 -2
  49. package/plugin/skills/release-proof/SKILL.md +98 -0
  50. package/plugin/skills/release-proof/agents/openai.yaml +4 -0
  51. package/plugin/skills/release-proof/references/receipt-contract.md +44 -0
  52. package/plugin/skills/release-proof/scripts/release-proof.mjs +286 -0
  53. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +5 -1
  54. package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
  55. package/plugin/skills/rvbc/SKILL.md +9 -6
  56. package/plugin/skills/whats-new/SKILL.md +4 -4
  57. package/scripts/adr-backfill.mjs +107 -0
  58. package/scripts/advocacy-outcomes.mjs +808 -0
  59. package/scripts/agentdb-context.mjs +216 -0
  60. package/scripts/agentdb-fleet-doctor.mjs +101 -0
  61. package/scripts/ascii-drift.mjs +236 -0
  62. package/scripts/behavioral-l1-l4.mjs +210 -0
  63. package/scripts/brain-capability-check.mjs +72 -0
  64. package/scripts/brain-grade-groundtruth.mjs +100 -0
  65. package/scripts/brain-latency-50.mjs +227 -0
  66. package/scripts/brain-novice-50.mjs +189 -0
  67. package/scripts/brain-stamp.mjs +94 -0
  68. package/scripts/brain-state.mjs +212 -0
  69. package/scripts/build-bundle.mjs +531 -0
  70. package/scripts/build-concepts.mjs +132 -0
  71. package/scripts/build-l2.mjs +71 -0
  72. package/scripts/build-primer.mjs +73 -0
  73. package/scripts/build-symbols.mjs +68 -0
  74. package/scripts/calibrate-router.mjs +97 -0
  75. package/scripts/capability-audit.mjs +321 -0
  76. package/scripts/capability-registry.mjs +876 -0
  77. package/scripts/check-indexation.mjs +108 -0
  78. package/scripts/check-legibility.mjs +189 -0
  79. package/scripts/ci/build-fixture-kb.mjs +67 -0
  80. package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
  81. package/scripts/ci/learning-replay-recorder.mjs +59 -0
  82. package/scripts/ci/mutate-hook-timeout.mjs +70 -0
  83. package/scripts/ci/stranger-fixture-stage.mjs +17 -0
  84. package/scripts/ci/stranger-scenario.mjs +228 -0
  85. package/scripts/ci/stranger-timeout.mjs +25 -0
  86. package/scripts/ci-verdict.mjs +29 -0
  87. package/scripts/claims-verify.mjs +710 -0
  88. package/scripts/clear-claude-tmp.sh +31 -0
  89. package/scripts/console-engine.mjs +434 -0
  90. package/scripts/console-engine.test.mjs +125 -0
  91. package/scripts/corpus-qa.mjs +250 -0
  92. package/scripts/correction-detect-embed.mjs +346 -0
  93. package/scripts/correction-detect-measure.mjs +270 -0
  94. package/scripts/correction-detect.mjs +686 -0
  95. package/scripts/count-chunks.mjs +54 -0
  96. package/scripts/described-questions.json +30 -0
  97. package/scripts/design-grade.mjs +58 -0
  98. package/scripts/dev-plugin-link.sh +105 -0
  99. package/scripts/distill-project.mjs +200 -0
  100. package/scripts/doc-currency.mjs +801 -0
  101. package/scripts/eval-brain.mjs +244 -0
  102. package/scripts/fix-metaharness-memretrieve.mjs +121 -0
  103. package/scripts/fix-workstream.mjs +291 -0
  104. package/scripts/full-hints.mjs +87 -0
  105. package/scripts/gate.sh +39 -0
  106. package/scripts/gates.mjs +146 -0
  107. package/scripts/gen-console-images.mjs +54 -0
  108. package/scripts/gen-images.mjs +47 -0
  109. package/scripts/git-clone-refresh.mjs +52 -0
  110. package/scripts/git-hooks/pre-push +126 -0
  111. package/scripts/goal-match.mjs +398 -0
  112. package/scripts/goldie-research.mjs +223 -0
  113. package/scripts/goldie-weekly.sh +67 -0
  114. package/scripts/health-repair.mjs +237 -0
  115. package/scripts/helix-scenario-questions.json +10 -0
  116. package/scripts/ingest-gists.mjs +230 -0
  117. package/scripts/ingest-meeting.mjs +115 -0
  118. package/scripts/ingest-repo.mjs +79 -0
  119. package/scripts/install-npx-witness.sh +49 -0
  120. package/scripts/issue-fix.mjs +558 -0
  121. package/scripts/issue-watch.mjs +276 -0
  122. package/scripts/issue4-close-note.md +31 -0
  123. package/scripts/key-canary.mjs +91 -0
  124. package/scripts/latency-to-surface.mjs +233 -0
  125. package/scripts/learning-enable.mjs +380 -0
  126. package/scripts/learning-replay.mjs +1570 -0
  127. package/scripts/learnings.mjs +62 -0
  128. package/scripts/lesson-gate.mjs +680 -0
  129. package/scripts/lesson-lifecycle.mjs +449 -0
  130. package/scripts/lesson-promote.mjs +262 -0
  131. package/scripts/lesson-ratify.mjs +98 -0
  132. package/scripts/lesson-seed.mjs +252 -0
  133. package/scripts/lesson-store.mjs +447 -0
  134. package/scripts/loop-checkpoint.mjs +86 -0
  135. package/scripts/memdb-health.sh +14 -0
  136. package/scripts/memory-doctor.mjs +326 -0
  137. package/scripts/model-catalog.mjs +79 -0
  138. package/scripts/nightly-controller.mjs +66 -0
  139. package/scripts/nightly-gists.sh +72 -0
  140. package/scripts/nightly-wrapper.sh +172 -0
  141. package/scripts/notify.sh +12 -0
  142. package/scripts/npx-witness.sh +56 -0
  143. package/scripts/onboarding-console.mjs +2922 -0
  144. package/scripts/private-fence.mjs +69 -0
  145. package/scripts/proactivity-metrics.mjs +118 -0
  146. package/scripts/proof-questions.json +56 -0
  147. package/scripts/protected-release-invocation.mjs +76 -0
  148. package/scripts/prove.mjs +95 -0
  149. package/scripts/proxy/claude-proxied.sh +57 -0
  150. package/scripts/proxy/proxy-revert.sh +59 -0
  151. package/scripts/proxy/proxy-up.sh +60 -0
  152. package/scripts/proxy/proxy-verify.mjs +142 -0
  153. package/scripts/publication-receipt.mjs +307 -0
  154. package/scripts/published-surface-probe.mjs +241 -0
  155. package/scripts/qe/card-lane-gate.mjs +162 -0
  156. package/scripts/qe/session-start-gate.mjs +229 -0
  157. package/scripts/qe/ux-suite.mjs +323 -0
  158. package/scripts/reconcile-project.mjs +0 -0
  159. package/scripts/record-lesson.mjs +113 -0
  160. package/scripts/refresh-model-catalog.mjs +99 -0
  161. package/scripts/release-authority.mjs +93 -0
  162. package/scripts/release-proof.mjs +9 -0
  163. package/scripts/release-vector.mjs +281 -0
  164. package/scripts/release.mjs +439 -0
  165. package/scripts/remedy-registry.mjs +247 -0
  166. package/scripts/rerank-cap-eval.mjs +265 -0
  167. package/scripts/rerank-cap-warm-ab.mjs +129 -0
  168. package/scripts/route-cheap.mjs +20 -15
  169. package/scripts/router-utilization.mjs +182 -0
  170. package/scripts/routing-flywheel.mjs +596 -0
  171. package/scripts/rvf-generation.mjs +104 -0
  172. package/scripts/rvf-index-audit.mjs +138 -0
  173. package/scripts/self-update.mjs +296 -0
  174. package/scripts/selfcheck.mjs +7 -1
  175. package/scripts/sign-bundle.mjs +69 -0
  176. package/scripts/signal-watch.mjs +171 -0
  177. package/scripts/stabilization-receipt.mjs +108 -0
  178. package/scripts/stack-sync.mjs +469 -0
  179. package/scripts/stamp-existing-rvf-generations.mjs +53 -0
  180. package/scripts/stamp-sweep.mjs +144 -0
  181. package/scripts/status-honesty.mjs +102 -0
  182. package/scripts/sync-version.mjs +217 -0
  183. package/scripts/token-report.mjs +102 -0
  184. package/scripts/top100-benchmark.mjs +479 -0
  185. package/scripts/top100-corpus.mjs +112 -0
  186. package/scripts/top100-semantic-assertions.mjs +449 -0
  187. package/scripts/update-apply.mjs +9 -0
  188. package/scripts/upgrade-notice.mjs +14 -0
  189. package/scripts/verify-bundle.mjs +51 -0
  190. package/scripts/verify-channels.mjs +184 -0
  191. package/scripts/verify-model-catalog.mjs +104 -0
  192. package/scripts/verify-nightly-close-issue4.sh +31 -0
  193. package/scripts/version.mjs +40 -0
  194. package/scripts/wired-check.mjs +867 -0
  195. package/plugin/scripts/finalize-token-meter.mjs +0 -25
@@ -41,14 +41,15 @@ const CHILD_MCP = process.env.RUVNET_BRAIN_CHILD_MCP || path.join(KB, 'forge-mcp
41
41
  const ACTIVE = path.join(BRAIN_HOME, 'active.json');
42
42
  const LEASES = path.join(BRAIN_HOME, 'leases');
43
43
  const LEASE = path.join(LEASES, `mcp-${process.pid}.json`);
44
+ const READINESS = path.join(BRAIN_HOME, 'mcp-readiness.json');
44
45
 
45
46
  const PROTOCOL_VERSION = '2024-11-05';
46
47
  const SERVER_INFO = { name: 'ruvnet-brain', version: '2.0.0' };
47
- // Local frozen tool declarations. search_ruvnet is only a fallback: a live child's declaration wins
48
- // so its description can refresh. The two managed-CLI schemas are owned by this protocol shell.
48
+ // Stable declarations belong to this protocol shell. Capability discovery must never wait for a
49
+ // model or vector store; the first operation that needs the worker joins its readiness promise.
49
50
  const SEARCH_TOOL = {
50
51
  name: 'search_ruvnet',
51
- description: 'Source-grounded knowledge base for the RuvNet ecosystem. (Brain bundle not installed on this machine — calls will return install guidance.)',
52
+ description: 'Source-grounded knowledge base for the RuvNet ecosystem. The first call may wait while the local search worker becomes ready.',
52
53
  inputSchema: {
53
54
  type: 'object',
54
55
  properties: {
@@ -60,22 +61,45 @@ const SEARCH_TOOL = {
60
61
  };
61
62
  const FALLBACK_TOOLS = [SEARCH_TOOL, ...MANAGED_CLI_TOOLS];
62
63
 
63
- function withLocalTools(tools) {
64
- const merged = new Map();
65
- for (const tool of Array.isArray(tools) ? tools : []) {
66
- if (tool?.name) merged.set(tool.name, tool);
67
- }
68
- for (const tool of FALLBACK_TOOLS) {
69
- if (!merged.has(tool.name)) merged.set(tool.name, tool);
70
- }
71
- return [...merged.values()];
72
- }
73
-
74
64
  const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\n');
75
65
  const clientOk = (id, result) => out({ jsonrpc: '2.0', id, result });
76
66
  const clientErr = (id, code, message) => out({ jsonrpc: '2.0', id, error: { code, message } });
77
67
  const readJSON = (f) => { try { return JSON.parse(fs.readFileSync(f, 'utf8')); } catch { return null; } };
78
68
 
69
+ function writeReadiness(value) {
70
+ try {
71
+ fs.mkdirSync(BRAIN_HOME, { recursive: true });
72
+ const tmp = `${READINESS}.${process.pid}.${Date.now()}.tmp`;
73
+ fs.writeFileSync(tmp, `${JSON.stringify({ ...value, pid: process.pid, at: new Date().toISOString() })}\n`, { mode: 0o600 });
74
+ fs.renameSync(tmp, READINESS);
75
+ } catch (error) {
76
+ console.error(`[ruvnet-brain] could not persist MCP readiness: ${error.message}`);
77
+ }
78
+ }
79
+
80
+ function recordStartupFailure({ phase, startedAt, generation, error }) {
81
+ const now = Date.now();
82
+ const fields = {
83
+ state: 'degraded', phase, generation, elapsedMs: now - startedAt,
84
+ retryable: true, retryState: 'next-search-retries',
85
+ error: String(error?.message || error || 'unknown startup failure').slice(0, 500),
86
+ };
87
+ writeReadiness(fields);
88
+ const healthPath = path.join(BRAIN_HOME, 'health.json');
89
+ try {
90
+ fs.mkdirSync(BRAIN_HOME, { recursive: true });
91
+ const tmp = `${healthPath}.${process.pid}.${now}.tmp`;
92
+ fs.writeFileSync(tmp, `${JSON.stringify({
93
+ status: 'down', source: 'mcp-parent-startup', phase,
94
+ error: fields.error, elapsedMs: fields.elapsedMs, generation,
95
+ retryState: fields.retryState, ts: new Date(now).toISOString(),
96
+ })}\n`, { mode: 0o600 });
97
+ fs.renameSync(tmp, healthPath);
98
+ } catch (writeError) {
99
+ console.error(`[ruvnet-brain] could not persist startup health: ${writeError.message}`);
100
+ }
101
+ }
102
+
79
103
  function currentGeneration() {
80
104
  const a = readJSON(ACTIVE);
81
105
  let brainMtime = 0;
@@ -124,6 +148,7 @@ function killChild(reason) {
124
148
  clearChildIdleTimer();
125
149
  if (!child) return childRetirement;
126
150
  const c = child; child = null;
151
+ c.intentionalStop = true;
127
152
  for (const [, p] of c.pending) p.reject(new Error(`brain worker ${reason}`));
128
153
  c.pending.clear();
129
154
  childRetirement = new Promise((resolve) => {
@@ -147,16 +172,22 @@ function killChild(reason) {
147
172
  async function ensureChild() {
148
173
  const gen = currentGeneration();
149
174
  if (child && child.generation !== gen && pendingCount === 0) await killChild('superseded by a newer generation');
175
+ // A concurrent initialize/tools-list/tools-call must join the same readiness promise. Returning
176
+ // `child` while its private initialize or model warmup is still running advertises a half-ready
177
+ // worker and charges cold-start to the first user call.
178
+ if (childStartup) return childStartup;
150
179
  if (child) return child;
151
180
  await childRetirement;
152
181
  if (!fs.existsSync(CHILD_MCP)) return null;
153
182
 
154
183
  if (!childStartup) {
155
184
  childStartup = (async () => {
185
+ const startedAt = Date.now();
186
+ let phase = 'initialize';
156
187
  const env = { ...process.env, KB_DIR: KB };
157
188
  if (!env.KB_MODEL_CACHE) env.KB_MODEL_CACHE = path.join(BRAIN_HOME, 'models');
158
189
  const proc = spawn(process.execPath, [CHILD_MCP], { stdio: ['pipe', 'pipe', 'inherit'], env });
159
- const c = { proc, generation: currentGeneration(), nextId: 1, pending: new Map() };
190
+ const c = { proc, generation: currentGeneration(), nextId: 1, pending: new Map(), intentionalStop: false };
160
191
  const rl = readline.createInterface({ input: proc.stdout });
161
192
  rl.on('line', (line) => {
162
193
  let msg; try { msg = JSON.parse(line); } catch { return; }
@@ -167,6 +198,13 @@ async function ensureChild() {
167
198
  if (child === c) child = null;
168
199
  for (const [, p] of c.pending) p.reject(new Error('brain worker exited'));
169
200
  c.pending.clear();
201
+ if (!c.intentionalStop) {
202
+ writeReadiness({
203
+ state: 'degraded', phase: 'worker-exit', generation: c.generation,
204
+ elapsedMs: Date.now() - startedAt, retryable: true,
205
+ retryState: 'next-search-retries', error: 'brain worker exited unexpectedly',
206
+ });
207
+ }
170
208
  });
171
209
  proc.on('error', () => { if (child === c) child = null; });
172
210
  child = c;
@@ -176,11 +214,29 @@ async function ensureChild() {
176
214
  protocolVersion: PROTOCOL_VERSION,
177
215
  capabilities: {},
178
216
  clientInfo: { name: 'ruvnet-brain-shell', version: SERVER_INFO.version },
179
- }, CHILD_INIT_TIMEOUT_MS);
217
+ }, CHILD_INIT_TIMEOUT_MS, { reportTimeout: false });
218
+ phase = 'warmup';
219
+ const warmed = await childRequest(c, 'brain/warmup', {}, CHILD_INIT_TIMEOUT_MS, { reportTimeout: false });
220
+ // Rolling-upgrade compatibility: 4.0.1 workers predate the private warmup extension. The
221
+ // stable shell is installed before the 700MB KB swap completes, so rejecting -32601 here
222
+ // makes every query fail during the exact in-place upgrade window the shell exists to hide.
223
+ // An older initialized worker is usable (its first query may be cold); any other warmup
224
+ // failure still fails closed.
225
+ const warmupUnsupported = warmed.error?.code === -32601
226
+ || /method not found/i.test(String(warmed.error?.message || ''));
227
+ if (!warmupUnsupported && (warmed.error || warmed.result?.ready !== true)) {
228
+ throw new Error(warmed.error?.message || 'brain worker did not confirm warm readiness');
229
+ }
180
230
  } catch (e) {
181
231
  await killChild('failed initialize');
232
+ recordStartupFailure({ phase, startedAt, generation: c.generation, error: e });
182
233
  throw new Error(`brain worker failed to initialize: ${e.message}`);
183
234
  }
235
+ writeReadiness({
236
+ state: 'ready', phase, generation: c.generation,
237
+ workerPid: c.proc.pid, elapsedMs: Date.now() - startedAt,
238
+ retryable: false, retryState: 'none',
239
+ });
184
240
  armChildIdleTimer(c);
185
241
  return c;
186
242
  })();
@@ -221,13 +277,13 @@ async function onChildTimeout(method, timeoutMs) {
221
277
  // Steady-state latency remains a separate release gate; this budget prevents a permanent cold loop.
222
278
  const CALL_TIMEOUT_MS = Number(process.env.RUVNET_BRAIN_CALL_TIMEOUT_MS) || 240_000;
223
279
 
224
- function childRequest(c, method, params, timeoutMs = CALL_TIMEOUT_MS) {
280
+ function childRequest(c, method, params, timeoutMs = CALL_TIMEOUT_MS, { reportTimeout = true } = {}) {
225
281
  return new Promise((resolve, reject) => {
226
282
  const id = c.nextId++;
227
283
  const timer = setTimeout(() => {
228
284
  c.pending.delete(id);
229
285
  reject(new Error(`brain worker timeout on ${method}`));
230
- void onChildTimeout(method, timeoutMs); // after the reject — the caller must not wait on the alarm
286
+ if (reportTimeout) void onChildTimeout(method, timeoutMs); // caller never waits on the alarm
231
287
  }, timeoutMs);
232
288
  c.pending.set(id, { resolve: (m) => { clearTimeout(timer); resolve(m); }, reject: (e) => { clearTimeout(timer); reject(e); } });
233
289
  try { c.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); }
@@ -241,23 +297,16 @@ async function handleClient(msg) {
241
297
  if (id === undefined || id === null) return; // notifications need no answer
242
298
  switch (method) {
243
299
  case 'initialize':
300
+ // MCP initialize itself stays protocol-fast; normal clients request tools/list immediately
301
+ // afterwards, which joins this readiness promise and does not receive the tool declaration
302
+ // until the worker's query embedder and cross-encoder are warm.
303
+ void ensureChild().catch((e) => {
304
+ console.error(`[ruvnet-brain] worker warmup failed during initialize: ${e.message}`);
305
+ });
244
306
  return clientOk(id, { protocolVersion: PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO });
245
307
  case 'ping':
246
308
  return clientOk(id, {});
247
309
  case 'tools/list': {
248
- try {
249
- const c = await ensureChild();
250
- if (c) {
251
- try {
252
- const r = await childRequest(c, 'tools/list', {}, 15_000);
253
- if (r.result?.tools?.length) {
254
- armChildIdleTimer(c);
255
- return clientOk(id, { ...r.result, tools: withLocalTools(r.result.tools) });
256
- }
257
- }
258
- catch { /* fall through to the static declaration */ }
259
- }
260
- } catch { /* fall through to the static declaration during a transient startup outage */ }
261
310
  return clientOk(id, { tools: FALLBACK_TOOLS });
262
311
  }
263
312
  case 'tools/call': {
@@ -300,4 +349,13 @@ clientRl.on('line', (line) => {
300
349
  let msg; try { msg = JSON.parse(line); } catch { return; } // malformed line: ignore, never crash
301
350
  handleClient(msg).catch((e) => { if (msg.id !== undefined && msg.id !== null) clientErr(msg.id, -32603, e.message); });
302
351
  });
303
- clientRl.on('close', () => { killChild('client disconnected'); process.exit(0); });
352
+ let shuttingDown = false;
353
+ async function shutdown(reason) {
354
+ if (shuttingDown) return;
355
+ shuttingDown = true;
356
+ await killChild(reason);
357
+ process.exit(0);
358
+ }
359
+ clientRl.on('close', () => { void shutdown('client disconnected'); });
360
+ process.once('SIGTERM', () => { void shutdown('parent received SIGTERM'); });
361
+ process.once('SIGINT', () => { void shutdown('parent received SIGINT'); });
@@ -107,11 +107,25 @@ if (!SUPERVISOR) {
107
107
  ? path.join(process.env.SystemRoot, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')
108
108
  : 'powershell.exe';
109
109
  const quotePs = (value) => `'${String(value).replaceAll("'", "''")}'`;
110
+ // Start-Process has its own standard-stream boundary. On GitHub's packed PowerShell install
111
+ // path the SessionStart body finished but the external checker never received `close` before
112
+ // its 5s watchdog — consistent with a descendant retaining a capture handle, though that job
113
+ // did not instrument exact handle ownership. Redirect both streams to distinct files at the
114
+ // native boundary so the supervisor cannot inherit the hook's capture streams; redirecting
115
+ // only this short Node wrapper's stdio does not establish that contract for its grandchild.
116
+ const supervisorOut = logPath && logPath !== '-'
117
+ ? `${logPath}.supervisor.stdout`
118
+ : path.join(os.tmpdir(), `ruvnet-brain-detach-supervisor-${process.pid}.stdout`);
119
+ const supervisorErr = logPath && logPath !== '-'
120
+ ? `${logPath}.supervisor.stderr`
121
+ : path.join(os.tmpdir(), `ruvnet-brain-detach-supervisor-${process.pid}.stderr`);
110
122
  const launch = [
111
123
  'Start-Process',
112
124
  '-FilePath', quotePs(process.execPath),
113
125
  '-ArgumentList', `@(${quotePs(SELF)},${quotePs('--payload-env')})`,
114
126
  '-WindowStyle', 'Hidden',
127
+ '-RedirectStandardOutput', quotePs(supervisorOut),
128
+ '-RedirectStandardError', quotePs(supervisorErr),
115
129
  ].join(' ');
116
130
  child = spawn(powershell, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', launch], {
117
131
  detached: true,
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // One detached first-session lifecycle worker: seed the Stable Spine, then perform the update
3
+ // heartbeat the seed used to race. Keeping both operations in one worker preserves ADR-054's
4
+ // "Brain OFF still receives fixes" contract without making SessionStart launch two detachers.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawnSync } from 'node:child_process';
8
+
9
+ const [updateApply, hostUpdate, versionLog] = process.argv.slice(2);
10
+ if (![updateApply, hostUpdate, versionLog].every((value) => typeof value === 'string' && value)) {
11
+ process.stderr.write('usage: first-session-worker.mjs <update-apply.mjs> <host-update.mjs> <version-log>\n');
12
+ process.exit(2);
13
+ }
14
+
15
+ const seed = spawnSync(process.execPath, [updateApply, '--seed'], {
16
+ env: process.env,
17
+ stdio: 'inherit',
18
+ });
19
+ if (seed.error || seed.status !== 0) {
20
+ process.stderr.write(`[ruvnet-brain] first-session seed failed: ${seed.error?.message || `exit ${seed.status}`}\n`);
21
+ process.exit(1);
22
+ }
23
+
24
+ const check = spawnSync(process.execPath, [hostUpdate, '--check'], {
25
+ env: process.env,
26
+ encoding: 'utf8',
27
+ });
28
+ const version = String(check.stdout || '').trim();
29
+ if (!check.error && check.status === 0 && /^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$/.test(version)) {
30
+ fs.mkdirSync(path.dirname(versionLog), { recursive: true });
31
+ const temporary = `${versionLog}.tmp-${process.pid}`;
32
+ fs.writeFileSync(temporary, `${version}\n`);
33
+ fs.renameSync(temporary, versionLog);
34
+ }
35
+
36
+ // Network failure must not undo a successful seed. The 15-minute heartbeat stamp lets the normal
37
+ // SessionStart path retry later, while the current runtime remains valid and usable.
38
+ process.exit(0);
@@ -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,23 +75,23 @@ 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' },
85
- 'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
84
+ 'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
85
+ 'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
86
86
  'hijack-ruvnet': { file: 'hijack-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
87
- 'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run' },
88
- 'ground-before-write': { file: 'ground-before-write.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run' },
87
+ 'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
88
+ 'ground-before-write': { file: 'ground-before-write.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
89
89
  'grounding-stamp': { file: 'grounding-stamp.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
90
90
  'verify-interface': { file: 'verify-interface.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
91
- 'design-wall': { file: 'design-wall.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run' },
91
+ 'design-wall': { file: 'design-wall.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
92
92
  // The consent guard (ADR-054 §3): it protects the OFF state itself, so it is the one hook that
93
93
  // matters MORE while the brain is off. 'run', permanently.
94
- 'protect-state': { file: 'protect-brain-state.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run' },
94
+ 'protect-state': { file: 'protect-brain-state.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 },
95
95
  'learn-capture': { file: 'learn-capture.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
96
96
  'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
97
97
  'md-stamp': { file: 'md-stamp.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
@@ -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
@@ -112,7 +112,7 @@ const TABLE = {
112
112
  // exit code, and an opted-in lesson BLOCK must propagate as exit 2 (an advisory delivery is exit 0
113
113
  // and passes straight through; a spawn error is exit 1, which CC treats as a non-blocking notice).
114
114
  // The CC event name is forwarded to the runtime as an extra argv (see runHook's arg plumbing).
115
- 'unprompted-speech': { file: 'unprompted-runtime.mjs', interpreter: 'node', mode: 'blocking', channel: 'unprompted', offBehavior: 'silence' },
115
+ 'unprompted-speech': { file: 'unprompted-runtime.mjs', interpreter: 'node', mode: 'blocking', channel: 'unprompted', offBehavior: 'silence', stdinBytes: 65536 },
116
116
  // THE LIFECYCLE PLANE (ADR-055 §2, build item 1). Stop was the ONE registered hook that bypassed
117
117
  // this table entirely — hooks.json pointed straight at continuation-gate.mjs, so it had no mode,
118
118
  // no offBehavior, and no spine resolution, contradicting this file's own `_note` and leaving
@@ -156,7 +156,7 @@ if (BRAIN_OFF && entry.offBehavior === 'silence') process.exit(0);
156
156
  // already running, and skip the interpreter entirely only when BOTH prompt intent and project
157
157
  // state prove that no advisory can fire. The regex deliberately over-approximates the shell gates:
158
158
  // false positives take the established body; false negatives would be a product defect.
159
- let groundInput = null;
159
+ let hookInput = null;
160
160
  const GROUND_RELEVANT = /ruvnet|ruflo|ruvector|\brvf\b|agentdb|agenticow|rulake|ruview|rupixel|ruv-fann|agentic-flow|synthlang|dspy|qudag|safla|metaharness|cve-bench|sparc|swarm|claude-flow|pinecone|pgvector|chroma|weaviate|faiss|milvus|qdrant|hnswlib|annoy|vector|langchain|llama|autogen|crew-ai|semantic-kernel|embedding|retrieval|prompt compression|token cost|post-quantum|quantum-resistant|\badr\b|decision|architect|design|plan|spec|refactor|migrat|implement|build|write|add|change|fix|update|deploy|create|enhance|set up|setup|wire|integrate|test|coverage|audit|review|benchmark|lint|scan|debug|optimi|app|feature|service|system|backend|frontend|\bapi\b|module|pipeline|infra|database|schema|workflow|roadmap|milestone|autonomous|unattended|do not stop|keep working|keep going|soak run|harness|quality|readiness|evolve|self-improv|hardening|cheaper|cheap|lower cost|compute arbitrage|cascade|scorecard|score .*repo/i;
161
161
 
162
162
  function projectCanSpeakWithoutPrompt() {
@@ -173,7 +173,7 @@ function projectCanSpeakWithoutPrompt() {
173
173
  return false;
174
174
  }
175
175
 
176
- function readGroundInput() {
176
+ function readHookInput(limit) {
177
177
  return new Promise((resolve) => {
178
178
  const chunks = [];
179
179
  let bytes = 0;
@@ -194,8 +194,8 @@ function readGroundInput() {
194
194
  idle.unref?.();
195
195
  };
196
196
  process.stdin.on('data', (chunk) => {
197
- if (bytes < 32768) {
198
- const kept = chunk.subarray(0, 32768 - bytes);
197
+ if (bytes < limit) {
198
+ const kept = chunk.subarray(0, limit - bytes);
199
199
  chunks.push(kept);
200
200
  bytes += kept.length;
201
201
  }
@@ -247,15 +247,15 @@ 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;
257
- const io = hookId === 'ground-ruvnet' && groundInput !== null
258
- ? { stdio: ['pipe', 'inherit', 'inherit'], input: groundInput }
257
+ const io = hookInput !== null
258
+ ? { stdio: ['pipe', 'inherit', 'inherit'], input: hookInput }
259
259
  : { stdio: 'inherit' };
260
260
  const r = spawnSync(cmd, [file, ...extraArgs], { ...io, env });
261
261
  if (r.error) {
@@ -287,17 +287,22 @@ function dispatchHook() {
287
287
  return runHook(fallbackFile);
288
288
  }
289
289
 
290
- if (hookId === 'ground-ruvnet') {
291
- readGroundInput().then((input) => {
292
- groundInput = input;
293
- let text = input.toString('utf8');
294
- try {
295
- const parsed = JSON.parse(text);
296
- text = parsed?.prompt ?? parsed?.user_prompt ?? parsed?.input ?? text;
297
- } catch { /* raw/malformed input is classified as-is */ }
298
- if (!GROUND_RELEVANT.test(String(text)) && !projectCanSpeakWithoutPrompt()) process.exit(0);
290
+ if (entry.stdinBytes) {
291
+ readHookInput(entry.stdinBytes).then((input) => {
292
+ hookInput = input;
293
+ if (hookId === 'ground-ruvnet') {
294
+ let text = input.toString('utf8');
295
+ try {
296
+ const parsed = JSON.parse(text);
297
+ text = parsed?.prompt ?? parsed?.user_prompt ?? parsed?.input ?? text;
298
+ } catch { /* raw/malformed input is classified as-is */ }
299
+ if (!GROUND_RELEVANT.test(String(text)) && !projectCanSpeakWithoutPrompt()) process.exit(0);
300
+ }
299
301
  process.exit(dispatchHook());
300
- }).catch(() => process.exit(dispatchHook()));
302
+ }).catch(() => {
303
+ hookInput = Buffer.alloc(0);
304
+ process.exit(dispatchHook());
305
+ });
301
306
  } else {
302
307
  process.exit(dispatchHook());
303
308
  }
@@ -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` : '')