ruvnet-brain 4.3.21 → 4.3.26

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 (143) hide show
  1. package/README.md +5 -5
  2. package/bin/install.mjs +275 -60
  3. package/console/app.js +189 -9
  4. package/console/index.html +70 -24
  5. package/console/scope.css +137 -0
  6. package/console/scope.html +144 -0
  7. package/console/scope.js +209 -0
  8. package/console/style.css +26 -0
  9. package/console/tips.html +1 -0
  10. package/kb/corpus-release-identity.mjs +239 -0
  11. package/kb/update-storage-transaction.mjs +20 -3
  12. package/package.json +9 -2
  13. package/plugin/.claude-plugin/plugin.json +2 -2
  14. package/plugin/.codex-plugin/plugin.json +1 -1
  15. package/plugin/commands/checkpoint.md +61 -0
  16. package/plugin/hooks/codex-hooks.json +64 -1
  17. package/plugin/hooks/hook-contracts.json +299 -6
  18. package/plugin/hooks/hooks.json +81 -1
  19. package/plugin/mcp/server.mjs +23 -0
  20. package/plugin/scripts/advocacy-catalog.mjs +245 -0
  21. package/plugin/scripts/advocacy-route.mjs +460 -0
  22. package/plugin/scripts/continuation-gate.mjs +25 -2
  23. package/plugin/scripts/continuation-objective.mjs +7 -1
  24. package/plugin/scripts/continuity-hook-policy.mjs +190 -15
  25. package/plugin/scripts/coverage-integrity.mjs +7 -0
  26. package/plugin/scripts/gates.mjs +113 -10
  27. package/plugin/scripts/grounding-turn-gate.mjs +167 -0
  28. package/plugin/scripts/grounding-turn-mark.mjs +91 -0
  29. package/plugin/scripts/hook-shim.mjs +14 -0
  30. package/plugin/scripts/nightly-scheduler.mjs +37 -4
  31. package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
  32. package/plugin/scripts/project-progression-contract.mjs +16 -0
  33. package/plugin/scripts/project-progression-hook.mjs +3 -0
  34. package/plugin/scripts/project-progression-producer.mjs +252 -0
  35. package/plugin/scripts/project-progression-reader.mjs +271 -0
  36. package/plugin/scripts/project-progression-session-start.mjs +93 -16
  37. package/plugin/scripts/project-progression-sources.mjs +220 -0
  38. package/plugin/scripts/project-progression-store.mjs +106 -13
  39. package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
  40. package/plugin/scripts/session-snapshot-hook.mjs +115 -7
  41. package/plugin/scripts/session-start-budget.mjs +59 -0
  42. package/plugin/scripts/session-start-core.mjs +234 -457
  43. package/plugin/scripts/session-start-fsutil.mjs +61 -0
  44. package/plugin/scripts/session-start-health.mjs +64 -0
  45. package/plugin/scripts/session-start-hook-description.mjs +45 -0
  46. package/plugin/scripts/session-start-issue-alert.mjs +77 -0
  47. package/plugin/scripts/session-start-repo-identity.mjs +54 -0
  48. package/plugin/scripts/session-start-signals.mjs +73 -0
  49. package/plugin/scripts/session-start-trace.mjs +86 -0
  50. package/plugin/scripts/session-start-update-plane.mjs +104 -0
  51. package/plugin/scripts/unprompted-runtime.mjs +32 -2
  52. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
  53. package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
  54. package/scripts/adr-072-completion.mjs +1 -1
  55. package/scripts/agentdb-fleet-doctor.mjs +5 -1
  56. package/scripts/approved-runtime.mjs +197 -0
  57. package/scripts/brain-novice-50.mjs +16 -1
  58. package/scripts/brain-score.mjs +23 -5
  59. package/scripts/build-bundle.mjs +971 -530
  60. package/scripts/build-concepts.mjs +36 -116
  61. package/scripts/console-engine.test.mjs +8 -7
  62. package/scripts/console-runtime-identity.mjs +4 -0
  63. package/scripts/corpus-aggregates.mjs +94 -77
  64. package/scripts/corpus-candidate.mjs +475 -222
  65. package/scripts/corpus-next-seed.mjs +225 -0
  66. package/scripts/corpus-promotion.mjs +58 -0
  67. package/scripts/corpus-reconcile.mjs +411 -105
  68. package/scripts/doc-currency.mjs +16 -1
  69. package/scripts/dual-host-deliberation.mjs +25 -2
  70. package/scripts/dual-host-suggest.mjs +17 -1
  71. package/scripts/falsify.mjs +13 -3
  72. package/scripts/gist-receipts.mjs +482 -87
  73. package/scripts/github-health-watch.mjs +12 -2
  74. package/scripts/handoff-asset.mjs +34 -0
  75. package/scripts/hook-retirement-check.mjs +8 -1
  76. package/scripts/host-registry.mjs +1 -1
  77. package/scripts/ingest-gists.mjs +74 -101
  78. package/scripts/job-heartbeat.sh +77 -14
  79. package/scripts/learning-replay-execution.mjs +10 -4
  80. package/scripts/nightly-gists.sh +27 -13
  81. package/scripts/nightly-two-run-proof.mjs +1 -1
  82. package/scripts/nightly-watchdog.mjs +61 -4
  83. package/scripts/onboarding-console.mjs +364 -28
  84. package/scripts/oracle/produce-questions.mjs +293 -0
  85. package/scripts/oracle/producer-hosts.mjs +235 -0
  86. package/scripts/oracle/repo-recall.mjs +448 -0
  87. package/scripts/oracle/retrieval-accuracy.mjs +818 -0
  88. package/scripts/oracle/source-tree.mjs +165 -0
  89. package/scripts/oracle/source-units.mjs +391 -0
  90. package/scripts/oracle/spike-run.mjs +98 -0
  91. package/scripts/oracle/unit-inventory.mjs +141 -0
  92. package/scripts/oracle/unit-sampling.mjs +128 -0
  93. package/scripts/oracle/validate-labels.mjs +250 -0
  94. package/scripts/private-overlay.mjs +248 -0
  95. package/scripts/product-integrity-contract.mjs +1 -1
  96. package/scripts/proxy/claude-proxied.sh +6 -0
  97. package/scripts/proxy/proxy-revert.sh +5 -0
  98. package/scripts/proxy/proxy-up.sh +6 -0
  99. package/scripts/proxy/proxy-verify.mjs +4 -0
  100. package/scripts/public-inputs.mjs +409 -0
  101. package/scripts/public-verification-inputs.mjs +112 -26
  102. package/scripts/public-verification-lane.mjs +1 -1
  103. package/scripts/published-surface-probe.mjs +34 -4
  104. package/scripts/qe/card-lane-gate.mjs +16 -1
  105. package/scripts/qe/session-start-gate.mjs +16 -1
  106. package/scripts/rebuild-gists-from-receipts.mjs +58 -78
  107. package/scripts/record-lesson.mjs +4 -1
  108. package/scripts/rehearse-corpus-pipeline.mjs +994 -0
  109. package/scripts/release-abort-stale.mjs +5 -1
  110. package/scripts/release-authority.mjs +104 -12
  111. package/scripts/release-channel-kind.mjs +86 -0
  112. package/scripts/release-convergence-watchdog.mjs +7 -2
  113. package/scripts/release-projection.mjs +177 -72
  114. package/scripts/release-transaction-provider.mjs +47 -10
  115. package/scripts/release-transaction.mjs +40 -11
  116. package/scripts/release.mjs +252 -17
  117. package/scripts/retrieval-canary.mjs +87 -0
  118. package/scripts/rvf-index-audit.mjs +573 -13
  119. package/scripts/rvf-wire.mjs +269 -0
  120. package/scripts/seal-gist-receipt.mjs +65 -0
  121. package/scripts/selfcheck.mjs +42 -21
  122. package/scripts/source-coverage.mjs +253 -24
  123. package/scripts/status-honesty.mjs +25 -0
  124. package/scripts/sync-census.mjs +0 -0
  125. package/scripts/sync-version.mjs +2 -0
  126. package/scripts/trismart.mjs +42 -0
  127. package/scripts/updater-manifest.mjs +162 -0
  128. package/scripts/verify-channels.mjs +17 -5
  129. package/scripts/wired-check.mjs +48 -10
  130. package/tri-smart-skill/QUICKSTART.md +37 -0
  131. package/tri-smart-skill/README.md +92 -0
  132. package/tri-smart-skill/install.cmd +14 -0
  133. package/tri-smart-skill/install.command +13 -0
  134. package/tri-smart-skill/install.mjs +51 -0
  135. package/tri-smart-skill/install.sh +9 -0
  136. package/tri-smart-skill/tri-smart/SKILL.md +90 -0
  137. package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
  138. package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
  139. package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
  140. package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
  141. package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
  142. package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
  143. package/scripts/corpus-seed-publish.mjs +0 -110
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env node
2
+ // session-start-fsutil.mjs — the small set of fail-silent fs/process helpers every SessionStart
3
+ // stage module needs. Extracted 2026-09-11 so session-start-core.mjs's own file could shrink below
4
+ // 500 lines by moving stage bodies into their own modules without each one re-implementing the same
5
+ // six helpers. Every function here is deliberately fail-open: SessionStart is advisory and must
6
+ // never throw a session over a missing file or a slow subprocess.
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+
11
+ export const read = (file, fallback = '') => {
12
+ try { return fs.readFileSync(file, 'utf8'); } catch { return fallback; }
13
+ };
14
+
15
+ export const json = (file, fallback = null) => {
16
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
17
+ };
18
+
19
+ export const exists = (file) => {
20
+ try { return fs.existsSync(file); } catch { return false; }
21
+ };
22
+
23
+ export const mkdir = (dir) => {
24
+ try { fs.mkdirSync(dir, { recursive: true }); return true; } catch { return false; }
25
+ };
26
+
27
+ export const write = (file, value) => {
28
+ try { mkdir(path.dirname(file)); fs.writeFileSync(file, String(value)); return true; } catch { return false; }
29
+ };
30
+
31
+ export const mtimeMs = (file) => {
32
+ try { return fs.statSync(file).mtimeMs; } catch { return 0; }
33
+ };
34
+
35
+ export const firstVersion = (text) => String(text).split(/\r?\n/).find((line) =>
36
+ /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(line.trim()))?.trim() || '';
37
+
38
+ /** spawnSync a node script, fail-silent: a missing file, a spawn error, or a timeout all resolve to
39
+ * null — never thrown. SessionStart is advisory; no stage may fail a session over a subprocess. */
40
+ export const runNode = (file, args = [], options = {}) => {
41
+ if (!exists(file)) return null;
42
+ try {
43
+ return spawnSync(process.execPath, [file, ...args], {
44
+ cwd: options.cwd,
45
+ env: options.env,
46
+ encoding: 'utf8',
47
+ stdio: options.stdio || 'ignore',
48
+ timeout: options.timeout,
49
+ windowsHide: true,
50
+ });
51
+ } catch { return null; }
52
+ };
53
+
54
+ /** Launch a maintenance job OUT of this process's own wait — via the shared detach.mjs launcher,
55
+ * which forks a truly detached child and returns in ~40ms (see plugin/scripts/detach.mjs's own
56
+ * header). `timeout: 2000` bounds ONLY the launch step (spawning the detached grandchild), never the
57
+ * job itself, whose lifetime is `ttl` seconds inside detach.mjs's own supervisor. */
58
+ export const dispatchDetached = (hookDir, ttl, log, command, args = [], env = process.env) =>
59
+ runNode(path.join(hookDir, 'detach.mjs'), [
60
+ String(ttl), log, command, ...args,
61
+ ], { env, stdio: 'ignore', timeout: 2000 })?.status === 0;
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // session-start-health.mjs — the three LOCAL installation-health reads SessionStart's banner depends
3
+ // on: is the brain turned off, is the knowledge cache actually usable, and is the MCP worker alive.
4
+ // Extracted 2026-09-11 out of session-start-core.mjs to keep that file under 500 lines; behavior is
5
+ // unchanged from the original inline functions.
6
+ //
7
+ // These are the checks behind the 🚨 HEALTH ALARM and the RETRIEVAL-DOWN banner — LOCAL installation
8
+ // integrity, not maintainer-only content. Per the 2026-09-11 reviewer correction, everything read
9
+ // here is delivered to every user via SessionStart's existing always-shown alarm/banner lines,
10
+ // worded for the user — never gated behind the maintainer entitlement file.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { json, exists, mtimeMs } from './session-start-fsutil.mjs';
14
+
15
+ export const brainState = (env, home) => {
16
+ const stateDir = env.RUVNET_BRAIN_STATE_DIR || path.join(home, '.config', 'ruvnet-brain');
17
+ const file = path.join(stateDir, 'brain-off');
18
+ let off = env.RUVNET_BRAIN_OFF === '1';
19
+ if (!off) {
20
+ try { fs.statSync(file); off = true; }
21
+ catch (error) { off = !(error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')); }
22
+ }
23
+ let since = '';
24
+ if (off) {
25
+ const record = json(file);
26
+ if (typeof record?.since === 'string') since = record.since.slice(0, 10);
27
+ if (!since && mtimeMs(file)) since = new Date(mtimeMs(file)).toISOString().slice(0, 10);
28
+ }
29
+ return { off, since, stateDir, file };
30
+ };
31
+
32
+ export const health = (home, off) => {
33
+ const kb = path.join(home, '.cache', 'ruvnet-brain', 'kb');
34
+ let rvf = false;
35
+ try { rvf = fs.readdirSync(kb).some((name) => name.endsWith('.rvf') && exists(path.join(kb, name))); }
36
+ catch { /* absent */ }
37
+ const absentByChoice = off && (!exists(kb) || !rvf);
38
+ if (absentByChoice) return { problem: '', absentByChoice };
39
+ if (!exists(kb)) return { problem: `the brain cache directory is MISSING (${kb}) — reinstall: npx github:stuinfla/ruvnet-brain`, absentByChoice };
40
+ if (!rvf) return { problem: `NO vector stores (.rvf) found in ${kb} — the brain is empty; reinstall: npx github:stuinfla/ruvnet-brain --force`, absentByChoice };
41
+ if (!exists(path.join(kb, 'node_modules', '@xenova', 'transformers', 'package.json'))) {
42
+ return { problem: `reader dependencies are MISSING (node_modules gone) — every search WILL fail. Fix: cd ${kb} && npm i`, absentByChoice };
43
+ }
44
+ const last = json(path.join(home, '.cache', 'ruvnet-brain', 'health.json'));
45
+ if (last?.status === 'down') {
46
+ const detail = `"error": ${JSON.stringify(String(last.error || 'unknown error'))}`.slice(0, 180);
47
+ return { problem: `the last real search FAILED across all repos (${detail}). Fix: cd ${kb} && npm i, then run one search to clear the alarm`, absentByChoice };
48
+ }
49
+ return { problem: '', absentByChoice };
50
+ };
51
+
52
+ export const mcpReadiness = (env, home) => {
53
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
54
+ const receipt = json(path.join(brainHome, 'mcp-readiness.json'));
55
+ if (receipt?.state === 'ready' && Number.isInteger(receipt.pid) && Number.isInteger(receipt.workerPid)) {
56
+ try {
57
+ process.kill(receipt.pid, 0);
58
+ process.kill(receipt.workerPid, 0);
59
+ return { state: 'ready', receipt };
60
+ } catch { /* a stale receipt is registration evidence, not live evidence */ }
61
+ }
62
+ if (receipt?.state === 'degraded') return { state: 'degraded', receipt };
63
+ return { state: 'registered', receipt };
64
+ };
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // session-start-hook-description.mjs — BANNER TRUTH. The banner used to say "the grounding hooks
3
+ // are active" unconditionally, in every state, on every version — false since 4.3.17 retired the
4
+ // grounding plane (plugin/hooks/hook-contracts.json's own `_note`: "Exactly two continuity handlers
5
+ // are permitted"). This module reads THAT file at runtime and describes whatever is actually
6
+ // registered, so the sentence can never drift from the real contract again — including forward, when
7
+ // the continuity lane bumps hook-contracts.json to a new `_version` with a different event set
8
+ // (e.g. capture hooks at Stop/PreCompact/SessionEnd, an advocacy route on UserPromptSubmit): this
9
+ // file does not hardcode which ids or events exist, only how to render whatever it finds.
10
+ import fs from 'node:fs';
11
+
12
+ // Friendly labels for ids this file KNOWS about today. Anything else still renders correctly via the
13
+ // fallback below — this dictionary only makes today's two contracts read naturally; it is never the
14
+ // thing that decides what counts as "active".
15
+ const KNOWN_ACTIONS = {
16
+ 'session-start': 'restore',
17
+ 'continuation-gate': 'continuation',
18
+ };
19
+
20
+ function friendlyAction(contract) {
21
+ const known = KNOWN_ACTIONS[contract?.id];
22
+ if (known) return known;
23
+ const id = String(contract?.id || '').trim();
24
+ return id ? id.replace(/-/g, ' ') : 'hook';
25
+ }
26
+
27
+ /** One factual sentence naming every {event, action} pair hook-contracts.json currently declares,
28
+ * grouped by event. Never asserts a capability (like "grounding") the data does not name. */
29
+ export function describeLifecycleHooks(contractsDoc) {
30
+ const contracts = Array.isArray(contractsDoc?.contracts) ? contractsDoc.contracts : [];
31
+ if (!contracts.length) return 'No lifecycle hooks are currently registered.';
32
+ const byEvent = new Map();
33
+ for (const contract of contracts) {
34
+ if (!contract?.event) continue;
35
+ const list = byEvent.get(contract.event) || [];
36
+ list.push(friendlyAction(contract));
37
+ byEvent.set(contract.event, list);
38
+ }
39
+ const parts = [...byEvent.entries()].map(([event, actions]) => `${event} ${actions.join('/')}`);
40
+ return parts.length ? `Lifecycle hooks: ${parts.join(', ')}.` : 'No lifecycle hooks are currently registered.';
41
+ }
42
+
43
+ export function readHookContracts(hookContractsPath) {
44
+ try { return JSON.parse(fs.readFileSync(hookContractsPath, 'utf8')); } catch { return null; }
45
+ }
@@ -0,0 +1,77 @@
1
+ #!/usr/bin/env node
2
+ // session-start-issue-alert.mjs — the maintainer-only open-issue pointer.
3
+ //
4
+ // REWRITTEN 2026-09-11 after two findings:
5
+ // 1. SCOPING BUG: the per-user entitlement file was never checked against the CURRENT project's
6
+ // identity, so an entitled maintainer saw the alert in every unrelated project on the machine.
7
+ // Fixed by session-start-repo-identity.mjs's isMaintainerRepo() — BOTH the local entitlement
8
+ // file AND the current checkout's git remote must agree before anything is shown.
9
+ // 2. SCOPE CREEP: SessionStart printed a per-issue breakdown (numbers, titles, ages) sourced from a
10
+ // GitHub lookup. Per reviewer correction, that detail belongs to issue-watch.mjs's own output
11
+ // surface (its console report and open-issues.json), not to a hook that fires on every prompt
12
+ // boundary. SessionStart now prints AT MOST one line — a pointer, not a report — read from the
13
+ // cache within a 100ms budget and never touching the network itself.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import { isMaintainerRepo } from './session-start-repo-identity.mjs';
17
+
18
+ const json = (file, fallback = null) => {
19
+ try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
20
+ };
21
+
22
+ export const ISSUE_POINTER_BUDGET_MS = 100;
23
+ // A corrupt/huge open-issues.json is not evidence of anything actionable; refuse to even attempt
24
+ // parsing past this size rather than let a pathological file consume the stage's budget.
25
+ const MAX_STATUS_BYTES = 256 * 1024;
26
+
27
+ export const maintainerIssueEntitlement = (env, home, repo, platform = process.platform) => {
28
+ const file = env.RUVNET_BRAIN_MAINTAINER_ISSUES_FILE
29
+ || path.join(home, '.config', 'ruvnet-brain', 'maintainer-issues.json');
30
+ // Windows ACL ownership is not available through this dependency-free hot path. Fail closed
31
+ // instead of weakening an owner-only promise into "any local user who can write the file".
32
+ if (platform === 'win32') return false;
33
+ let stat;
34
+ try { stat = fs.lstatSync(file); } catch { return false; }
35
+ if (!stat.isFile() || stat.isSymbolicLink()) return false;
36
+ // This is maintainer-only operational data. Refuse group/world-readable opt-ins on POSIX so a
37
+ // shared machine cannot turn a private maintainer signal into a terminal banner for other users.
38
+ if ((stat.mode & 0o077) !== 0) return false;
39
+ if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return false;
40
+ const entitlement = json(file);
41
+ return entitlement?.enabled === true
42
+ && Array.isArray(entitlement.repos)
43
+ && entitlement.repos.includes(repo);
44
+ };
45
+
46
+ /**
47
+ * At most ONE line, naming a count and where to look — never a per-issue breakdown. Requires BOTH
48
+ * the local per-user entitlement file AND the current project's git remote to name the entitled
49
+ * repo; either alone is not enough (see module header).
50
+ */
51
+ export function surfaceIssuePointer({ stateDir, emit, now, env, home, platform, cwd, budgetMs = ISSUE_POINTER_BUDGET_MS }) {
52
+ const start = Date.now();
53
+ try {
54
+ const statusPath = path.join(stateDir, 'open-issues.json');
55
+ let size = 0;
56
+ try { size = fs.statSync(statusPath).size; } catch { return; }
57
+ if (size > MAX_STATUS_BYTES) return;
58
+
59
+ const status = json(statusPath);
60
+ const observedAt = Date.parse(status?.at || '');
61
+ if (!Number.isFinite(observedAt) || observedAt > now + 5 * 60_000 || now - observedAt > 6 * 3600_000) return;
62
+ // The 100ms budget covers exactly what correction #2 named — "a one-line pointer read from the
63
+ // cache" — checked HERE, before the (separately, generously bounded — see
64
+ // session-start-repo-identity.mjs's own spawnSync timeout) git-based repo-scoping check below.
65
+ // A local JSON read is always microseconds; a real overrun here means something pathological.
66
+ if (Date.now() - start > budgetMs) return;
67
+ if (!maintainerIssueEntitlement(env, home, status.repo, platform)) return;
68
+ if (!isMaintainerRepo(cwd)) return; // the CURRENT project must itself be the entitled repo
69
+
70
+ const open = Array.isArray(status.issues) ? status.issues : [];
71
+ if (!open.length) return;
72
+ const breaches = open.filter((issue) => issue.breach).length;
73
+ const ageHours = (now - observedAt) / 3_600_000;
74
+ const breachNote = breaches ? `, ${breaches} past SLA` : '';
75
+ emit(`[RuvNet Brain — OPEN ISSUES: ${open.length} on ${status.repo}${breachNote}, checked ${ageHours.toFixed(1)}h ago — full detail: node scripts/issue-watch.mjs]`);
76
+ } catch { /* advisory only — a broken cache read must never affect the session */ }
77
+ }
@@ -0,0 +1,54 @@
1
+ #!/usr/bin/env node
2
+ // session-start-repo-identity.mjs — resolves whether the CURRENT project's git remote IS the one
3
+ // maintainer-scoped repo, read-only and bounded. See session-start-issue-alert.mjs for the bug this
4
+ // closes.
5
+ //
6
+ // THE BUG (found 2026-09-11): the maintainer entitlement file
7
+ // (~/.config/ruvnet-brain/maintainer-issues.json) is PER-USER, not per-project. A maintainer entitled
8
+ // for stuinfla/ruvnet-brain therefore saw the SAME "16 issues past SLA" banner in every UNRELATED
9
+ // project on the machine — Codex repeated it in 4 of 8 answers on sandbox repos with zero relation to
10
+ // ruvnet-brain. The entitlement check never looked at what project the session was actually IN. This
11
+ // module is that missing half.
12
+ //
13
+ // FIXED CONSTANT, ON PURPOSE (reviewer correction 2026-09-11): entitlement must be an exact
14
+ // remote-URL match against ONE fixed repo, never a value read back out of a cache file or the
15
+ // entitlement file's own `repos` array — so a corrupted or malicious open-issues.json / entitlement
16
+ // file can widen who is ASKED, but never which repo is ELIGIBLE to be asked about.
17
+ import { spawnSync } from 'node:child_process';
18
+
19
+ export const MAINTAINER_REPO_SLUG = 'stuinfla/ruvnet-brain';
20
+
21
+ // Matches both `git@github.com:owner/repo.git` (ssh) and `https://github.com/owner/repo(.git)`.
22
+ const SLUG_RE = /github\.com[:/]+([^/]+)\/([^/.]+?)(?:\.git)?$/;
23
+
24
+ /** "owner/repo" from a git remote URL in either form; null for anything else (including no URL). */
25
+ export function parseRepoSlugFromRemote(remoteUrl) {
26
+ const m = SLUG_RE.exec(String(remoteUrl || '').trim());
27
+ return m ? `${m[1]}/${m[2]}` : null;
28
+ }
29
+
30
+ /** Read-only, LOCAL-only (`git config --get`, never a network call), bounded so a hung or missing
31
+ * git binary can never eat the SessionStart budget. Returns null for: not a git checkout, no
32
+ * `origin` remote, a non-GitHub remote, or git missing/erroring.
33
+ *
34
+ * 1000ms, not a few hundred (2026-09-11, found live under real machine load with several parallel
35
+ * agents contending for CPU): `git config --get` is normally single-digit ms, but a tighter budget
36
+ * measurably flaked under contention — the git subprocess got killed before it could answer, which
37
+ * fails CLOSED (hides the pointer) rather than open, but is still avoidable jitter for a purely
38
+ * local read. Matches this codebase's own convention for "should be near-instant, allow headroom
39
+ * for a loaded runner" (ascii-drift.mjs's spawnSync timeout is the same order of magnitude). */
40
+ export function resolveProjectRepoSlug(cwd, { timeoutMs = 1000, gitBin = process.env.GIT_BIN || 'git' } = {}) {
41
+ try {
42
+ const r = spawnSync(gitBin, ['config', '--get', 'remote.origin.url'], {
43
+ cwd, encoding: 'utf8', timeout: timeoutMs,
44
+ });
45
+ if (r.status !== 0) return null;
46
+ return parseRepoSlugFromRemote(r.stdout);
47
+ } catch { return null; }
48
+ }
49
+
50
+ /** The ONE predicate maintainer-only content may gate on: is THIS project checkout the entitled
51
+ * repo, by exact remote-URL match against the fixed constant above. */
52
+ export function isMaintainerRepo(cwd, options) {
53
+ return resolveProjectRepoSlug(cwd, options) === MAINTAINER_REPO_SLUG;
54
+ }
@@ -0,0 +1,73 @@
1
+ #!/usr/bin/env node
2
+ // session-start-signals.mjs — surfaces external CI-signal transitions (red/green/unverifiable)
3
+ // recorded by scripts/signal-watch.mjs's polled half. Extracted 2026-09-11 out of
4
+ // session-start-core.mjs to keep that file under 500 lines, AND fixed at the same time:
5
+ //
6
+ // THE MEASURED SLOW PART (2026-09-11 latency profiling): this repo's own checkout has
7
+ // scripts/signal-watch.mjs (a dev-only tool — a downloader's machine has none), so
8
+ // `exists(poller)` was true and the OLD code ran it with a blocking `spawnSync` whenever
9
+ // pending.jsonl was non-empty and ci-status.json was stale — a real `gh run list` network call,
10
+ // internally bounded at 3000ms by that file's own GH_TIMEOUT_MS, dominating every cold/warm firing
11
+ // measured in this repo. That 3000ms budget was sized for the OLD "SessionStart owns the whole 5s
12
+ // hooks.json timeout" assumption; the derived-sum contract (session-start-budget.mjs) no longer
13
+ // allows any one stage that much room.
14
+ //
15
+ // THE FIX: dispatch the poll the same way session-start-core.mjs already dispatches every other
16
+ // maintenance job (stableSpine's seed, heartbeat's update-check) — DETACHED, via detach.mjs, which
17
+ // returns in ~40ms having handed the real work to a background process with its own TTL. The signal
18
+ // surfaced THIS session-start is therefore always the PREVIOUS poll's result (same "eventually
19
+ // consistent, never blocking" contract issue-watch.mjs's hourly cache already uses for open issues);
20
+ // a push's CI-red transition surfaces on the NEXT session-start rather than the current one, which is
21
+ // still well inside the "surface this soon, not never" intent and no longer risks a multi-second hang.
22
+ import path from 'node:path';
23
+ import { json, exists, mtimeMs, write, dispatchDetached } from './session-start-fsutil.mjs';
24
+
25
+ export function surfaceSignals({ env, cwd, stateDir, hookDir, emit, now, pollTtlSec = 30 }) {
26
+ const signalDir = env.RUVNET_SIGNAL_DIR || path.join(stateDir, 'external-signals');
27
+ const pending = path.join(signalDir, 'pending.jsonl');
28
+ const statusFile = path.join(signalDir, 'ci-status.json');
29
+ const surfacedFile = path.join(signalDir, 'surfaced.json');
30
+ if (!exists(pending)) return;
31
+
32
+ const poller = path.join(env.CLAUDE_PROJECT_DIR || cwd, 'scripts', 'signal-watch.mjs');
33
+ if (exists(poller) && now - mtimeMs(statusFile) > 600_000) {
34
+ dispatchDetached(hookDir, pollTtlSec, path.join(stateDir, '.last-signal-poll.log'), process.execPath, [poller], env);
35
+ }
36
+ const status = json(statusFile);
37
+ if (!status || typeof status !== 'object') return;
38
+ const onDisk = json(surfacedFile, {});
39
+ const surfaced = {
40
+ debts: onDisk?.debts && typeof onDisk.debts === 'object' ? onDisk.debts : {},
41
+ redRepo: onDisk?.redRepo && typeof onDisk.redRepo === 'object' ? onDisk.redRepo : {},
42
+ };
43
+ let changed = false;
44
+ const entries = Object.entries(status).sort((a, b) =>
45
+ new Date(a[1]?.checkedAt || 0) - new Date(b[1]?.checkedAt || 0));
46
+ for (const [key, debt] of entries) {
47
+ const shortSha = String(debt?.ref || '').slice(0, 7);
48
+ if (debt?.state === 'resolved' && debt.conclusion !== 'success') {
49
+ if (surfaced.debts[key] === 'red') continue;
50
+ emit(`[RuvNet Brain — EXTERNAL SIGNAL: CI is RED for ${debt.repo}@${shortSha} — surface this to the user now, near the top, with ZERO prompting]`);
51
+ emit(`Workflow ${debt.workflowName || 'ci'} concluded ${debt.conclusion} on ${debt.repo}@${shortSha}; inspect with gh run list --repo ${debt.repo} --commit ${debt.ref}.`);
52
+ surfaced.debts[key] = 'red';
53
+ surfaced.redRepo[debt.repo] = key;
54
+ changed = true;
55
+ continue;
56
+ }
57
+ if (debt?.state === 'resolved' && debt.conclusion === 'success') {
58
+ if (surfaced.redRepo[debt.repo]) {
59
+ emit(`[RuvNet Brain — external signal: CI is GREEN again for ${debt.repo}@${shortSha} — one line, then move on]`);
60
+ delete surfaced.redRepo[debt.repo];
61
+ changed = true;
62
+ }
63
+ if (surfaced.debts[key] !== 'green') { surfaced.debts[key] = 'green'; changed = true; }
64
+ continue;
65
+ }
66
+ if (debt?.state === 'unverifiable' && surfaced.debts[key] !== 'unverifiable') {
67
+ emit(`[RuvNet Brain — external signal: CI status could not be checked for ${debt.repo}@${shortSha}: ${debt.reason || 'unknown reason'}]`);
68
+ surfaced.debts[key] = 'unverifiable';
69
+ changed = true;
70
+ }
71
+ }
72
+ if (changed) write(surfacedFile, JSON.stringify(surfaced, null, 2));
73
+ }
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env node
2
+ // session-start-trace.mjs — runtime enforcement of the STAGE_BUDGETS_MS contract
3
+ // (session-start-budget.mjs), plus the fine-grained diagnostic trace requested after the 2026-09-11
4
+ // incident where one SessionStart firing was silently killed by its own watchdog with zero output:
5
+ // nothing recorded WHICH stage was still running when the 5s hooks.json timeout hit.
6
+ //
7
+ // TWO JOBS, one small module (ADR-055 F16 — a gate and its evidence must not be two code paths):
8
+ // 1. stage(name, fn) — always runs fn and always records its elapsed ms. When
9
+ // RUVNET_BRAIN_SESSION_START_TRACE=1 it also streams `SESSION_TRACE stage=<name>
10
+ // elapsed_ms=<n>` to stderr — the exact substring scripts/selfcheck.mjs's assertContract()
11
+ // already greps out of a slow hook's stderr (see its 'slow' violation), so a stage trace is
12
+ // useful diagnostic detail on a stranger's machine even without the env flag set, the moment a
13
+ // firing is already slow enough to be flagged.
14
+ // 2. A wall-clock DEADLINE derived from the very same STAGE_BUDGETS_MS: once the running total of
15
+ // "budget that should already have been spent" would exceed the deadline, a stage is SKIPPED
16
+ // with a one-line stderr note instead of being allowed to run past the hook's own timeout and
17
+ // get killed with no explanation. Skipping is always safe here — every stage this file wraps is
18
+ // advisory (SessionStart's whole contract is 'advisory'; nothing wrapped may block a session).
19
+ import { STAGE_BUDGETS_MS, sumBudgetsMs } from './session-start-budget.mjs';
20
+
21
+ /**
22
+ * @param {object} opts
23
+ * @param {boolean} [opts.enabled] - stream each stage's timing to stderr as it completes.
24
+ * @param {(chunk: string) => void} [opts.write] - stderr sink (defaults to a no-op).
25
+ * @param {Record<string, number>} [opts.budgets] - stage name -> budget ms (defaults to the shared
26
+ * contract in session-start-budget.mjs; a test seam only).
27
+ * @param {number} [opts.deadlineMs] - total wall-clock budget for every WRAPPED stage combined,
28
+ * excluding `restore` (which runs before this tracer starts, outside session-start-core.mjs's
29
+ * try block). Defaults to the contract sum minus `restore`'s own budget.
30
+ */
31
+ export function createStageTracer({
32
+ enabled = false,
33
+ write = () => {},
34
+ budgets = STAGE_BUDGETS_MS,
35
+ deadlineMs = sumBudgetsMs(budgets) - (budgets.restore || 0),
36
+ } = {}) {
37
+ const stages = [];
38
+ const bodyStart = Date.now();
39
+
40
+ const record = (name, ms, extra = '') => {
41
+ stages.push({ name, ms, skipped: Boolean(extra) });
42
+ // A skip is an anomaly, not a diagnostic opt-in: it is the direct fix for "the watchdog killed
43
+ // the hook with zero output", so it is written UNCONDITIONALLY, trace flag or not. Normal
44
+ // per-stage timings stay opt-in behind RUVNET_BRAIN_SESSION_START_TRACE so a healthy hook's
45
+ // stderr stays quiet by default.
46
+ if (extra) write(`SESSION_TRACE stage=${name} elapsed_ms=${ms}${extra}\n`);
47
+ else if (enabled) write(`SESSION_TRACE stage=${name} elapsed_ms=${ms}\n`);
48
+ };
49
+
50
+ /** Would running `name` now blow the shared wall-clock deadline? Checked against that stage's OWN
51
+ * declared budget (not the actual runtime, which is not known until after it runs) — the same
52
+ * "assume the worst case" posture the hooks.json timeout itself takes. */
53
+ const wouldExceedDeadline = (name) => {
54
+ const budget = budgets[name] ?? 0;
55
+ return (Date.now() - bodyStart) + budget > deadlineMs;
56
+ };
57
+
58
+ const stage = (name, fn) => {
59
+ if (wouldExceedDeadline(name)) {
60
+ record(name, 0, ' skipped=budget-exceeded');
61
+ return undefined;
62
+ }
63
+ const t0 = Date.now();
64
+ const result = fn();
65
+ record(name, Date.now() - t0);
66
+ return result;
67
+ };
68
+
69
+ const stageAsync = async (name, fn) => {
70
+ if (wouldExceedDeadline(name)) {
71
+ record(name, 0, ' skipped=budget-exceeded');
72
+ return undefined;
73
+ }
74
+ const t0 = Date.now();
75
+ const result = await fn();
76
+ record(name, Date.now() - t0);
77
+ return result;
78
+ };
79
+
80
+ return {
81
+ stage,
82
+ stageAsync,
83
+ table: () => stages.slice(),
84
+ totalMs: () => Date.now() - bodyStart,
85
+ };
86
+ }
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ // session-start-update-plane.mjs — the Stable Spine seed dispatch and the update-check heartbeat.
3
+ // Extracted 2026-09-11 out of session-start-core.mjs (unchanged behavior) to keep that file under
4
+ // 500 lines. `announceVersion` was intentionally NOT migrated here: it was dead code (defined, never
5
+ // called, never tested) and was deleted rather than relocated — moving unused code to a new module
6
+ // would still be scope creep, just in a different file.
7
+ import path from 'node:path';
8
+ import { read, write, exists, json, firstVersion, dispatchDetached } from './session-start-fsutil.mjs';
9
+
10
+ export const compareVersions = (a, b) => {
11
+ const left = String(a).split(/[.-]/).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
12
+ const right = String(b).split(/[.-]/).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
13
+ for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
14
+ const x = left[i] ?? 0;
15
+ const y = right[i] ?? 0;
16
+ if (x === y) continue;
17
+ if (typeof x === 'number' && typeof y === 'number') return x - y;
18
+ return String(x) < String(y) ? -1 : 1;
19
+ }
20
+ return 0;
21
+ };
22
+
23
+ export const stableSpine = ({ env, hookDir, stateDir, home, pluginVersion, emit, now }) => {
24
+ const activeFile = path.join(stateDir, 'active.json');
25
+ const stamp = path.join(stateDir, '.last-update-check');
26
+ let seedDispatched = false;
27
+ if (!exists(activeFile)) {
28
+ const seedStamp = path.join(stateDir, '.seed-attempted');
29
+ const last = Number(read(seedStamp).trim()) || 0;
30
+ const canSeed = exists(path.join(env.CLAUDE_PLUGIN_ROOT || '', 'scripts'))
31
+ || exists(path.join(home, '.claude', 'plugins', 'cache', 'ruvnet-brain'));
32
+ if (canSeed && now / 1000 - last > 300) {
33
+ write(seedStamp, `${Math.floor(now / 1000)}\n`);
34
+ seedDispatched = dispatchDetached(hookDir, 120, path.join(stateDir, '.seed.log'),
35
+ 'node', [
36
+ path.join(hookDir, 'first-session-worker.mjs'),
37
+ path.join(hookDir, 'update-apply.mjs'),
38
+ path.join(hookDir, 'host-update.mjs'),
39
+ path.join(stateDir, '.last-version-check.log'),
40
+ ], env);
41
+ if (seedDispatched) write(stamp, `${Math.floor(now / 1000)}\n`);
42
+ }
43
+ } else {
44
+ const active = json(activeFile);
45
+ const shellBoundary = active?.shellChangedAtVersion;
46
+ const shellChangedBeforeHost = shellBoundary && pluginVersion
47
+ ? compareVersions(pluginVersion, shellBoundary) < 0
48
+ : false;
49
+ if ((active?.shellChanged || shellChangedBeforeHost) && pluginVersion && active.version && active.version !== pluginVersion) {
50
+ emit(`[RuvNet Brain — v${active.version} changed boot-level declarations (the rare case); this session booted v${pluginVersion}'s]`);
51
+ const host = env.RUVNET_HOOK_HOST || 'claude';
52
+ const convergence = json(path.join(stateDir, 'host-convergence.json'));
53
+ const ready = convergence?.desiredVersion === active.version
54
+ && convergence?.hosts?.[host]?.state === 'ready'
55
+ && convergence.hosts[host].version === active.version;
56
+ if (!ready) {
57
+ emit(`Tell the user ONE line: "🧠 RuvNet Brain v${active.version} runtime is live, but this host's exact boot snapshot is not yet verified — do not restart for this update yet; automatic host repair will retry."`);
58
+ } else if (host === 'codex') {
59
+ emit(`Tell the user ONE line: "🧠 RuvNet Brain v${active.version} is already installed and verified for Codex; restart Codex to load its boot-level declarations, then run /hooks and trust only ruvnet-brain@ruvnet-brain if Codex shows the changed definitions as pending. Runtime behavior already updated live."`);
60
+ } else {
61
+ emit(`Tell the user ONE line: "🧠 RuvNet Brain v${active.version} is already installed and verified for Claude Code; one restart picks up its boot-level declarations (\`claude --continue\` keeps this conversation). Runtime behavior already updated live."`);
62
+ }
63
+ }
64
+ }
65
+ return { seedDispatched, stamp };
66
+ };
67
+
68
+ export const heartbeat = ({ env, hookDir, stateDir, home, running, seedDispatched, stamp, emit, now }) => {
69
+ const last = Number(read(stamp).trim()) || 0;
70
+ const epoch = Math.floor(now / 1000);
71
+ if (seedDispatched || epoch <= 0 || epoch - last <= 900) return;
72
+ write(stamp, `${epoch}\n`);
73
+ const pref = read(path.join(stateDir, '.auto-update-pref')).trim();
74
+ const kbDir = path.join(home, '.cache', 'ruvnet-brain', 'kb');
75
+ if (pref === 'yes' && exists(path.join(kbDir, 'forge-update.mjs'))) {
76
+ const kbLog = path.join(stateDir, '.last-kb-check.log');
77
+ if (/\bBEHIND\b/.test(read(kbLog))) {
78
+ emit('[RuvNet Brain — a newer knowledge bundle is available. It is signed (Ed25519) and the updater verifies that signature before extracting anything. We do NOT auto-apply it: applying replaces executable tool files, which is your call. To update: cd ~/.cache/ruvnet-brain/kb && node forge-update.mjs --apply]');
79
+ }
80
+ dispatchDetached(hookDir, 60, kbLog, process.execPath,
81
+ [path.join(kbDir, 'forge-update.mjs'), '--check'], env);
82
+ }
83
+ const versionLog = path.join(stateDir, '.last-version-check.log');
84
+ const remoteVersion = firstVersion(read(versionLog));
85
+ dispatchDetached(hookDir, 10, versionLog, process.execPath,
86
+ [path.join(hookDir, 'host-update.mjs'), '--check'], env);
87
+ if (!running || !remoteVersion || remoteVersion === running) return;
88
+ if (pref === 'yes' && exists(path.join(hookDir, 'host-update.mjs'))) {
89
+ dispatchDetached(hookDir, 600, path.join(stateDir, '.last-auto-update.log'),
90
+ process.execPath, [path.join(hookDir, 'host-update.mjs')], env);
91
+ emit(`[RuvNet Brain — v${remoteVersion} is downloading and will AUTO-APPLY via the Stable Spine (ADR-023); this session picks up the new behavior live]`);
92
+ emit('Tell the user ONE short line, near the top of your first response:');
93
+ emit(` "🧠 RuvNet Brain v${remoteVersion} is installing in the background for every detected host. Runtime behavior goes live automatically; if boot-level declarations changed, I'll ask for a restart only after that host's exact new snapshot is verified."`);
94
+ emit("Don't repeat this notice later in the same session.");
95
+ emit('');
96
+ return;
97
+ }
98
+ emit('[RuvNet Brain — update available, auto-update not enabled]');
99
+ emit('Tell the user this PLAINLY, near the top of your first response:');
100
+ emit(` "🧠 RuvNet Brain found v${remoteVersion} (you're on v${running}). Run: npx ruvnet-brain@latest --update"`);
101
+ emit(" (Or say the word and I'll turn on auto-update so this never comes up again.)\"");
102
+ emit("Don't repeat this notice later in the same session.");
103
+ emit('');
104
+ };
@@ -48,6 +48,17 @@
48
48
  // stdout — an opted-in refusal is never swallowed.
49
49
  // alarm → always delivered. Not gated by anything. Silence here = a broken install looking healthy.
50
50
  //
51
+ // WHO ACTUALLY READS THE ADVISORY PATH — a precision this file's own wording keeps blurring, and the
52
+ // blur causes a real authoring mistake. "User-facing bytes" is right about OWNERSHIP (these are the
53
+ // only bytes that leave the hook) and wrong about AUDIENCE on the advisory path: `additionalContext`
54
+ // is injected into the MODEL's context window, not printed to the user's terminal. So an advisory
55
+ // `copy` that reads as finished user-facing prose produces a sentence the user never sees and a model
56
+ // that may or may not paraphrase it. An advisory candidate should be written as ONE INSTRUCTION TO
57
+ // THE MODEL, with any user-facing sentence quoted inside it (see advocacy-route.mjs for the shape).
58
+ // The BLOCK path is different and unchanged: exit 2's stderr becomes the refusal the user is shown.
59
+ // Consequence for measurement: a test at this boundary proves CANDIDATE DELIVERED. Whether the user
60
+ // ever saw a sentence is the model's behaviour and is only observable in a real-host run.
61
+ //
51
62
  // DELIVERY (the runtime writes the final envelope to the REAL streams, itself):
52
63
  // advisory → exit 0, stdout = {"hookSpecificOutput":{"hookEventName":…, "additionalContext":…}}.
53
64
  // block → exit 2, reason on stderr, stdout byte-empty.
@@ -140,8 +151,18 @@ const BASH = resolveBash();
140
151
  const ANTICIPATE = { argv: [BASH, path.join(SCRIPTS_DIR, 'anticipate.sh')], feedStdin: true, channels: ['advocacy', 'promotion'] };
141
152
  const lesson = (subEvent) => ({ argv: [BASH, path.join(SCRIPTS_DIR, 'lesson-hooks.sh'), subEvent], feedStdin: true, channels: ['lesson'] });
142
153
 
154
+ // THE RECOMMENDATION PRODUCER (advocacy-route.mjs). It answers a DIFFERENT question from anticipate:
155
+ // anticipate asks "what is installed here and switched OFF?", the route asks "what does rUv already
156
+ // ship that would materially help THIS ordinary request?" — the second is unreachable through the
157
+ // first, because anticipate's SILENCE RULE 1 makes an 'absent' capability silent by design and
158
+ // goal-match.mjs's GLOBAL_VETO rejects `customers`/`production`/`deploy` prompts outright.
159
+ // It is bound to the advocacy channel ONLY: it can never emit a lesson block or an alarm.
160
+ // process.execPath, not 'node' — the same reason resolveBash() exists: a PATH lookup that fails on
161
+ // one host is a producer that is dead there and indistinguishable from "nothing to say".
162
+ const ADVOCACY_ROUTE = { argv: [process.execPath, path.join(SCRIPTS_DIR, 'advocacy-route.mjs')], feedStdin: true, channels: ['advocacy'] };
163
+
143
164
  const BUILTIN_REGISTRY = {
144
- 'UserPromptSubmit': [ANTICIPATE, lesson('UserPromptSubmit')],
165
+ 'UserPromptSubmit': [ANTICIPATE, ADVOCACY_ROUTE, lesson('UserPromptSubmit')],
145
166
  'PreToolUse-write': [lesson('PreToolUse-write')],
146
167
  'PreToolUse-bash': [lesson('PreToolUse-bash')],
147
168
  'PreToolUse-push': [lesson('PreToolUse-push')],
@@ -219,9 +240,18 @@ for (const p of producers) {
219
240
  if (remaining <= 0) break; // global budget spent → run no more producers
220
241
  let out = '';
221
242
  try {
243
+ // Build env for the spawned producer. advocacy-route.mjs needs RUVNET_ADVOCACY_ROUTE_BUDGET_MS
244
+ // to self-limit under concurrent load; pass it through if set by the test or caller.
245
+ const producerEnv = {
246
+ ...process.env,
247
+ RUVNET_EMIT_CANDIDATES: '1',
248
+ };
249
+ if (process.env.RUVNET_ADVOCACY_ROUTE_BUDGET_MS) {
250
+ producerEnv.RUVNET_ADVOCACY_ROUTE_BUDGET_MS = process.env.RUVNET_ADVOCACY_ROUTE_BUDGET_MS;
251
+ }
222
252
  const r = spawnSync(p.argv[0], p.argv.slice(1), {
223
253
  input: p.feedStdin ? payload : Buffer.alloc(0),
224
- env: { ...process.env, RUVNET_EMIT_CANDIDATES: '1' },
254
+ env: producerEnv,
225
255
  timeout: remaining,
226
256
  maxBuffer: MAX_BUFFER,
227
257
  });