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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +215 -31
  3. package/docs/RELEASE-NOTES-4.0.md +1 -1
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/whats-new.md +6 -6
  8. package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
  9. package/plugin/mcp/server.mjs +70 -33
  10. package/plugin/scripts/hook-shim.mjs +27 -22
  11. package/plugin/scripts/lesson-command-scope.mjs +133 -0
  12. package/plugin/scripts/lesson-gate.mjs +401 -0
  13. package/plugin/scripts/lesson-presentation.mjs +99 -0
  14. package/plugin/scripts/lesson-store.mjs +452 -0
  15. package/plugin/scripts/route-dispatch.sh +1 -0
  16. package/plugin/scripts/session-start-core.mjs +28 -2
  17. package/plugin/scripts/verify-interface.sh +1 -0
  18. package/plugin/scripts/whats-new.mjs +42 -0
  19. package/plugin/skills/release-proof/SKILL.md +21 -4
  20. package/plugin/skills/release-proof/references/receipt-contract.md +8 -2
  21. package/plugin/skills/release-proof/scripts/release-proof.mjs +77 -1
  22. package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
  23. package/plugin/skills/whats-new/SKILL.md +4 -4
  24. package/scripts/build-bundle.mjs +34 -25
  25. package/scripts/fix-workstream.mjs +291 -0
  26. package/scripts/health-repair.mjs +14 -27
  27. package/scripts/issue-fix.mjs +135 -216
  28. package/scripts/learning-replay-cli.mjs +236 -0
  29. package/scripts/learning-replay-contract.mjs +255 -0
  30. package/scripts/learning-replay-execution.mjs +193 -0
  31. package/scripts/learning-replay-fixture.mjs +380 -0
  32. package/scripts/learning-replay-proof.mjs +459 -0
  33. package/scripts/learning-replay.mjs +10 -1565
  34. package/scripts/lesson-gate.mjs +3 -679
  35. package/scripts/lesson-store.mjs +4 -447
  36. package/scripts/memory-doctor.mjs +80 -9
  37. package/scripts/nightly-wrapper.sh +20 -28
  38. package/scripts/onboarding-console.mjs +302 -95
  39. package/scripts/protected-release-invocation.mjs +76 -0
  40. package/scripts/publication-receipt.mjs +307 -0
  41. package/scripts/release-authority.mjs +93 -0
  42. package/scripts/release-vector.mjs +49 -25
  43. package/scripts/release.mjs +55 -11
  44. package/scripts/self-update.mjs +15 -227
  45. package/scripts/stabilization-receipt.mjs +108 -0
  46. package/scripts/wired-check.mjs +3 -0
@@ -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
  }
@@ -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
+ }