ruvnet-brain 4.0.2 → 4.0.5
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/lesson-command-scope.mjs +133 -0
- package/plugin/scripts/lesson-gate.mjs +401 -0
- package/plugin/scripts/lesson-presentation.mjs +99 -0
- package/plugin/scripts/lesson-store.mjs +452 -0
- package/plugin/scripts/route-dispatch.sh +1 -0
- package/plugin/scripts/session-start-core.mjs +28 -2
- package/plugin/scripts/verify-interface.sh +1 -0
- 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/learning-replay-cli.mjs +236 -0
- package/scripts/learning-replay-contract.mjs +255 -0
- package/scripts/learning-replay-execution.mjs +193 -0
- package/scripts/learning-replay-fixture.mjs +380 -0
- package/scripts/learning-replay-proof.mjs +459 -0
- package/scripts/learning-replay.mjs +10 -1565
- package/scripts/lesson-gate.mjs +3 -679
- package/scripts/lesson-store.mjs +4 -447
- package/scripts/memory-doctor.mjs +80 -9
- package/scripts/nightly-wrapper.sh +20 -28
- package/scripts/onboarding-console.mjs +302 -95
- 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-vector.mjs +49 -25
- 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
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// lesson-command-scope.mjs — pure advisory scope classification for lesson-gate.
|
|
2
|
+
//
|
|
3
|
+
// This is deliberately an allowlist of outside-repository mutations. Unknown commands stay quiet:
|
|
4
|
+
// over-firing an advisory trains users and agents to ignore it. Segments are classified only from
|
|
5
|
+
// executable position, so quoted examples and grep/echo text cannot manufacture a trigger.
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
function repositoryRoot(start = process.cwd()) {
|
|
10
|
+
let dir = start;
|
|
11
|
+
for (let i = 0; i < 12; i++) {
|
|
12
|
+
if (fs.existsSync(path.join(dir, '.git'))) return dir;
|
|
13
|
+
const parent = path.dirname(dir);
|
|
14
|
+
if (parent === dir) break;
|
|
15
|
+
dir = parent;
|
|
16
|
+
}
|
|
17
|
+
return start;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function splitTopLevelSegments(command) {
|
|
21
|
+
const segments = [];
|
|
22
|
+
let current = '';
|
|
23
|
+
let quote = null;
|
|
24
|
+
for (let i = 0; i < command.length; i++) {
|
|
25
|
+
const char = command[i];
|
|
26
|
+
if (quote) {
|
|
27
|
+
current += char;
|
|
28
|
+
if (char === quote) quote = null;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (char === '"' || char === "'") {
|
|
32
|
+
quote = char;
|
|
33
|
+
current += char;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
if ((char === '&' && command[i + 1] === '&') || (char === '|' && command[i + 1] === '|')) {
|
|
37
|
+
segments.push(current);
|
|
38
|
+
current = '';
|
|
39
|
+
i++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (char === ';' || char === '&' || char === '|' || char === '\n') {
|
|
43
|
+
segments.push(current);
|
|
44
|
+
current = '';
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
current += char;
|
|
48
|
+
}
|
|
49
|
+
if (current.trim()) segments.push(current);
|
|
50
|
+
return segments.map((segment) => segment.trim()).filter(Boolean);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function tokenize(segment) {
|
|
54
|
+
const tokens = [];
|
|
55
|
+
let current = '';
|
|
56
|
+
let quote = null;
|
|
57
|
+
for (const char of segment) {
|
|
58
|
+
if (quote) {
|
|
59
|
+
if (char === quote) quote = null;
|
|
60
|
+
else current += char;
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
if (char === '"' || char === "'") {
|
|
64
|
+
quote = char;
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
if (/\s/.test(char)) {
|
|
68
|
+
if (current) tokens.push(current);
|
|
69
|
+
current = '';
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
current += char;
|
|
73
|
+
}
|
|
74
|
+
if (current) tokens.push(current);
|
|
75
|
+
return tokens;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const SYSTEM_PATH_PREFIXES = ['/etc', '/usr', '/bin', '/sbin', '/System', '/Library', '/private', '/var', '/opt'];
|
|
79
|
+
const INSTALL_VERBS = new Set(['install', 'i', 'add', 'uninstall', 'remove', 'rm', 'un']);
|
|
80
|
+
const GLOBAL_FLAGS = new Set(['-g', '--global']);
|
|
81
|
+
const SECURITY_MUTATING = new Set([
|
|
82
|
+
'create-keychain', 'delete-keychain', 'set-keychain-password', 'set-keychain-settings',
|
|
83
|
+
'unlock-keychain', 'lock-keychain', 'import', 'add-generic-password', 'add-internet-password',
|
|
84
|
+
'delete-generic-password', 'delete-internet-password', 'default-keychain',
|
|
85
|
+
]);
|
|
86
|
+
const BREW_MUTATING = new Set(['install', 'uninstall', 'remove', 'rm', 'upgrade', 'reinstall', 'tap', 'untap', 'link', 'unlink', 'pin', 'unpin', 'services']);
|
|
87
|
+
const PKG_MUTATING = new Set(['install', 'remove', 'purge', 'upgrade']);
|
|
88
|
+
const FS_MUTATING_VERBS = new Set(['rm', 'mv', 'cp', 'ln', 'shred', 'truncate', 'unlink']);
|
|
89
|
+
|
|
90
|
+
function curlMutates(tokens) {
|
|
91
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
92
|
+
const token = tokens[i];
|
|
93
|
+
if ((token === '-X' || token === '--request') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes((tokens[i + 1] || '').toUpperCase())) return true;
|
|
94
|
+
if (/^--request=/.test(token) && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(token.split('=')[1].toUpperCase())) return true;
|
|
95
|
+
if (token === '-d' || token === '--data' || /^--data(-raw|-binary|-urlencode)?$/.test(token) || token === '--upload-file' || token === '-T') return true;
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function classifySegment(segment, repoRoot) {
|
|
101
|
+
let tokens = tokenize(segment);
|
|
102
|
+
while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens = tokens.slice(1);
|
|
103
|
+
if (!tokens.length) return false;
|
|
104
|
+
const lead = tokens[0];
|
|
105
|
+
const outsideRepo = (token) => token && !token.startsWith('-') && (
|
|
106
|
+
token.startsWith('~') || (token.startsWith('/') && token !== repoRoot && !token.startsWith(repoRoot + path.sep))
|
|
107
|
+
);
|
|
108
|
+
const systemPath = (token) => token && !token.startsWith('-')
|
|
109
|
+
&& SYSTEM_PATH_PREFIXES.some((prefix) => token === prefix || token.startsWith(prefix + '/'));
|
|
110
|
+
if (lead === 'sudo') return true;
|
|
111
|
+
switch (lead) {
|
|
112
|
+
case 'launchctl': return true;
|
|
113
|
+
case 'security': return SECURITY_MUTATING.has(tokens[1]);
|
|
114
|
+
case 'defaults': return tokens[1] === 'write' || tokens[1] === 'delete';
|
|
115
|
+
case 'npm': case 'pnpm': case 'yarn': return INSTALL_VERBS.has(tokens[1]) && tokens.some((token) => GLOBAL_FLAGS.has(token));
|
|
116
|
+
case 'pip': case 'pip3': case 'pipx': return tokens[1] === 'install' || tokens[1] === 'uninstall';
|
|
117
|
+
case 'brew': return BREW_MUTATING.has(tokens[1]);
|
|
118
|
+
case 'gem': return tokens[1] === 'install' || tokens[1] === 'uninstall';
|
|
119
|
+
case 'apt': case 'apt-get': case 'yum': case 'dnf': case 'pacman': case 'port': return PKG_MUTATING.has(tokens[1]);
|
|
120
|
+
case 'git': return tokens[1] === 'push';
|
|
121
|
+
case 'curl': return curlMutates(tokens);
|
|
122
|
+
case 'wget': return tokens.some((token) => token.startsWith('--post-data') || token.startsWith('--post-file'));
|
|
123
|
+
case 'chmod': case 'chown': case 'chgrp': return tokens.slice(1).some(systemPath);
|
|
124
|
+
case 'dd': case 'mkfs': case 'diskutil': return true;
|
|
125
|
+
case 'crontab': return tokens[1] === '-e' || tokens[1] === '-r';
|
|
126
|
+
default: return FS_MUTATING_VERBS.has(lead) && tokens.slice(1).some(outsideRepo);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function looksLikeOutsideRepoMutation(command, repoRoot = repositoryRoot()) {
|
|
131
|
+
if (!command || typeof command !== 'string') return false;
|
|
132
|
+
return splitTopLevelSegments(command).some((segment) => classifySegment(segment, repoRoot));
|
|
133
|
+
}
|