ruvnet-brain 4.0.2 → 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.
@@ -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) => {
@@ -157,10 +182,12 @@ async function ensureChild() {
157
182
 
158
183
  if (!childStartup) {
159
184
  childStartup = (async () => {
185
+ const startedAt = Date.now();
186
+ let phase = 'initialize';
160
187
  const env = { ...process.env, KB_DIR: KB };
161
188
  if (!env.KB_MODEL_CACHE) env.KB_MODEL_CACHE = path.join(BRAIN_HOME, 'models');
162
189
  const proc = spawn(process.execPath, [CHILD_MCP], { stdio: ['pipe', 'pipe', 'inherit'], env });
163
- const c = { proc, generation: currentGeneration(), nextId: 1, pending: new Map() };
190
+ const c = { proc, generation: currentGeneration(), nextId: 1, pending: new Map(), intentionalStop: false };
164
191
  const rl = readline.createInterface({ input: proc.stdout });
165
192
  rl.on('line', (line) => {
166
193
  let msg; try { msg = JSON.parse(line); } catch { return; }
@@ -171,6 +198,13 @@ async function ensureChild() {
171
198
  if (child === c) child = null;
172
199
  for (const [, p] of c.pending) p.reject(new Error('brain worker exited'));
173
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
+ }
174
208
  });
175
209
  proc.on('error', () => { if (child === c) child = null; });
176
210
  child = c;
@@ -180,8 +214,9 @@ async function ensureChild() {
180
214
  protocolVersion: PROTOCOL_VERSION,
181
215
  capabilities: {},
182
216
  clientInfo: { name: 'ruvnet-brain-shell', version: SERVER_INFO.version },
183
- }, CHILD_INIT_TIMEOUT_MS);
184
- const warmed = await childRequest(c, 'brain/warmup', {}, 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 });
185
220
  // Rolling-upgrade compatibility: 4.0.1 workers predate the private warmup extension. The
186
221
  // stable shell is installed before the 700MB KB swap completes, so rejecting -32601 here
187
222
  // makes every query fail during the exact in-place upgrade window the shell exists to hide.
@@ -194,8 +229,14 @@ async function ensureChild() {
194
229
  }
195
230
  } catch (e) {
196
231
  await killChild('failed initialize');
232
+ recordStartupFailure({ phase, startedAt, generation: c.generation, error: e });
197
233
  throw new Error(`brain worker failed to initialize: ${e.message}`);
198
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
+ });
199
240
  armChildIdleTimer(c);
200
241
  return c;
201
242
  })();
