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.
- package/README.md +1 -1
- package/bin/install.mjs +215 -31
- package/docs/RELEASE-NOTES-4.0.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/whats-new.md +6 -6
- package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
- package/plugin/mcp/server.mjs +70 -33
- package/plugin/scripts/hook-shim.mjs +27 -22
- package/plugin/scripts/session-start-core.mjs +28 -2
- package/plugin/scripts/whats-new.mjs +42 -0
- package/plugin/skills/release-proof/SKILL.md +21 -4
- package/plugin/skills/release-proof/references/receipt-contract.md +8 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +77 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
- package/plugin/skills/whats-new/SKILL.md +4 -4
- package/scripts/build-bundle.mjs +34 -25
- package/scripts/fix-workstream.mjs +291 -0
- package/scripts/health-repair.mjs +14 -27
- package/scripts/issue-fix.mjs +135 -216
- package/scripts/memory-doctor.mjs +64 -9
- package/scripts/nightly-wrapper.sh +20 -28
- package/scripts/onboarding-console.mjs +226 -53
- package/scripts/protected-release-invocation.mjs +76 -0
- package/scripts/publication-receipt.mjs +307 -0
- package/scripts/release-authority.mjs +93 -0
- package/scripts/release.mjs +55 -11
- package/scripts/self-update.mjs +15 -227
- package/scripts/stabilization-receipt.mjs +108 -0
- package/scripts/wired-check.mjs +3 -0
package/plugin/mcp/server.mjs
CHANGED
|
@@ -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
|
-
//
|
|
48
|
-
//
|
|
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.
|
|
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
|
-
|
|
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); //
|
|
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
|
-
|
|
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
|
|
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
|
|
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 <
|
|
198
|
-
const kept = chunk.subarray(0,
|
|
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 =
|
|
258
|
-
? { stdio: ['pipe', 'inherit', 'inherit'], input:
|
|
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 (
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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(() =>
|
|
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
|
-
|
|
441
|
-
|
|
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
|
|
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
|
-
|
|
24
|
+
9. Publish only through the protected release workflow. Never run `npm publish` or `gh release
|
|
23
25
|
create` locally.
|
|
24
|
-
|
|
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
|
-
|
|
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`));
|