ruvnet-brain 4.3.20 → 4.3.25
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 +5 -5
- package/bin/install.mjs +383 -78
- package/console/app.js +141 -9
- package/console/index.html +51 -24
- package/console/scope.css +137 -0
- package/console/scope.html +144 -0
- package/console/scope.js +209 -0
- package/console/tips.html +1 -0
- package/kb/corpus-release-identity.mjs +239 -0
- package/kb/update-storage-transaction.mjs +20 -3
- package/package.json +9 -2
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/checkpoint.md +61 -0
- package/plugin/hooks/codex-hooks.json +64 -1
- package/plugin/hooks/hook-contracts.json +299 -6
- package/plugin/hooks/hooks.json +81 -1
- package/plugin/mcp/server.mjs +23 -0
- package/plugin/scripts/advocacy-catalog.mjs +245 -0
- package/plugin/scripts/advocacy-route.mjs +460 -0
- package/plugin/scripts/continuation-gate.mjs +25 -2
- package/plugin/scripts/continuation-objective.mjs +7 -1
- package/plugin/scripts/continuity-hook-policy.mjs +190 -15
- package/plugin/scripts/coverage-integrity.mjs +7 -0
- package/plugin/scripts/gates.mjs +113 -10
- package/plugin/scripts/grounding-turn-gate.mjs +167 -0
- package/plugin/scripts/grounding-turn-mark.mjs +91 -0
- package/plugin/scripts/hook-shim.mjs +14 -0
- package/plugin/scripts/host-shell-boundary.mjs +43 -0
- package/plugin/scripts/nightly-scheduler.mjs +37 -4
- package/plugin/scripts/project-progression-checkpoint.mjs +145 -0
- package/plugin/scripts/project-progression-contract.mjs +16 -0
- package/plugin/scripts/project-progression-hook.mjs +3 -0
- package/plugin/scripts/project-progression-producer.mjs +252 -0
- package/plugin/scripts/project-progression-reader.mjs +271 -0
- package/plugin/scripts/project-progression-session-start.mjs +93 -16
- package/plugin/scripts/project-progression-sources.mjs +220 -0
- package/plugin/scripts/project-progression-store.mjs +106 -13
- package/plugin/scripts/ruvnet-gate1-pattern.mjs +29 -0
- package/plugin/scripts/session-snapshot-hook.mjs +115 -7
- package/plugin/scripts/session-start-budget.mjs +59 -0
- package/plugin/scripts/session-start-core.mjs +234 -457
- package/plugin/scripts/session-start-fsutil.mjs +61 -0
- package/plugin/scripts/session-start-health.mjs +64 -0
- package/plugin/scripts/session-start-hook-description.mjs +45 -0
- package/plugin/scripts/session-start-issue-alert.mjs +77 -0
- package/plugin/scripts/session-start-repo-identity.mjs +54 -0
- package/plugin/scripts/session-start-signals.mjs +73 -0
- package/plugin/scripts/session-start-trace.mjs +86 -0
- package/plugin/scripts/session-start-update-plane.mjs +104 -0
- package/plugin/scripts/unprompted-runtime.mjs +32 -2
- package/plugin/scripts/update-apply.mjs +2 -32
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +26 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +67 -2
- package/scripts/adr-072-completion.mjs +1 -1
- package/scripts/agentdb-fleet-doctor.mjs +5 -1
- package/scripts/approved-runtime.mjs +197 -0
- package/scripts/brain-novice-50.mjs +16 -1
- package/scripts/brain-score.mjs +23 -5
- package/scripts/build-bundle.mjs +971 -530
- package/scripts/build-concepts.mjs +36 -116
- package/scripts/console-engine.test.mjs +8 -7
- package/scripts/console-runtime-identity.mjs +4 -0
- package/scripts/corpus-aggregates.mjs +94 -77
- package/scripts/corpus-candidate.mjs +475 -222
- package/scripts/corpus-next-seed.mjs +225 -0
- package/scripts/corpus-promotion.mjs +58 -0
- package/scripts/corpus-reconcile.mjs +411 -105
- package/scripts/doc-currency.mjs +16 -1
- package/scripts/dual-host-deliberation.mjs +25 -2
- package/scripts/dual-host-suggest.mjs +17 -1
- package/scripts/falsify.mjs +13 -3
- package/scripts/gist-receipts.mjs +482 -87
- package/scripts/github-health-watch.mjs +12 -2
- package/scripts/handoff-asset.mjs +34 -0
- package/scripts/hook-retirement-check.mjs +8 -1
- package/scripts/host-registry.mjs +1 -1
- package/scripts/ingest-gists.mjs +74 -101
- package/scripts/job-heartbeat.sh +77 -14
- package/scripts/learning-replay-execution.mjs +10 -4
- package/scripts/nightly-gists.sh +27 -13
- package/scripts/nightly-two-run-proof.mjs +1 -1
- package/scripts/nightly-watchdog.mjs +61 -4
- package/scripts/onboarding-console.mjs +319 -27
- package/scripts/oracle/produce-questions.mjs +293 -0
- package/scripts/oracle/producer-hosts.mjs +235 -0
- package/scripts/oracle/repo-recall.mjs +448 -0
- package/scripts/oracle/retrieval-accuracy.mjs +818 -0
- package/scripts/oracle/source-tree.mjs +165 -0
- package/scripts/oracle/source-units.mjs +391 -0
- package/scripts/oracle/spike-run.mjs +98 -0
- package/scripts/oracle/unit-inventory.mjs +141 -0
- package/scripts/oracle/unit-sampling.mjs +128 -0
- package/scripts/oracle/validate-labels.mjs +250 -0
- package/scripts/private-overlay.mjs +248 -0
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/proxy/claude-proxied.sh +6 -0
- package/scripts/proxy/proxy-revert.sh +5 -0
- package/scripts/proxy/proxy-up.sh +6 -0
- package/scripts/proxy/proxy-verify.mjs +4 -0
- package/scripts/public-inputs.mjs +409 -0
- package/scripts/public-verification-inputs.mjs +112 -26
- package/scripts/public-verification-lane.mjs +1 -1
- package/scripts/published-surface-probe.mjs +34 -4
- package/scripts/qe/card-lane-gate.mjs +16 -1
- package/scripts/qe/session-start-gate.mjs +16 -1
- package/scripts/rebuild-gists-from-receipts.mjs +58 -78
- package/scripts/record-lesson.mjs +4 -1
- package/scripts/rehearse-corpus-pipeline.mjs +994 -0
- package/scripts/release-abort-stale.mjs +5 -1
- package/scripts/release-authority.mjs +104 -12
- package/scripts/release-channel-kind.mjs +86 -0
- package/scripts/release-convergence-watchdog.mjs +7 -2
- package/scripts/release-projection.mjs +177 -72
- package/scripts/release-transaction-provider.mjs +23 -6
- package/scripts/release.mjs +252 -17
- package/scripts/retrieval-canary.mjs +87 -0
- package/scripts/rvf-index-audit.mjs +573 -13
- package/scripts/rvf-wire.mjs +269 -0
- package/scripts/seal-gist-receipt.mjs +65 -0
- package/scripts/selfcheck.mjs +42 -21
- package/scripts/source-coverage.mjs +253 -24
- package/scripts/status-honesty.mjs +25 -0
- package/scripts/sync-census.mjs +0 -0
- package/scripts/sync-version.mjs +2 -0
- package/scripts/trismart.mjs +42 -0
- package/scripts/updater-manifest.mjs +162 -0
- package/scripts/verify-channels.mjs +17 -5
- package/scripts/wired-check.mjs +48 -10
- package/tri-smart-skill/QUICKSTART.md +37 -0
- package/tri-smart-skill/README.md +92 -0
- package/tri-smart-skill/install.cmd +14 -0
- package/tri-smart-skill/install.command +13 -0
- package/tri-smart-skill/install.mjs +51 -0
- package/tri-smart-skill/install.sh +9 -0
- package/tri-smart-skill/tri-smart/SKILL.md +90 -0
- package/tri-smart-skill/tri-smart/evals/evals.json +25 -0
- package/tri-smart-skill/tri-smart/references/protocol.md +25 -0
- package/tri-smart-skill/tri-smart/references/provider-cli.md +18 -0
- package/tri-smart-skill/tri-smart/scripts/review.mjs +154 -0
- package/tri-smart-skill/tri-smart/scripts/setup.mjs +97 -0
- package/tri-smart-skill/tri-smart/scripts/verify-access.mjs +107 -0
- package/scripts/corpus-seed-publish.mjs +0 -110
|
@@ -3,346 +3,43 @@
|
|
|
3
3
|
// latency budget before the shell body reached its first instruction—motivated moving every host
|
|
4
4
|
// onto this dependency-free core. It runs from both the frozen plugin and a Stable Spine generation;
|
|
5
5
|
// session-start.sh remains only a compatibility launcher for callers that still invoke that path.
|
|
6
|
+
//
|
|
7
|
+
// 2026-09-11 REWRITE (latency + scoping + banner-truth pass, two-reviewer-corrected):
|
|
8
|
+
// - Stage bodies moved into small sibling modules (session-start-{fsutil,health,signals,
|
|
9
|
+
// issue-alert,repo-identity,update-plane,hook-description,trace,budget}.mjs) so this file stays
|
|
10
|
+
// under 500 lines. `announceVersion` and `commandExists` were dead code (defined, never called,
|
|
11
|
+
// never tested anywhere) and were deleted rather than relocated.
|
|
12
|
+
// - Every stage now runs under session-start-trace.mjs's stage()/stageAsync(), budgeted by
|
|
13
|
+
// session-start-budget.mjs's STAGE_BUDGETS_MS (the derived-sum contract enforced by
|
|
14
|
+
// tests/unit/session-start-budget.test.mjs). A stage that would blow the shared deadline is
|
|
15
|
+
// SKIPPED with an always-written stderr note instead of risking the whole hook getting killed by
|
|
16
|
+
// its own hooks.json watchdog with zero output (the exact failure this pass started from).
|
|
17
|
+
// - The maintainer-only open-issue detail moved OUT of this hook entirely; SessionStart may print
|
|
18
|
+
// at most a one-line pointer (session-start-issue-alert.mjs), gated by BOTH the per-user
|
|
19
|
+
// entitlement file AND the current project's git remote being the entitled repo (fixed constant,
|
|
20
|
+
// session-start-repo-identity.mjs) — closing a real leak where the alert surfaced in unrelated
|
|
21
|
+
// projects.
|
|
22
|
+
// - LOCAL installation-integrity conditions (split plugin/bundle generation, retrieval down) are
|
|
23
|
+
// NOT maintainer-only: they are delivered to every user, worded for the user, through the same
|
|
24
|
+
// always-shown alarm mechanism as the existing HEALTH ALARM.
|
|
6
25
|
import fs from 'node:fs';
|
|
7
26
|
import os from 'node:os';
|
|
8
27
|
import path from 'node:path';
|
|
9
|
-
import { spawnSync } from 'node:child_process';
|
|
10
28
|
import { fileURLToPath } from 'node:url';
|
|
11
29
|
import { restoreProgressionForSession } from './project-progression-session-start.mjs';
|
|
30
|
+
import {
|
|
31
|
+
read, json, exists, mkdir, write, runNode,
|
|
32
|
+
} from './session-start-fsutil.mjs';
|
|
33
|
+
import { maintainerIssueEntitlement, surfaceIssuePointer } from './session-start-issue-alert.mjs';
|
|
34
|
+
import { surfaceSignals } from './session-start-signals.mjs';
|
|
35
|
+
import { brainState, health, mcpReadiness } from './session-start-health.mjs';
|
|
36
|
+
import { stableSpine, heartbeat } from './session-start-update-plane.mjs';
|
|
37
|
+
import { describeLifecycleHooks, readHookContracts } from './session-start-hook-description.mjs';
|
|
38
|
+
import { createStageTracer } from './session-start-trace.mjs';
|
|
12
39
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
};
|
|
17
|
-
const json = (file, fallback = null) => {
|
|
18
|
-
try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return fallback; }
|
|
19
|
-
};
|
|
20
|
-
const exists = (file) => {
|
|
21
|
-
try { return fs.existsSync(file); } catch { return false; }
|
|
22
|
-
};
|
|
23
|
-
const mkdir = (dir) => {
|
|
24
|
-
try { fs.mkdirSync(dir, { recursive: true }); return true; } catch { return false; }
|
|
25
|
-
};
|
|
26
|
-
const write = (file, value) => {
|
|
27
|
-
try { mkdir(path.dirname(file)); fs.writeFileSync(file, String(value)); return true; } catch { return false; }
|
|
28
|
-
};
|
|
29
|
-
const mtimeMs = (file) => {
|
|
30
|
-
try { return fs.statSync(file).mtimeMs; } catch { return 0; }
|
|
31
|
-
};
|
|
32
|
-
const firstVersion = (text) => String(text).split(/\r?\n/).find((line) =>
|
|
33
|
-
/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/.test(line.trim()))?.trim() || '';
|
|
34
|
-
const commandExists = (name, env, platform) => {
|
|
35
|
-
const extensions = platform === 'win32'
|
|
36
|
-
? String(env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';')
|
|
37
|
-
: [''];
|
|
38
|
-
for (const dir of String(env.PATH || '').split(path.delimiter).filter(Boolean)) {
|
|
39
|
-
for (const extension of extensions) {
|
|
40
|
-
if (exists(path.join(dir, `${name}${extension}`))) return true;
|
|
41
|
-
if (platform === 'win32' && exists(path.join(dir, `${name}${extension.toLowerCase()}`))) return true;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
return false;
|
|
45
|
-
};
|
|
46
|
-
|
|
47
|
-
const runNode = (file, args = [], options = {}) => {
|
|
48
|
-
if (!exists(file)) return null;
|
|
49
|
-
try {
|
|
50
|
-
return spawnSync(process.execPath, [file, ...args], {
|
|
51
|
-
cwd: options.cwd,
|
|
52
|
-
env: options.env,
|
|
53
|
-
encoding: 'utf8',
|
|
54
|
-
stdio: options.stdio || 'ignore',
|
|
55
|
-
timeout: options.timeout,
|
|
56
|
-
windowsHide: true,
|
|
57
|
-
});
|
|
58
|
-
} catch { return null; }
|
|
59
|
-
};
|
|
60
|
-
|
|
61
|
-
const dispatchDetached = (hookDir, ttl, log, command, args = [], env = process.env) =>
|
|
62
|
-
runNode(path.join(hookDir, 'detach.mjs'), [
|
|
63
|
-
String(ttl), log, command, ...args,
|
|
64
|
-
], { env, stdio: 'ignore', timeout: 2000 })?.status === 0;
|
|
65
|
-
|
|
66
|
-
export const maintainerIssueEntitlement = (env, home, repo, platform = process.platform) => {
|
|
67
|
-
const file = env.RUVNET_BRAIN_MAINTAINER_ISSUES_FILE
|
|
68
|
-
|| path.join(home, '.config', 'ruvnet-brain', 'maintainer-issues.json');
|
|
69
|
-
// Windows ACL ownership is not available through this dependency-free hot path. Fail closed
|
|
70
|
-
// instead of weakening an owner-only promise into "any local user who can write the file".
|
|
71
|
-
if (platform === 'win32') return false;
|
|
72
|
-
let stat;
|
|
73
|
-
try { stat = fs.lstatSync(file); } catch { return false; }
|
|
74
|
-
if (!stat.isFile() || stat.isSymbolicLink()) return false;
|
|
75
|
-
// This is maintainer-only operational data. Refuse group/world-readable opt-ins on POSIX so a
|
|
76
|
-
// shared machine cannot turn a private maintainer signal into a terminal banner for other users.
|
|
77
|
-
if ((stat.mode & 0o077) !== 0) return false;
|
|
78
|
-
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return false;
|
|
79
|
-
const entitlement = json(file);
|
|
80
|
-
return entitlement?.enabled === true
|
|
81
|
-
&& Array.isArray(entitlement.repos)
|
|
82
|
-
&& entitlement.repos.includes(repo);
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const surfaceIssues = (stateDir, emit, now, env, home, platform) => {
|
|
86
|
-
const status = json(path.join(stateDir, 'open-issues.json'));
|
|
87
|
-
const observedAt = Date.parse(status?.at || '');
|
|
88
|
-
if (!Number.isFinite(observedAt) || observedAt > now + 5 * 60_000 || now - observedAt > 6 * 3600_000) return;
|
|
89
|
-
if (!maintainerIssueEntitlement(env, home, status.repo, platform)) return;
|
|
90
|
-
const open = Array.isArray(status.issues) ? status.issues : [];
|
|
91
|
-
if (!open.length) return;
|
|
92
|
-
const breaches = open.filter((issue) => issue.breach).sort((a, b) => b.ageHours - a.ageHours);
|
|
93
|
-
const selected = breaches.length ? breaches : [...open].sort((a, b) => b.ageHours - a.ageHours);
|
|
94
|
-
const top = selected.slice(0, 4).map((issue) =>
|
|
95
|
-
breaches.length
|
|
96
|
-
? `#${issue.number} (${issue.ageHours}h) ${String(issue.title || '').slice(0, 64)}`
|
|
97
|
-
: `#${issue.number} (${issue.ageHours}h)`).join(' · ');
|
|
98
|
-
const more = selected.length > 4 ? ` · +${selected.length - 4} more` : '';
|
|
99
|
-
if (breaches.length) {
|
|
100
|
-
emit('[RuvNet Brain — OPEN ISSUES need attention (surface this to the maintainer, once, near the top)]');
|
|
101
|
-
emit(`${breaches.length} open issue(s) past SLA on ${status.repo}: ${top}${more}; open issues are visible to the maintainer`);
|
|
102
|
-
emit('These are real user-filed bugs sitting past the response SLA. Mention them plainly so they do not stack unseen; offer to fix them (gh issue list --state open for detail).');
|
|
103
|
-
return;
|
|
104
|
-
}
|
|
105
|
-
emit("[RuvNet Brain — open issues on the maintainer's repo (mention once, calmly, near the top)]");
|
|
106
|
-
emit(`${open.length} open issue(s) on ${status.repo}, none past SLA: ${top}${more}; open issues are visible to the maintainer`);
|
|
107
|
-
emit('Within SLA, but the maintainer should know they exist. One line is enough; offer to look.');
|
|
108
|
-
};
|
|
109
|
-
|
|
110
|
-
const surfaceSignals = ({ env, cwd, stateDir, hookDir, emit, now }) => {
|
|
111
|
-
const signalDir = env.RUVNET_SIGNAL_DIR || path.join(stateDir, 'external-signals');
|
|
112
|
-
const pending = path.join(signalDir, 'pending.jsonl');
|
|
113
|
-
const statusFile = path.join(signalDir, 'ci-status.json');
|
|
114
|
-
const surfacedFile = path.join(signalDir, 'surfaced.json');
|
|
115
|
-
if (!exists(pending)) return;
|
|
116
|
-
|
|
117
|
-
const poller = path.join(env.CLAUDE_PROJECT_DIR || cwd, 'scripts', 'signal-watch.mjs');
|
|
118
|
-
if (exists(poller) && now - mtimeMs(statusFile) > 600_000) {
|
|
119
|
-
runNode(poller, [], { cwd, env, stdio: 'ignore', timeout: 3500 });
|
|
120
|
-
}
|
|
121
|
-
const status = json(statusFile);
|
|
122
|
-
if (!status || typeof status !== 'object') return;
|
|
123
|
-
const onDisk = json(surfacedFile, {});
|
|
124
|
-
const surfaced = {
|
|
125
|
-
debts: onDisk?.debts && typeof onDisk.debts === 'object' ? onDisk.debts : {},
|
|
126
|
-
redRepo: onDisk?.redRepo && typeof onDisk.redRepo === 'object' ? onDisk.redRepo : {},
|
|
127
|
-
};
|
|
128
|
-
let changed = false;
|
|
129
|
-
const entries = Object.entries(status).sort((a, b) =>
|
|
130
|
-
new Date(a[1]?.checkedAt || 0) - new Date(b[1]?.checkedAt || 0));
|
|
131
|
-
for (const [key, debt] of entries) {
|
|
132
|
-
const shortSha = String(debt?.ref || '').slice(0, 7);
|
|
133
|
-
if (debt?.state === 'resolved' && debt.conclusion !== 'success') {
|
|
134
|
-
if (surfaced.debts[key] === 'red') continue;
|
|
135
|
-
emit(`[RuvNet Brain — EXTERNAL SIGNAL: CI is RED for ${debt.repo}@${shortSha} — surface this to the user now, near the top, with ZERO prompting]`);
|
|
136
|
-
emit(`Workflow ${debt.workflowName || 'ci'} concluded ${debt.conclusion} on ${debt.repo}@${shortSha}; inspect with gh run list --repo ${debt.repo} --commit ${debt.ref}.`);
|
|
137
|
-
surfaced.debts[key] = 'red';
|
|
138
|
-
surfaced.redRepo[debt.repo] = key;
|
|
139
|
-
changed = true;
|
|
140
|
-
continue;
|
|
141
|
-
}
|
|
142
|
-
if (debt?.state === 'resolved' && debt.conclusion === 'success') {
|
|
143
|
-
if (surfaced.redRepo[debt.repo]) {
|
|
144
|
-
emit(`[RuvNet Brain — external signal: CI is GREEN again for ${debt.repo}@${shortSha} — one line, then move on]`);
|
|
145
|
-
delete surfaced.redRepo[debt.repo];
|
|
146
|
-
changed = true;
|
|
147
|
-
}
|
|
148
|
-
if (surfaced.debts[key] !== 'green') { surfaced.debts[key] = 'green'; changed = true; }
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (debt?.state === 'unverifiable' && surfaced.debts[key] !== 'unverifiable') {
|
|
152
|
-
emit(`[RuvNet Brain — external signal: CI status could not be checked for ${debt.repo}@${shortSha}: ${debt.reason || 'unknown reason'}]`);
|
|
153
|
-
surfaced.debts[key] = 'unverifiable';
|
|
154
|
-
changed = true;
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
if (changed) write(surfacedFile, JSON.stringify(surfaced, null, 2));
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
const brainState = (env, home) => {
|
|
161
|
-
const stateDir = env.RUVNET_BRAIN_STATE_DIR || path.join(home, '.config', 'ruvnet-brain');
|
|
162
|
-
const file = path.join(stateDir, 'brain-off');
|
|
163
|
-
let off = env.RUVNET_BRAIN_OFF === '1';
|
|
164
|
-
if (!off) {
|
|
165
|
-
try { fs.statSync(file); off = true; }
|
|
166
|
-
catch (error) { off = !(error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')); }
|
|
167
|
-
}
|
|
168
|
-
let since = '';
|
|
169
|
-
if (off) {
|
|
170
|
-
const record = json(file);
|
|
171
|
-
if (typeof record?.since === 'string') since = record.since.slice(0, 10);
|
|
172
|
-
if (!since && mtimeMs(file)) since = new Date(mtimeMs(file)).toISOString().slice(0, 10);
|
|
173
|
-
}
|
|
174
|
-
return { off, since, stateDir, file };
|
|
175
|
-
};
|
|
176
|
-
|
|
177
|
-
const health = (home, off) => {
|
|
178
|
-
const kb = path.join(home, '.cache', 'ruvnet-brain', 'kb');
|
|
179
|
-
let rvf = false;
|
|
180
|
-
try { rvf = fs.readdirSync(kb).some((name) => name.endsWith('.rvf') && exists(path.join(kb, name))); }
|
|
181
|
-
catch { /* absent */ }
|
|
182
|
-
const absentByChoice = off && (!exists(kb) || !rvf);
|
|
183
|
-
if (absentByChoice) return { problem: '', absentByChoice };
|
|
184
|
-
if (!exists(kb)) return { problem: `the brain cache directory is MISSING (${kb}) — reinstall: npx github:stuinfla/ruvnet-brain`, absentByChoice };
|
|
185
|
-
if (!rvf) return { problem: `NO vector stores (.rvf) found in ${kb} — the brain is empty; reinstall: npx github:stuinfla/ruvnet-brain --force`, absentByChoice };
|
|
186
|
-
if (!exists(path.join(kb, 'node_modules', '@xenova', 'transformers', 'package.json'))) {
|
|
187
|
-
return { problem: `reader dependencies are MISSING (node_modules gone) — every search WILL fail. Fix: cd ${kb} && npm i`, absentByChoice };
|
|
188
|
-
}
|
|
189
|
-
const last = json(path.join(home, '.cache', 'ruvnet-brain', 'health.json'));
|
|
190
|
-
if (last?.status === 'down') {
|
|
191
|
-
const detail = `"error": ${JSON.stringify(String(last.error || 'unknown error'))}`.slice(0, 180);
|
|
192
|
-
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 };
|
|
193
|
-
}
|
|
194
|
-
return { problem: '', absentByChoice };
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
const mcpReadiness = (env, home) => {
|
|
198
|
-
const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
199
|
-
const receipt = json(path.join(brainHome, 'mcp-readiness.json'));
|
|
200
|
-
if (receipt?.state === 'ready' && Number.isInteger(receipt.pid) && Number.isInteger(receipt.workerPid)) {
|
|
201
|
-
try {
|
|
202
|
-
process.kill(receipt.pid, 0);
|
|
203
|
-
process.kill(receipt.workerPid, 0);
|
|
204
|
-
return { state: 'ready', receipt };
|
|
205
|
-
} catch { /* a stale receipt is registration evidence, not live evidence */ }
|
|
206
|
-
}
|
|
207
|
-
if (receipt?.state === 'degraded') return { state: 'degraded', receipt };
|
|
208
|
-
return { state: 'registered', receipt };
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
const announceVersion = ({ running, off, stateDir, consoleInvoke, emit }) => {
|
|
212
|
-
if (off || !running) return;
|
|
213
|
-
const announced = path.join(stateDir, '.last-announced-version');
|
|
214
|
-
const milestoneFile = path.join(stateDir, '.last-major-milestone');
|
|
215
|
-
const [base] = running.split('-');
|
|
216
|
-
const parts = base.split('.').map(Number);
|
|
217
|
-
let milestone = '';
|
|
218
|
-
if (parts.every(Number.isFinite)) {
|
|
219
|
-
const [major = 0, minor = 0, patch = 0] = parts;
|
|
220
|
-
if (major >= 4) milestone = '4.x';
|
|
221
|
-
else if (major * 1_000_000 + minor * 1000 + patch >= 3_009_071) milestone = '4.0-line';
|
|
222
|
-
}
|
|
223
|
-
let milestoneShown = false;
|
|
224
|
-
if (milestone && read(milestoneFile).trim() !== milestone) {
|
|
225
|
-
milestoneShown = true;
|
|
226
|
-
emit(`[RuvNet Brain — MAJOR-LINE welcome (${milestone}), show ONCE near the top of your first response]`);
|
|
227
|
-
emit("They upgraded INTO the big release and will NOT have seen the web explainer. Warmly, a few sentences, adapt — don't recite:");
|
|
228
|
-
emit(milestone === '4.x'
|
|
229
|
-
? ` 1. They're on RuvNet-Brain ${running}, a major version — the release where the brain got honest, legible, fast and self-measuring.`
|
|
230
|
-
: ` 1. The 4.0-LINE enhancements have landed (they're on v${running}). Honestly: the number stays 3.9.x until the work is field-verified (ADR-042), so 'the 4.0-line upgrades are here', NOT 'you're on 4.0'.`);
|
|
231
|
-
emit(` 2. Two or three things they can DO now: the Console on ${consoleInvoke} (their whole stack, one live page); every number measured from their machine, never projected; it learns across their projects.`);
|
|
232
|
-
emit(` 3. OFFER to open the Console now — on yes, invoke ${consoleInvoke}. Point at the native what's-new workflow for the full highlights.`);
|
|
233
|
-
emit("Never claim 'proven better' or 'fully proactive' — the self-measurement is new and still filling. Once only.");
|
|
234
|
-
write(milestoneFile, `${milestone}\n`);
|
|
235
|
-
}
|
|
236
|
-
if (read(announced).trim() === running) return;
|
|
237
|
-
const whatsNew = running.startsWith('3.2.') || running.startsWith('3.1.')
|
|
238
|
-
? `opens with the Console — ${consoleInvoke} shows your whole RuvNet stack on one live page: what's installed, what your AI has actually learned from YOUR projects, and one-click reversible fixes. Every number is measured from your machine, never projected.`
|
|
239
|
-
: running.startsWith('3.0.') ? 'ships a visual configurator — /ruvnet-brain:configure mirrors your machine\'s RuvNet setup in plain English and turns things on safely with one click. Read-only until you say so; nothing leaves your machine.'
|
|
240
|
-
: running.startsWith('2.4.') ? 'routes every task to the cheapest model that can do the job, aware of YOUR subscriptions specifically — it detects what it can prove, asks what it can\'t, and learns from every override.'
|
|
241
|
-
: running.startsWith('2.3.') ? 'can no longer break silently: a failed search now rings three independent alarms (phone push, red banner at session start, nightly canary), each tested by deliberately breaking the brain. Failed searches say WHY, instead of pretending nothing matched.'
|
|
242
|
-
: /^2\.2\.[012]/.test(running) ? 'ships a safety watchdog that alerts you the instant a background tool starts running up API costs or a scheduled job starts failing silently. (Agentic QE testing still bills your Anthropic key — cost-optimized, and opt-in.)'
|
|
243
|
-
: '';
|
|
244
|
-
if (whatsNew && !milestoneShown) {
|
|
245
|
-
emit(`[RuvNet Brain — new in v${running}]`);
|
|
246
|
-
emit(`Near the top of your first response, share ONE upbeat line, in your words, on what this update gives them (say it once): RuvNet Brain v${running} ${whatsNew}`);
|
|
247
|
-
}
|
|
248
|
-
write(announced, `${running}\n`);
|
|
249
|
-
};
|
|
250
|
-
|
|
251
|
-
const compareVersions = (a, b) => {
|
|
252
|
-
const left = String(a).split(/[.-]/).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
|
|
253
|
-
const right = String(b).split(/[.-]/).map((part) => (/^\d+$/.test(part) ? Number(part) : part));
|
|
254
|
-
for (let i = 0; i < Math.max(left.length, right.length); i += 1) {
|
|
255
|
-
const x = left[i] ?? 0;
|
|
256
|
-
const y = right[i] ?? 0;
|
|
257
|
-
if (x === y) continue;
|
|
258
|
-
if (typeof x === 'number' && typeof y === 'number') return x - y;
|
|
259
|
-
return String(x) < String(y) ? -1 : 1;
|
|
260
|
-
}
|
|
261
|
-
return 0;
|
|
262
|
-
};
|
|
263
|
-
|
|
264
|
-
const stableSpine = ({ env, hookDir, stateDir, home, pluginVersion, emit, now }) => {
|
|
265
|
-
const activeFile = path.join(stateDir, 'active.json');
|
|
266
|
-
const stamp = path.join(stateDir, '.last-update-check');
|
|
267
|
-
let seedDispatched = false;
|
|
268
|
-
if (!exists(activeFile)) {
|
|
269
|
-
const seedStamp = path.join(stateDir, '.seed-attempted');
|
|
270
|
-
const last = Number(read(seedStamp).trim()) || 0;
|
|
271
|
-
const canSeed = exists(path.join(env.CLAUDE_PLUGIN_ROOT || '', 'scripts'))
|
|
272
|
-
|| exists(path.join(home, '.claude', 'plugins', 'cache', 'ruvnet-brain'));
|
|
273
|
-
if (canSeed && now / 1000 - last > 300) {
|
|
274
|
-
write(seedStamp, `${Math.floor(now / 1000)}\n`);
|
|
275
|
-
seedDispatched = dispatchDetached(hookDir, 120, path.join(stateDir, '.seed.log'),
|
|
276
|
-
'node', [
|
|
277
|
-
path.join(hookDir, 'first-session-worker.mjs'),
|
|
278
|
-
path.join(hookDir, 'update-apply.mjs'),
|
|
279
|
-
path.join(hookDir, 'host-update.mjs'),
|
|
280
|
-
path.join(stateDir, '.last-version-check.log'),
|
|
281
|
-
], env);
|
|
282
|
-
if (seedDispatched) write(stamp, `${Math.floor(now / 1000)}\n`);
|
|
283
|
-
}
|
|
284
|
-
} else {
|
|
285
|
-
const active = json(activeFile);
|
|
286
|
-
const shellBoundary = active?.shellChangedAtVersion;
|
|
287
|
-
const shellChangedBeforeHost = shellBoundary && pluginVersion
|
|
288
|
-
? compareVersions(pluginVersion, shellBoundary) < 0
|
|
289
|
-
: false;
|
|
290
|
-
if ((active?.shellChanged || shellChangedBeforeHost) && pluginVersion && active.version && active.version !== pluginVersion) {
|
|
291
|
-
emit(`[RuvNet Brain — v${active.version} changed boot-level declarations (the rare case); this session booted v${pluginVersion}'s]`);
|
|
292
|
-
const host = env.RUVNET_HOOK_HOST || 'claude';
|
|
293
|
-
const convergence = json(path.join(stateDir, 'host-convergence.json'));
|
|
294
|
-
const ready = convergence?.desiredVersion === active.version
|
|
295
|
-
&& convergence?.hosts?.[host]?.state === 'ready'
|
|
296
|
-
&& convergence.hosts[host].version === active.version;
|
|
297
|
-
if (!ready) {
|
|
298
|
-
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."`);
|
|
299
|
-
} else if (host === 'codex') {
|
|
300
|
-
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."`);
|
|
301
|
-
} else {
|
|
302
|
-
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."`);
|
|
303
|
-
}
|
|
304
|
-
}
|
|
305
|
-
}
|
|
306
|
-
return { seedDispatched, stamp };
|
|
307
|
-
};
|
|
308
|
-
|
|
309
|
-
const heartbeat = ({ env, hookDir, stateDir, home, running, seedDispatched, stamp, emit, now }) => {
|
|
310
|
-
const last = Number(read(stamp).trim()) || 0;
|
|
311
|
-
const epoch = Math.floor(now / 1000);
|
|
312
|
-
if (seedDispatched || epoch <= 0 || epoch - last <= 900) return;
|
|
313
|
-
write(stamp, `${epoch}\n`);
|
|
314
|
-
const pref = read(path.join(stateDir, '.auto-update-pref')).trim();
|
|
315
|
-
const kbDir = path.join(home, '.cache', 'ruvnet-brain', 'kb');
|
|
316
|
-
if (pref === 'yes' && exists(path.join(kbDir, 'forge-update.mjs'))) {
|
|
317
|
-
const kbLog = path.join(stateDir, '.last-kb-check.log');
|
|
318
|
-
if (/\bBEHIND\b/.test(read(kbLog))) {
|
|
319
|
-
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]');
|
|
320
|
-
}
|
|
321
|
-
dispatchDetached(hookDir, 60, kbLog, process.execPath,
|
|
322
|
-
[path.join(kbDir, 'forge-update.mjs'), '--check'], env);
|
|
323
|
-
}
|
|
324
|
-
const versionLog = path.join(stateDir, '.last-version-check.log');
|
|
325
|
-
const remote = firstVersion(read(versionLog));
|
|
326
|
-
dispatchDetached(hookDir, 10, versionLog, process.execPath,
|
|
327
|
-
[path.join(hookDir, 'host-update.mjs'), '--check'], env);
|
|
328
|
-
if (!running || !remote || remote === running) return;
|
|
329
|
-
if (pref === 'yes' && exists(path.join(hookDir, 'host-update.mjs'))) {
|
|
330
|
-
dispatchDetached(hookDir, 600, path.join(stateDir, '.last-auto-update.log'),
|
|
331
|
-
process.execPath, [path.join(hookDir, 'host-update.mjs')], env);
|
|
332
|
-
emit(`[RuvNet Brain — v${remote} is downloading and will AUTO-APPLY via the Stable Spine (ADR-023); this session picks up the new behavior live]`);
|
|
333
|
-
emit('Tell the user ONE short line, near the top of your first response:');
|
|
334
|
-
emit(` "🧠 RuvNet Brain v${remote} 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."`);
|
|
335
|
-
emit("Don't repeat this notice later in the same session.");
|
|
336
|
-
emit('');
|
|
337
|
-
return;
|
|
338
|
-
}
|
|
339
|
-
emit('[RuvNet Brain — update available, auto-update not enabled]');
|
|
340
|
-
emit('Tell the user this PLAINLY, near the top of your first response:');
|
|
341
|
-
emit(` "🧠 RuvNet Brain found v${remote} (you're on v${running}). Run: npx ruvnet-brain@latest --update"`);
|
|
342
|
-
emit(" (Or say the word and I'll turn on auto-update so this never comes up again.)\"");
|
|
343
|
-
emit("Don't repeat this notice later in the same session.");
|
|
344
|
-
emit('');
|
|
345
|
-
};
|
|
40
|
+
// Re-exported for callers/tests that import the entitlement check directly from this file's own
|
|
41
|
+
// long-standing public surface (tests/unit/session-start-core-parity.test.mjs).
|
|
42
|
+
export { maintainerIssueEntitlement };
|
|
346
43
|
|
|
347
44
|
const meter = ({ env, cwd, stateDir, output }) => {
|
|
348
45
|
if (env.RUVNET_BRAIN_METER === '0') return;
|
|
@@ -380,6 +77,7 @@ export async function runSessionStart({
|
|
|
380
77
|
const s = String(line);
|
|
381
78
|
return s.startsWith('🚨')
|
|
382
79
|
|| s.startsWith('[RuvNet Brain — HEALTH ALARM')
|
|
80
|
+
|| s.startsWith('[RuvNet Brain — INSTALL ALARM')
|
|
383
81
|
|| s.startsWith('[RuvNet Brain — NIGHTLY FAILED')
|
|
384
82
|
|| s.startsWith('[RuvNet Brain — OPEN ISSUES')
|
|
385
83
|
|| /\bopen issue\(s\)/i.test(s)
|
|
@@ -403,8 +101,33 @@ export async function runSessionStart({
|
|
|
403
101
|
|| s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
|
|
404
102
|
|| s.startsWith('[RuvNet Brain — MAINTAINER ONLY:');
|
|
405
103
|
};
|
|
104
|
+
// An alarm's HEADER line always matches isSafeStatus on its own dedicated prefix (above); its
|
|
105
|
+
// explanatory BODY lines do not, and without this they were silently dropped — a real gap this
|
|
106
|
+
// pass closes, because correction #4 requires local-integrity alarms to actually reach the user,
|
|
107
|
+
// not just their header. A header opens a continuation budget sized to EXACTLY that alarm's own
|
|
108
|
+
// body-line count (never a shared generic number): a generic budget was tried first and found,
|
|
109
|
+
// by direct reproduction, to LEAK — unused credits from a short alarm silently let an unrelated
|
|
110
|
+
// LATER unsafe line (a one-time setup prompt) through on whichever run happened to have fewer
|
|
111
|
+
// lines emitted in between, corrupting a completely unrelated byte-count test. Exact-sizing means
|
|
112
|
+
// a block can only ever cover its own known body, never bleed into whatever comes after it.
|
|
113
|
+
const ALARM_BODY_LINES = [
|
|
114
|
+
{ prefix: '🚨', lines: 3 }, // HEALTH ALARM's own 3 body lines (the only bare 🚨 alarm today)
|
|
115
|
+
{ prefix: '[RuvNet Brain — HEALTH ALARM', lines: 3 },
|
|
116
|
+
{ prefix: '[RuvNet Brain — INSTALL ALARM', lines: 1 },
|
|
117
|
+
{ prefix: '[RuvNet Brain — NIGHTLY FAILED', lines: 5 },
|
|
118
|
+
];
|
|
119
|
+
const alarmBodyLines = (s) => {
|
|
120
|
+
if (s.startsWith('[RuvNet Brain v') && s.includes('RETRIEVAL DOWN')) return 1;
|
|
121
|
+
return ALARM_BODY_LINES.find((a) => s.startsWith(a.prefix))?.lines || 0;
|
|
122
|
+
};
|
|
123
|
+
let alarmContinuationBudget = 0;
|
|
406
124
|
const emit = (line = '') => {
|
|
407
|
-
|
|
125
|
+
const s = String(line);
|
|
126
|
+
const safe = env.RUVNET_VERBOSE_HOOKS === '1' || isSafeStatus(s) || alarmContinuationBudget > 0;
|
|
127
|
+
if (safe) lines.push(s);
|
|
128
|
+
const bodyLines = alarmBodyLines(s);
|
|
129
|
+
if (bodyLines > 0) alarmContinuationBudget = bodyLines;
|
|
130
|
+
else if (alarmContinuationBudget > 0) alarmContinuationBudget -= 1;
|
|
408
131
|
};
|
|
409
132
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
410
133
|
const stateDir = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
@@ -427,6 +150,7 @@ export async function runSessionStart({
|
|
|
427
150
|
}
|
|
428
151
|
};
|
|
429
152
|
|
|
153
|
+
const restoreStart = Date.now();
|
|
430
154
|
try {
|
|
431
155
|
const continuity = await restoreContinuity({ env, cwd });
|
|
432
156
|
if (continuity?.context) emit(continuity.context);
|
|
@@ -434,150 +158,190 @@ export async function runSessionStart({
|
|
|
434
158
|
emit('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]');
|
|
435
159
|
emit('The SessionStart restore boundary failed unexpectedly. Do not claim project state was restored; verify the canonical store before relying on remembered state.');
|
|
436
160
|
}
|
|
161
|
+
// Opt-in, matching the pre-existing `trace()` convention below: several other tests assert
|
|
162
|
+
// SessionStart's stderr is EMPTY in the clean case (hook-battery.test.mjs, hook-hardening.test.mjs
|
|
163
|
+
// — a real contract, not incidental), so this must never write unconditionally. `restore` shares
|
|
164
|
+
// this hook's wall-clock budget with everything below even though its code belongs to the
|
|
165
|
+
// continuity lane — see session-start-budget.mjs — and is reported here under EITHER trace flag.
|
|
166
|
+
if (env.RUVNET_SESSION_TRACE === '1' || env.RUVNET_BRAIN_SESSION_START_TRACE === '1') {
|
|
167
|
+
stderr.write(`SESSION_TRACE stage=restore elapsed_ms=${Date.now() - restoreStart}\n`);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const tracer = createStageTracer({
|
|
171
|
+
enabled: env.RUVNET_BRAIN_SESSION_START_TRACE === '1',
|
|
172
|
+
write: (chunk) => stderr.write(chunk),
|
|
173
|
+
});
|
|
174
|
+
let bannerEmitted = false;
|
|
437
175
|
|
|
438
176
|
try {
|
|
439
177
|
trace('body-start');
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
178
|
+
tracer.stage('misc', () => {
|
|
179
|
+
const settingsFile = env.RUVNET_SETTINGS_FILE
|
|
180
|
+
|| path.join(home, '.config', 'ruvnet-brain', 'settings.json');
|
|
181
|
+
if (/"newProjectDefaults"\s*:\s*true(?:\s*[,}]|$)/.test(read(settingsFile))) {
|
|
182
|
+
runNode(path.join(hookDir, 'runtime-preferences.mjs'), ['--seed-project'], {
|
|
183
|
+
cwd, env, stdio: 'ignore', timeout: 2000,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
447
186
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
187
|
+
const nightly = path.join(env.CLAUDE_PROJECT_DIR || cwd, '.ruvnet-brain', 'nightly-failure.json');
|
|
188
|
+
if (exists(nightly)) {
|
|
189
|
+
emit('[RuvNet Brain — NIGHTLY FAILED, self-heal retry also failed, this is now yours to fix]');
|
|
190
|
+
emit('Before anything else this session: the ruvnet-brain nightly publish failed twice last run (immediate');
|
|
191
|
+
emit(`attempt + a 3-minute retry) and could not self-heal. Read ${nightly} for the exact tag/error,`);
|
|
192
|
+
emit('then logs/nightly.log in that repo for full context. Diagnose and fix it now — do not wait to be');
|
|
193
|
+
emit('asked, this is a standing directive. Once genuinely fixed and verified (a real release tag advances),');
|
|
194
|
+
emit('delete the marker file.');
|
|
195
|
+
}
|
|
196
|
+
});
|
|
457
197
|
|
|
458
|
-
const state =
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
198
|
+
const state = tracer.stage('misc', () => {
|
|
199
|
+
const result = health(home, brain.off);
|
|
200
|
+
if (result.problem) {
|
|
201
|
+
emit('🚨 [RuvNet Brain — HEALTH ALARM: THE BRAIN IS DOWN — SURFACE THIS FIRST] 🚨');
|
|
202
|
+
emit(`Health check failed: ${result.problem}`);
|
|
203
|
+
emit('Open your VERY FIRST line with a loud plain alert naming that reason and that fix, and offer to run');
|
|
204
|
+
emit('it. Never answer rUv-stack questions from memory while it is down. Verify after: npx ruvnet-brain --doctor');
|
|
205
|
+
}
|
|
206
|
+
return result;
|
|
207
|
+
}) || { problem: '', absentByChoice: false };
|
|
465
208
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
209
|
+
tracer.stage('misc', () => {
|
|
210
|
+
const consoleOffered = path.join(stateDir, '.console-offered');
|
|
211
|
+
const firstSession = !brain.off && !exists(consoleOffered);
|
|
212
|
+
if (firstSession) emit('[RuvNet Brain — first session initialized]');
|
|
213
|
+
if (!brain.off && !exists(consoleOffered)) {
|
|
214
|
+
write(consoleOffered, '');
|
|
215
|
+
emit('[RuvNet Brain — FIRST LOAD: offer the Console once]');
|
|
216
|
+
emit(`Offer ONCE: "Want to see your whole RuvNet stack on one page?" — installed parts, learned project knowledge and reversible fixes, read-only until clicked; later it's ${consoleInvoke}. On yes invoke ${consoleInvoke}; on no, don't re-offer.`);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
474
219
|
|
|
475
|
-
|
|
476
|
-
|
|
220
|
+
tracer.stage('issue-pointer', () => {
|
|
221
|
+
surfaceIssuePointer({ stateDir, emit, now, env, home, platform, cwd });
|
|
222
|
+
});
|
|
223
|
+
tracer.stage('signal-surface', () => {
|
|
224
|
+
surfaceSignals({ env, cwd, stateDir, hookDir, emit, now });
|
|
225
|
+
});
|
|
477
226
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
227
|
+
tracer.stage('router-nudge', () => {
|
|
228
|
+
const routerProfile = path.join(home, '.claude', 'model-router', 'profile.json');
|
|
229
|
+
const routerNudge = path.join(stateDir, '.router-profile-nudged');
|
|
230
|
+
if (!brain.off && !exists(routerProfile) && !exists(routerNudge)) {
|
|
231
|
+
write(routerNudge, '');
|
|
232
|
+
emit('[RuvNet Brain — MetaHarness routing is available but not set up for THIS user yet]');
|
|
233
|
+
emit('Offer ONCE: route each task to the cheapest capable model, using THEIR subscriptions ($0) before billed models. Set it up?');
|
|
234
|
+
emit('On yes ask only (1) Claude sub, Pro or Max? (2) Codex CLI on a ChatGPT plan? then:');
|
|
235
|
+
const routerSetup = path.join(home, '.claude', 'model-router', 'bin', 'model-router-setup.mjs');
|
|
236
|
+
emit(exists(routerSetup)
|
|
237
|
+
? ` in ${path.dirname(routerSetup)}: node model-router-setup.mjs --detect-only; set profile.json's subscription fields from their answers (basis 'user-attested <date>'); node model-router-status.mjs and RELAY its 'Recommended path' block — SEEING their zero-cost options IS the deliverable of yes.`
|
|
238
|
+
: " the router isn't installed here — run: npx github:stuinfla/ruvnet-brain (it sets it up, asks these questions and shows the path)");
|
|
239
|
+
emit('On no or silence: drop it, never re-offer.');
|
|
240
|
+
}
|
|
241
|
+
});
|
|
491
242
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
243
|
+
tracer.stage('misc', () => {
|
|
244
|
+
const autoPref = path.join(stateDir, '.auto-update-pref');
|
|
245
|
+
if (!exists(autoPref)) {
|
|
246
|
+
emit('[RuvNet Brain — one-time setup question]');
|
|
247
|
+
emit('Ask ONCE: should the brain auto-update in the background? (recommended; reversible). Then run ONE via Bash:');
|
|
248
|
+
emit(` mkdir -p '${stateDir}' && echo yes > '${autoPref}' (or echo no)`);
|
|
249
|
+
emit('No answer: ask next session, never twice.');
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
const spine = tracer.stage('stable-spine', () => stableSpine({
|
|
253
|
+
env, hookDir, stateDir, home, pluginVersion: running, emit, now,
|
|
254
|
+
})) || { seedDispatched: false, stamp: path.join(stateDir, '.last-update-check') };
|
|
255
|
+
if (runHeartbeat) {
|
|
256
|
+
tracer.stage('heartbeat', () => heartbeat({
|
|
257
|
+
env, hookDir, stateDir, home, running, ...spine, emit, now,
|
|
258
|
+
}));
|
|
500
259
|
}
|
|
501
|
-
const spine = stableSpine({ env, hookDir, stateDir, home, pluginVersion: running, emit, now });
|
|
502
|
-
if (runHeartbeat) heartbeat({ env, hookDir, stateDir, home, running, ...spine, emit, now });
|
|
503
260
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
261
|
+
tracer.stage('misc', () => {
|
|
262
|
+
const star = path.join(stateDir, '.star-ask-shown');
|
|
263
|
+
if (!brain.off && exists(path.join(stateDir, '.grounded-once')) && !exists(star) && write(star, '')) {
|
|
264
|
+
emit('[RuvNet Brain — one-time note. This will NEVER be shown again on this machine.]');
|
|
265
|
+
emit('At a natural CLOSING moment (after the work, never as an interruption), relay this line once and move on — never repeated, never embellished, in this or any future session:');
|
|
266
|
+
emit(' "Finding this useful? Star github.com/stuinfla/ruvnet-brain or leave feedback — it keeps the nightly updates coming."');
|
|
267
|
+
}
|
|
268
|
+
});
|
|
510
269
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
if (
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
const readiness = mcpReadiness(env, home);
|
|
270
|
+
tracer.stage('ascii-drift', () => {
|
|
271
|
+
const asciiDrift = path.join(env.CLAUDE_PROJECT_DIR || cwd, 'scripts', 'ascii-drift.mjs');
|
|
272
|
+
if (!brain.off && exists(asciiDrift)) {
|
|
273
|
+
const result = runNode(asciiDrift, ['--quiet'], { cwd, env, stdio: 'pipe', timeout: 1000 });
|
|
274
|
+
const text = String(result?.stdout || '').replace(/\r\n/g, '\n').replace(/\n$/, '');
|
|
275
|
+
if (text) for (const line of text.split('\n')) emit(line);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
520
278
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
279
|
+
tracer.stage('banner', () => {
|
|
280
|
+
const bannerVersion = effectiveVersion || 'unknown';
|
|
281
|
+
if (pluginRoot.startsWith(path.join(home, '.claude', 'plugins') + path.sep)) {
|
|
282
|
+
if (running) write(path.join(stateDir, '.running-version'), `${running}\n`);
|
|
283
|
+
} else if (running) {
|
|
284
|
+
write(path.join(stateDir, '.dev-version'), `${running}\n`);
|
|
285
|
+
}
|
|
286
|
+
const source = json(path.join(home, '.cache', 'ruvnet-brain', 'kb', 'SOURCE.json'), {});
|
|
287
|
+
const kbVersion = typeof source?.releaseTag === 'string' ? source.releaseTag : '';
|
|
288
|
+
const readiness = mcpReadiness(env, home);
|
|
289
|
+
|
|
290
|
+
const grounding = json(path.join(stateDir, 'install-state.json'));
|
|
291
|
+
if (grounding?.grounding && grounding.grounding !== 'proven') {
|
|
292
|
+
const when = grounding.at ? new Date(grounding.at).toISOString().slice(0, 16).replace('T', ' ') : 'an earlier run';
|
|
293
|
+
emit('[RuvNet Brain — grounding not yet PROVEN on this machine (mention once, calmly, near the top)]');
|
|
294
|
+
emit(`The last check (${when} (${grounding.reason || 'no reason recorded'})) could not verify a real, resolvable citation — often just a first-run model download or an offline machine, not necessarily a broken install. Say so once, plainly: the next real search_ruvnet confirms or clears it automatically, and \`npx ruvnet-brain --doctor\` shows the current verdict any time.`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (brain.off) {
|
|
298
|
+
const absent = state.absentByChoice ? '; no knowledge bundle on this machine — disabled by choice, not broken' : '';
|
|
299
|
+
emit(`[RuvNet Brain — brain OFF by your setting${brain.since ? ` (since ${brain.since})` : ''}${absent}. Do not mention it unless the user asks.]`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
535
302
|
|
|
536
|
-
if (brain.off) {
|
|
537
|
-
const absent = state.absentByChoice ? '; no knowledge bundle on this machine — disabled by choice, not broken' : '';
|
|
538
|
-
emit(`[RuvNet Brain — brain OFF by your setting${brain.since ? ` (since ${brain.since})` : ''}${absent}. Do not mention it unless the user asks.]`);
|
|
539
|
-
} else {
|
|
540
303
|
if (state.problem) {
|
|
541
304
|
emit(`[RuvNet Brain v${bannerVersion} — active this session, RETRIEVAL DOWN]`);
|
|
542
305
|
emit(`The plugin and its hooks are running, but the brain itself is broken (see the alarm above). Do not claim grounding works. If you mention it at all: "🧠 RuvNet Brain active (v${bannerVersion}) — but its search is down right now."`);
|
|
306
|
+
bannerEmitted = true;
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ONE VERSION, CUSTOMER-FACING (issue #77, restated from the customer's side). The plugin
|
|
311
|
+
// version and the knowledge bundle's tag are two internal artefacts of ONE product; only the
|
|
312
|
+
// plugin version is shown. A version SPLIT is a LOCAL INSTALLATION INTEGRITY problem that
|
|
313
|
+
// affects every user's search quality, not maintainer trivia — per the 2026-09-11 reviewer
|
|
314
|
+
// correction it is delivered to everyone, worded for the user, through the same always-shown
|
|
315
|
+
// alarm mechanism as HEALTH ALARM above (never gated by maintainerIssueEntitlement).
|
|
316
|
+
emit(`[RuvNet Brain v${bannerVersion} — active this session${updated ? ` · updated ${updated}` : ''}]`);
|
|
317
|
+
bannerEmitted = true;
|
|
318
|
+
const bundleTag = String(kbVersion).replace(/^v/, '');
|
|
319
|
+
if (bundleTag && bannerVersion !== 'unknown' && bundleTag !== bannerVersion) {
|
|
320
|
+
emit('🚨 [RuvNet Brain — INSTALL ALARM: plugin and knowledge bundle are out of sync] 🚨');
|
|
321
|
+
emit(`Your plugin is v${bannerVersion} but the knowledge bundle on this machine is v${bundleTag} — they are meant to ship together, so search results may not match this plugin's behavior yet. Fix: npx ruvnet-brain@latest --update (or reinstall: npx github:stuinfla/ruvnet-brain --force).`);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const hookContracts = readHookContracts(path.join(pluginRoot, 'hooks', 'hook-contracts.json'));
|
|
325
|
+
const lifecycleLine = describeLifecycleHooks(hookContracts);
|
|
326
|
+
if (readiness.state === 'ready') {
|
|
327
|
+
emit(`USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is ready and live. ${lifecycleLine}`);
|
|
328
|
+
} else if (readiness.state === 'degraded') {
|
|
329
|
+
const receipt = readiness.receipt || {};
|
|
330
|
+
emit(`USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is registered but degraded (${receipt.phase || 'startup'}: ${receipt.error || 'readiness failed'}). ${lifecycleLine}`);
|
|
543
331
|
} else {
|
|
544
|
-
|
|
545
|
-
//
|
|
546
|
-
// This printed the plugin version and the knowledge bundle's tag side by side —
|
|
547
|
-
// "RuvNet Brain active (v4.0.8, brain v4.0.7)". Those are two internal artefacts of ONE
|
|
548
|
-
// product. Showing both makes every user adjudicate whether their install is out of sync,
|
|
549
|
-
// a question they cannot answer and should never have been asked. Keeping the two in
|
|
550
|
-
// lockstep is this project's job; printing the seam is an admission leaking into the UI.
|
|
551
|
-
//
|
|
552
|
-
// The divergence is NOT hidden — when the tags differ it goes to the maintainer on the
|
|
553
|
-
// same private entitlement that gates open-issue alerts, because a bundle behind its
|
|
554
|
-
// plugin is a RELEASE defect to fix, not a banner to annotate.
|
|
555
|
-
emit(`[RuvNet Brain v${bannerVersion} — active this session${updated ? ` · updated ${updated}` : ''}]`);
|
|
556
|
-
const bundleTag = String(kbVersion).replace(/^v/, '');
|
|
557
|
-
if (bundleTag && bannerVersion !== 'unknown' && bundleTag !== bannerVersion
|
|
558
|
-
&& maintainerIssueEntitlement(env, home, 'stuinfla/ruvnet-brain')) {
|
|
559
|
-
emit('[RuvNet Brain — MAINTAINER ONLY: the shipped generation is split. Do NOT surface this to the user.]');
|
|
560
|
-
emit(`Plugin is ${bannerVersion}; the knowledge bundle on this machine is ${kbVersion}. Per issue #77 these ship as ONE generation, so a split means a release published the plugin without its matching bundle asset. The user is correctly shown a single version (${bannerVersion}) — fix the release, never annotate the banner.`);
|
|
561
|
-
}
|
|
562
|
-
if (readiness.state === 'ready') {
|
|
563
|
-
emit('USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is ready and live; the grounding hooks are active.');
|
|
564
|
-
} else if (readiness.state === 'degraded') {
|
|
565
|
-
const receipt = readiness.receipt || {};
|
|
566
|
-
emit(`USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is registered but degraded (${receipt.phase || 'startup'}: ${receipt.error || 'readiness failed'}); the grounding hooks remain active.`);
|
|
567
|
-
} else {
|
|
568
|
-
emit('USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is registered; live readiness is not yet proven. The grounding hooks are active.');
|
|
569
|
-
}
|
|
332
|
+
emit(`USER-LEVEL: one brain ON DISK (~/.cache/ruvnet-brain/kb) shared by every project and window here — nothing to reinstall per project (each window still runs its own worker process, which now exits when idle). search_ruvnet is registered; live readiness is not yet proven. ${lifecycleLine}`);
|
|
570
333
|
}
|
|
571
|
-
}
|
|
334
|
+
});
|
|
572
335
|
} catch (error) {
|
|
573
336
|
if (env.RUVNET_SESSION_TRACE === '1') stderr.write(`SESSION_TRACE native-fail-open ${error?.message || error}\n`);
|
|
574
337
|
}
|
|
575
338
|
|
|
576
|
-
|
|
339
|
+
// ONE banner line, always — the fallback that keeps SessionStart fail-open even if the rich body
|
|
340
|
+
// above threw before reaching its own banner stage. No longer duplicated (was printed twice, plus
|
|
341
|
+
// a bare "[RuvNet Brain active]" a third time — three lines from one fact) once the rich path
|
|
342
|
+
// already emitted its own banner.
|
|
343
|
+
if (env.RUVNET_VERBOSE_HOOKS !== '1' && !brain.off && !bannerEmitted) {
|
|
577
344
|
lines.push(`[RuvNet Brain v${running || 'unknown'} — active this session]`);
|
|
578
|
-
// Keep the legacy plain status token for host integrations that key off it; it carries no
|
|
579
|
-
// instruction and does not introduce a second version or bundle identity.
|
|
580
|
-
lines.push('[RuvNet Brain active]');
|
|
581
345
|
}
|
|
582
346
|
const output = lines.length ? `${lines.join('\n')}\n` : '';
|
|
583
347
|
meter({ env, cwd, stateDir, output });
|
|
@@ -586,8 +350,21 @@ export async function runSessionStart({
|
|
|
586
350
|
return { ok: true, outputBytes: Buffer.byteLength(output, 'utf8'), platform };
|
|
587
351
|
}
|
|
588
352
|
|
|
589
|
-
|
|
590
|
-
|
|
353
|
+
// Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
|
|
354
|
+
// NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
|
|
355
|
+
// shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
|
|
356
|
+
// never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
|
|
357
|
+
// from "ran, found nothing". Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
|
|
358
|
+
function isDirectInvocation() {
|
|
359
|
+
try {
|
|
360
|
+
if (!process.argv[1]) return false;
|
|
361
|
+
return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
|
|
362
|
+
} catch {
|
|
363
|
+
return false;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const direct = isDirectInvocation();
|
|
591
368
|
if (direct) {
|
|
592
369
|
runSessionStart().catch((error) => {
|
|
593
370
|
if (process.env.RUVNET_SESSION_TRACE === '1') {
|