@@ -236,13 +277,13 @@ async function onChildTimeout(method, timeoutMs) {
236
277
  // Steady-state latency remains a separate release gate; this budget prevents a permanent cold loop.
237
278
  const CALL_TIMEOUT_MS = Number(process.env.RUVNET_BRAIN_CALL_TIMEOUT_MS) || 240_000;
238
279
 
239
- function childRequest(c, method, params, timeoutMs = CALL_TIMEOUT_MS) {
280
+ function childRequest(c, method, params, timeoutMs = CALL_TIMEOUT_MS, { reportTimeout = true } = {}) {
240
281
  return new Promise((resolve, reject) => {
241
282
  const id = c.nextId++;
242
283
  const timer = setTimeout(() => {
243
284
  c.pending.delete(id);
244
285
  reject(new Error(`brain worker timeout on ${method}`));
245
- 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
246
287
  }, timeoutMs);
247
288
  c.pending.set(id, { resolve: (m) => { clearTimeout(timer); resolve(m); }, reject: (e) => { clearTimeout(timer); reject(e); } });
248
289
  try { c.proc.stdin.write(JSON.stringify({ jsonrpc: '2.0', id, method, params }) + '\n'); }
@@ -266,19 +307,6 @@ async function handleClient(msg) {
266
307
  case 'ping':
267
308
  return clientOk(id, {});
268
309
  case 'tools/list': {
269
- try {
270
- const c = await ensureChild();
271
- if (c) {
272
- try {
273
- const r = await childRequest(c, 'tools/list', {}, 15_000);
274
- if (r.result?.tools?.length) {
275
- armChildIdleTimer(c);
276
- return clientOk(id, { ...r.result, tools: withLocalTools(r.result.tools) });
277
- }
278
- }
279
- catch { /* fall through to the static declaration */ }
280
- }
281
- } catch { /* fall through to the static declaration during a transient startup outage */ }
282
310
  return clientOk(id, { tools: FALLBACK_TOOLS });
283
311
  }
284
312
  case 'tools/call': {
@@ -321,4 +349,13 @@ clientRl.on('line', (line) => {
321
349
  let msg; try { msg = JSON.parse(line); } catch { return; } // malformed line: ignore, never crash
322
350
  handleClient(msg).catch((e) => { if (msg.id !== undefined && msg.id !== null) clientErr(msg.id, -32603, e.message); });
323
351
  });
324
- 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'); });
@@ -82,16 +82,16 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
82
82
  // ONE resolved answer instead of racing the filesystem again mid-run.
83
83
  const TABLE = {
84
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' },
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' },
@@ -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
  }
@@ -254,8 +254,8 @@ function runHook(file) {
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
  }
@@ -172,6 +172,20 @@ const health = (home, off) => {
172
172
  return { problem: '', absentByChoice };
173
173
  };
174
174
 
175
+ const mcpReadiness = (env, home) => {
176
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
177
+ const receipt = json(path.join(brainHome, 'mcp-readiness.json'));
178
+ if (receipt?.state === 'ready' && Number.isInteger(receipt.pid) && Number.isInteger(receipt.workerPid)) {
179
+ try {
180
+ process.kill(receipt.pid, 0);
181
+ process.kill(receipt.workerPid, 0);
182
+ return { state: 'ready', receipt };
183
+ } catch { /* a stale receipt is registration evidence, not live evidence */ }
184
+ }
185
+ if (receipt?.state === 'degraded') return { state: 'degraded', receipt };
186
+ return { state: 'registered', receipt };
187
+ };
188
+
175
189
  const announceVersion = ({ running, off, stateDir, consoleInvoke, emit }) => {
176
190
  if (off || !running) return;
177
191
  const announced = path.join(stateDir, '.last-announced-version');
@@ -412,6 +426,7 @@ export async function runSessionStart({
412
426
  }
413
427
  const source = json(path.join(home, '.cache', 'ruvnet-brain', 'kb', 'SOURCE.json'), {});
414
428
  const kbVersion = typeof source?.releaseTag === 'string' ? source.releaseTag : '';
429
+ const readiness = mcpReadiness(env, home);
415
430
 
416
431
  const grounding = json(path.join(stateDir, 'install-state.json'));
417
432
  const asciiDrift = path.join(env.CLAUDE_PROJECT_DIR || cwd, 'scripts', 'ascii-drift.mjs');
@@ -437,8 +452,19 @@ export async function runSessionStart({
437
452
  emit(`The plugin and its hooks are running, but the brain itself is broken (see the alarm above). Do not claim grounding works. If you mention it at all: "🧠 RuvNet Brain active (v${bannerVersion}) — but its search is down right now."`);
438
453
  } else {
439
454
  emit(`[RuvNet Brain v${bannerVersion} — active this session${updated ? ` · updated ${updated}` : ''}${kbVersion ? ` · knowledge bundle ${kbVersion}` : ''}]`);
440
- emit('USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project. search_ruvnet and the grounding hooks are live now.');
441
- emit(`Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "🧠 RuvNet Brain active (v${bannerVersion}${kbVersion ? `, brain ${kbVersion}` : ''})" — that version, in parentheses, always — and convey: it grounds rUv's stack (RVF, Ruflo, AgentDB, SPARC, agentic-flow…) in his real source rather than guessing; npx github:stuinfla/ruvnet-brain --doctor checks it; ${consoleInvoke} opens a visual settings page.`);
455
+ let confidenceInstruction;
456
+ if (readiness.state === 'ready') {
457
+ emit('USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project. search_ruvnet is ready and live; the grounding hooks are active.');
458
+ confidenceInstruction = `Open your FIRST response with ONE short, warm confirmation in your own words (2-3 lines, then move on; never repeat it this session). It must say "🧠 RuvNet Brain active (v${bannerVersion}${kbVersion ? `, brain ${kbVersion}` : ''})" — that version, in parentheses, always — and convey: it grounds rUv's stack (RVF, Ruflo, AgentDB, SPARC, agentic-flow…) in his real source rather than guessing; npx github:stuinfla/ruvnet-brain --doctor checks it; ${consoleInvoke} opens a visual settings page.`;
459
+ } else if (readiness.state === 'degraded') {
460
+ const receipt = readiness.receipt || {};
461
+ emit(`USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project. search_ruvnet is registered but degraded (${receipt.phase || 'startup'}: ${receipt.error || 'readiness failed'}); the grounding hooks remain active.`);
462
+ confidenceInstruction = `Open your FIRST response with ONE short line: "🧠 RuvNet Brain active (v${bannerVersion}) — search is degraded right now." Do not claim source grounding until a real search succeeds. npx github:stuinfla/ruvnet-brain --doctor shows the current verdict; ${consoleInvoke} opens the Console.`;
463
+ } else {
464
+ emit('USER-LEVEL: one brain (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project. search_ruvnet is registered; live readiness is not yet proven. The grounding hooks are active.');
465
+ confidenceInstruction = `Open your FIRST response with ONE short line: "🧠 RuvNet Brain active (v${bannerVersion}) — search is registered and will prove readiness on first use." Do not claim source grounding until a real search returns a citation. npx github:stuinfla/ruvnet-brain --doctor shows the current verdict; ${consoleInvoke} opens the Console.`;
466
+ }
467
+ emit(confidenceInstruction);
442
468
  }
443
469
 
444
470
  const claudeJson = read(path.join(home, '.claude.json'));
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Installed What's New boundary. The executable, manifest and curated notes live in the same
3
+ // immutable plugin payload, so a Stable Spine generation can never report another version's notes.
4
+
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const notesPath = path.join(pluginRoot, 'docs', 'RELEASE-NOTES-4.0.md');
11
+ const manifestCandidates = [
12
+ path.join(pluginRoot, '.codex-plugin', 'plugin.json'),
13
+ path.join(pluginRoot, '.claude-plugin', 'plugin.json'),
14
+ ];
15
+
16
+ function fail(message) {
17
+ process.stderr.write(`RuvNet Brain What's New failed: ${message}\n`);
18
+ process.exitCode = 1;
19
+ }
20
+
21
+ let manifest = null;
22
+ for (const candidate of manifestCandidates) {
23
+ try {
24
+ manifest = JSON.parse(fs.readFileSync(candidate, 'utf8'));
25
+ if (manifest?.version) break;
26
+ } catch { /* try the other installed host manifest */ }
27
+ }
28
+
29
+ if (!manifest?.version) {
30
+ fail(`installed version metadata is missing from ${pluginRoot}`);
31
+ } else if (!fs.existsSync(notesPath)) {
32
+ fail(`installed release notes are missing at ${notesPath}`);
33
+ } else {
34
+ let notes = '';
35
+ try { notes = fs.readFileSync(notesPath, 'utf8'); }
36
+ catch (error) { fail(`installed release notes could not be read at ${notesPath}: ${error.message}`); }
37
+ if (notes) {
38
+ process.stdout.write(`RuvNet Brain ${manifest.version}\n\n`);
39
+ process.stdout.write(notes);
40
+ if (!notes.endsWith('\n')) process.stdout.write('\n');
41
+ }
42
+ }
@@ -17,17 +17,24 @@ green.
17
17
  4. Reject any test/QE result with zero tests, skips, todos, unknowns, pending jobs, or failures.
18
18
  5. Require two distinct independent graders scoring at least 95, each bound to the SHA and digest.
19
19
  6. Install the sealed artifact into virgin Claude Code and Codex homes; test their real entrypoints.
20
- 7. Require the active Brain registry to contain the `ruvnet-brain` RVF store and require narrow,
20
+ 7. Require the source package, Claude manifest, Codex manifest, packed npm version, bundle
21
+ `brainVersion`/`releaseTag`, and both installed host versions to identify one exact generation.
22
+ 8. Require the active Brain registry to contain the `ruvnet-brain` RVF store and require narrow,
21
23
  broad, and concurrent cited searches to complete within 80% of their deadline.
22
- 8. Publish only through the protected release workflow. Never run `npm publish` or `gh release
24
+ 9. Publish only through the protected release workflow. Never run `npm publish` or `gh release
23
25
  create` locally.
24
- 9. After publication, download npm and GitHub artifacts, compare their bytes with the seal, install
26
+ 10. After publication, download npm and GitHub artifacts, compare their bytes with the seal, install
25
27
  both hosts again, query the active MCP again, and require `published-surface-probe` green.
26
- 10. Close an issue only after posting its acceptance evidence. Never close from source inspection.
28
+ 11. Close an issue only after posting its acceptance evidence. Never close from source inspection.
27
29
 
28
30
  ## Candidate seal
29
31
 
30
32
  Generate the receipt from commands in the protected candidate workflow. Do not hand-author it.
33
+ For generation 4.0.4, dispatch `.github/workflows/protected-release.yml` only with the full candidate
34
+ SHA, sealed artifact SHA-256, exact version `4.0.4`, and the successful exact-SHA CI run ID whose
35
+ named `release-qe` job produced `release-evidence-<sha>`. The workflow checks every binding before
36
+ creating its sealed handoff and again after the production reviewer approves. Missing artifacts,
37
+ pending/red jobs, malformed inputs, version splits, and byte mismatches stop before the publisher.
31
38
  Validate it from the repository with:
32
39
 
33
40
  ```bash
@@ -58,6 +65,16 @@ Only exit 0 permits “shipped,” “deployed,” “green,” or “ready.”
58
65
  seal fails, say `PUBLICATION DEGRADED`, preserve the previous known-good release, and repair or
59
66
  roll back through the release workflow.
60
67
 
68
+ `scripts/release.mjs --publish` is intentionally unusable from a local shell or another workflow.
69
+ Its invocation guard requires GitHub Actions workflow `protected-release`, the candidate receipt,
70
+ and matching SHA/digest/version bindings before any push, tag, release, or npm action. The workflow
71
+ then runs `scripts/publication-receipt.mjs` after channel verification. That producer independently
72
+ downloads the sealed package from npm and the GitHub Release, requires both copies to match the
73
+ candidate bytes and identity, installs the npm copy into virgin Claude and Codex homes, proves the
74
+ installed self-RVF/readiness/search deadline, and runs `published-surface-probe` on the candidate
75
+ SHA. It refuses to overwrite an existing receipt. The workflow uploads both append-only receipts
76
+ only after the two-receipt validator exits 0; missing evidence is red, never inferred.
77
+
61
78
  ## Evidence and issue handling
62
79
 
63
80
  For each issue:
@@ -8,13 +8,17 @@ by protected workflows, never editable status documents.
8
8
  Required bindings:
9
9
 
10
10
  - `sha`, `tree`, `dirty:false`
11
+ - `version`, `tag`, and exact-equal `sourceVersions.package`, `sourceVersions.claudePlugin`, and
12
+ `sourceVersions.codexPlugin`
11
13
  - `artifact.path`, `artifact.sha256`, `artifact.sourceSha`
14
+ - exact-equal `artifact.version`, `artifact.bundle.brainVersion`, and
15
+ `artifact.bundle.releaseTag`
12
16
  - exact-SHA release-vector verdict with zero unknown/skipped
13
17
  - aggregate tests with nonzero total, all passed, zero failed/skipped/todo
14
18
  - fresh coverage floor and zero critical/high security findings
15
19
  - zero open GitHub issues
16
20
  - required GitHub workflow results on the same SHA
17
- - virgin-home Claude and Codex results on the same artifact digest
21
+ - virgin-home Claude and Codex results on the same artifact digest and exact candidate version
18
22
  - installed Brain self-RVF plus narrow, broad, and concurrent cited search timings
19
23
  - nonzero Agentic QE totals with zero failed/skipped
20
24
  - two distinct independent grader receipts at 95 or higher, bound to SHA and digest
@@ -24,6 +28,8 @@ Required bindings:
24
28
  Required bindings:
25
29
 
26
30
  - candidate SHA and artifact digest
31
+ - candidate `version` plus exact-equal npm version, GitHub tag, bundle `brainVersion`/`releaseTag`,
32
+ and installed Claude/Codex versions
27
33
  - npm and GitHub release bytes matching the candidate digest
28
34
  - clean installed Claude and Codex results from the public package
29
35
  - installed Brain self-RVF and broad search within 80 percent of deadline
@@ -31,7 +37,7 @@ Required bindings:
31
37
 
32
38
  ## Failure semantics
33
39
 
34
- Any missing field, malformed digest, mismatched SHA, dirty tree, open issue, absent/pending/red
40
+ Any missing field, split version identity, malformed digest, mismatched SHA, dirty tree, open issue, absent/pending/red
35
41
  workflow, skipped/todo/zero-test result, missing RVF store, uncited search, deadline-margin breach,
36
42
  low/missing grader, or public byte mismatch is `FAIL`. There is no warning state and no score
37
43
  average. The authority never publishes; publication belongs to the protected workflow after the
@@ -6,13 +6,59 @@ import path from 'node:path';
6
6
  import { fileURLToPath, pathToFileURL } from 'node:url';
7
7
  import { spawnSync } from 'node:child_process';
8
8
 
9
- export const REQUIRED_CHECKS = ['ci', 'integration-linux', 'stranger-matrix', 'ux-qe'];
9
+ export const REQUIRED_CHECKS = ['ci', 'integration-linux', 'stranger-matrix', 'ux-qe', 'release-qe'];
10
10
 
11
11
  const fail = (code, detail) => ({ code, detail });
12
12
  const cleanHex = (value, length) => new RegExp(`^[a-f0-9]{${length}}$`, 'i').test(String(value || ''));
13
13
  const digestOf = (receipt) => String(receipt?.artifact?.sha256 || '').replace(/^sha256:/, '');
14
+ const versionField = (value) => typeof value === 'string' && value.length > 0 ? value : null;
15
+
16
+ function versionIdentityFailures(expectedVersion, expectedTag, fields) {
17
+ const mismatches = [];
18
+ if (!versionField(expectedVersion)) mismatches.push('version is missing');
19
+ if (expectedTag !== `v${expectedVersion}`) mismatches.push(`tag=${expectedTag ?? 'missing'}`);
20
+ for (const [name, value, expected = expectedVersion] of fields) {
21
+ if (value !== expected) mismatches.push(`${name}=${value ?? 'missing'}`);
22
+ }
23
+ return mismatches;
24
+ }
25
+
26
+ export function evaluateStabilizationCandidateReceipt(receipt) {
27
+ const failures = [];
28
+ const sha = String(receipt?.sha || '');
29
+ const digest = digestOf(receipt);
30
+ if (receipt?.schemaVersion !== 1 || receipt?.phase !== 'stabilization-candidate' || receipt?.mode !== 'stabilization') {
31
+ failures.push(fail('INVALID_STABILIZATION_RECEIPT', 'schemaVersion=1, phase=stabilization-candidate, and mode=stabilization are required'));
32
+ }
33
+ if (receipt?.targetScore !== 95 || receipt?.scoreClaimed !== false) {
34
+ failures.push(fail('STABILIZATION_SCORE_MISREPRESENTED', 'stabilization must preserve the 95 target and explicitly make no 95 claim'));
35
+ }
36
+ if (!cleanHex(sha, 40) || !cleanHex(receipt?.tree, 40) || receipt?.dirty !== false) {
37
+ failures.push(fail('INVALID_LINEAGE', 'clean candidate SHA and tree are required'));
38
+ }
39
+ if (!cleanHex(digest, 64) || receipt?.artifact?.sourceSha !== sha) {
40
+ failures.push(fail('INVALID_ARTIFACT_DIGEST', 'sealed package digest must be bound to the candidate SHA'));
41
+ }
42
+ const version = versionField(receipt?.version);
43
+ const identityMismatches = versionIdentityFailures(version, receipt?.tag, [
44
+ ['source package', receipt?.sourceVersions?.package],
45
+ ['source Claude plugin', receipt?.sourceVersions?.claudePlugin],
46
+ ['source Codex plugin', receipt?.sourceVersions?.codexPlugin],
47
+ ['packed npm artifact', receipt?.artifact?.version],
48
+ ]);
49
+ if (identityMismatches.length) failures.push(fail('VERSION_IDENTITY_MISMATCH', identityMismatches.join(', ')));
50
+ if (receipt?.security?.status !== 'PASS' || receipt?.security?.critical !== 0 || receipt?.security?.high !== 0) {
51
+ failures.push(fail('SECURITY_NOT_PASS', 'stabilization requires zero critical/high dependency findings'));
52
+ }
53
+ if (!(receipt?.qe?.total > 0) || receipt?.qe?.status !== 'PASS' || receipt?.qe?.failed !== 0
54
+ || receipt?.qe?.skipped !== 0 || receipt?.qe?.passed !== receipt?.qe?.total) {
55
+ failures.push(fail('QE_NOT_PASS', 'release QE must execute nonzero work with zero failed/skipped tests'));
56
+ }
57
+ return { verdict: failures.length === 0 ? 'PASS' : 'FAIL', sha, artifactSha256: digest, failures };
58
+ }
14
59
 
15
60
  export function evaluateCandidateReceipt(receipt) {
61
+ if (receipt?.phase === 'stabilization-candidate') return evaluateStabilizationCandidateReceipt(receipt);
16
62
  const failures = [];
17
63
  const sha = String(receipt?.sha || '');
18
64
  const digest = digestOf(receipt);
@@ -23,6 +69,22 @@ export function evaluateCandidateReceipt(receipt) {
23
69
  if (!cleanHex(digest, 64)) failures.push(fail('INVALID_ARTIFACT_DIGEST', 'artifact SHA-256 is missing or malformed'));
24
70
  if (receipt?.artifact?.sourceSha !== sha) failures.push(fail('ARTIFACT_SOURCE_MISMATCH', 'packed artifact is not bound to the candidate SHA'));
25
71
 
72
+ const version = versionField(receipt?.version);
73
+ const tag = version ? `v${version}` : null;
74
+ const identityMismatches = versionIdentityFailures(version, receipt?.tag, [
75
+ ['source package', receipt?.sourceVersions?.package],
76
+ ['source Claude plugin', receipt?.sourceVersions?.claudePlugin],
77
+ ['source Codex plugin', receipt?.sourceVersions?.codexPlugin],
78
+ ['packed npm artifact', receipt?.artifact?.version],
79
+ ['bundle brainVersion', receipt?.artifact?.bundle?.brainVersion],
80
+ ['bundle releaseTag', receipt?.artifact?.bundle?.releaseTag, tag],
81
+ ['installed Claude host', receipt?.hosts?.claude?.version],
82
+ ['installed Codex host', receipt?.hosts?.codex?.version],
83
+ ]);
84
+ if (identityMismatches.length > 0) {
85
+ failures.push(fail('VERSION_IDENTITY_MISMATCH', `candidate surfaces must identify one generation: ${identityMismatches.join(', ')}`));
86
+ }
87
+
26
88
  const vector = receipt?.releaseVector || {};
27
89
  if (vector.verdict !== 'PASS' || vector.sha !== sha || vector.unknown !== 0 || vector.skipped !== 0) {
28
90
  failures.push(fail('RELEASE_VECTOR_NOT_PASS', 'release vector must PASS on the exact SHA with zero UNKNOWN/SKIP'));
@@ -85,6 +147,20 @@ export function evaluatePublicationReceipt(candidate, publication) {
85
147
  const digest = candidateResult.artifactSha256;
86
148
  if (publication?.schemaVersion !== 1 || publication?.phase !== 'publication') failures.push(fail('INVALID_PUBLICATION_RECEIPT', 'schemaVersion=1 and phase=publication are required'));
87
149
  if (publication?.sha !== sha || publication?.artifactSha256 !== digest) failures.push(fail('PUBLICATION_SEAL_MISMATCH', 'publication does not reference candidate seal'));
150
+ const version = versionField(candidate?.version);
151
+ const tag = version ? `v${version}` : null;
152
+ const identityMismatches = versionIdentityFailures(version, candidate?.tag, [
153
+ ['publication version', publication?.version],
154
+ ['npm version', publication?.npm?.version],
155
+ ['GitHub release tag', publication?.githubRelease?.tag, tag],
156
+ ['bundle brainVersion', publication?.bundle?.brainVersion],
157
+ ['bundle releaseTag', publication?.bundle?.releaseTag, tag],
158
+ ['installed Claude host', publication?.installed?.claude?.version],
159
+ ['installed Codex host', publication?.installed?.codex?.version],
160
+ ]);
161
+ if (identityMismatches.length > 0) {
162
+ failures.push(fail('PUBLIC_VERSION_IDENTITY_MISMATCH', `public surfaces must identify candidate ${version ?? 'UNKNOWN'}: ${identityMismatches.join(', ')}`));
163
+ }
88
164
  for (const surface of ['npm', 'githubRelease']) {
89
165
  const item = publication?.[surface];
90
166
  if (item?.sha !== sha || item?.artifactSha256 !== digest) failures.push(fail('PUBLIC_ARTIFACT_MISMATCH', `${surface} differs from candidate seal`));