ruvnet-brain 4.0.2 → 4.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/install.mjs +215 -31
- package/docs/RELEASE-NOTES-4.0.md +1 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/whats-new.md +6 -6
- package/plugin/docs/RELEASE-NOTES-4.0.md +88 -0
- package/plugin/mcp/server.mjs +70 -33
- package/plugin/scripts/hook-shim.mjs +27 -22
- package/plugin/scripts/session-start-core.mjs +28 -2
- package/plugin/scripts/whats-new.mjs +42 -0
- package/plugin/skills/release-proof/SKILL.md +21 -4
- package/plugin/skills/release-proof/references/receipt-contract.md +8 -2
- package/plugin/skills/release-proof/scripts/release-proof.mjs +77 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +22 -7
- package/plugin/skills/whats-new/SKILL.md +4 -4
- package/scripts/build-bundle.mjs +34 -25
- package/scripts/fix-workstream.mjs +291 -0
- package/scripts/health-repair.mjs +14 -27
- package/scripts/issue-fix.mjs +135 -216
- package/scripts/memory-doctor.mjs +64 -9
- package/scripts/nightly-wrapper.sh +20 -28
- package/scripts/onboarding-console.mjs +226 -53
- package/scripts/protected-release-invocation.mjs +76 -0
- package/scripts/publication-receipt.mjs +307 -0
- package/scripts/release-authority.mjs +93 -0
- package/scripts/release.mjs +55 -11
- package/scripts/self-update.mjs +15 -227
- package/scripts/stabilization-receipt.mjs +108 -0
- package/scripts/wired-check.mjs +3 -0
|
@@ -43,6 +43,7 @@ import os from 'node:os';
|
|
|
43
43
|
import path from 'node:path';
|
|
44
44
|
|
|
45
45
|
const HOME = os.homedir();
|
|
46
|
+
const DEFAULT_SCAN_ROOTS = ['Code', 'code', 'src', 'source', 'projects', 'dev', 'work'];
|
|
46
47
|
|
|
47
48
|
// Telemetry namespaces: high-volume, unembedded, zero-signal. Written by the npx hook calls.
|
|
48
49
|
// Counted separately so "you have 11,000 memories" is never mistaken for "you have 11,000 lessons".
|
|
@@ -131,8 +132,35 @@ const q = (db, sql) => {
|
|
|
131
132
|
// n() returns null for "unknown" and a number only when we genuinely counted. Callers must handle null.
|
|
132
133
|
const n = (r) => (r.ok ? (r.value === null || r.value === '' ? null : parseInt(r.value, 10)) : null);
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
135
|
+
function realExisting(value) {
|
|
136
|
+
try { return fs.realpathSync(value); } catch { return null; }
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function candidateRoots({
|
|
140
|
+
home = HOME,
|
|
141
|
+
configPath = path.join(home, '.claude', 'ruvnet-brain', 'config.json'),
|
|
142
|
+
} = {}) {
|
|
143
|
+
let configured = [];
|
|
144
|
+
try {
|
|
145
|
+
const value = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
|
146
|
+
if (Array.isArray(value.scanRoots)) configured = value.scanRoots.filter((item) => typeof item === 'string' && item.trim());
|
|
147
|
+
} catch { /* absent or malformed config does not erase the common roots */ }
|
|
148
|
+
|
|
149
|
+
const roots = new Set();
|
|
150
|
+
for (const value of [...DEFAULT_SCAN_ROOTS, ...configured]) {
|
|
151
|
+
const absolute = path.isAbsolute(value) ? value : path.join(home, value);
|
|
152
|
+
const canonical = realExisting(absolute);
|
|
153
|
+
try {
|
|
154
|
+
if (canonical && fs.statSync(canonical).isDirectory()) roots.add(canonical);
|
|
155
|
+
} catch { /* missing/non-directory roots are not candidates on this machine */ }
|
|
156
|
+
}
|
|
157
|
+
return [...roots].sort();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function storesBelow(root) {
|
|
161
|
+
const out = new Set();
|
|
162
|
+
const canonicalRoot = realExisting(root);
|
|
163
|
+
if (!canonicalRoot) return out;
|
|
136
164
|
const walk = (dir, depth) => {
|
|
137
165
|
if (depth > 4) return;
|
|
138
166
|
let entries;
|
|
@@ -142,24 +170,51 @@ export function findStores(root = path.join(HOME, 'Code')) {
|
|
|
142
170
|
if (e.name === 'node_modules' || e.name === '.git') continue;
|
|
143
171
|
if (e.name === '.swarm') {
|
|
144
172
|
const db = path.join(dir, '.swarm/memory.db');
|
|
145
|
-
|
|
173
|
+
const canonical = realExisting(db);
|
|
174
|
+
if (canonical) out.add(canonical);
|
|
146
175
|
continue;
|
|
147
176
|
}
|
|
148
177
|
if (e.name.startsWith('.') && e.name !== '.swarm') continue;
|
|
149
178
|
walk(path.join(dir, e.name), depth + 1);
|
|
150
179
|
}
|
|
151
180
|
};
|
|
152
|
-
walk(
|
|
153
|
-
|
|
154
|
-
|
|
181
|
+
walk(canonicalRoot, 0);
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export function findStores(root) {
|
|
186
|
+
const out = new Set();
|
|
187
|
+
if (root !== undefined) {
|
|
188
|
+
for (const db of storesBelow(root)) out.add(db);
|
|
189
|
+
return [...out].sort();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
for (const candidate of candidateRoots()) {
|
|
193
|
+
for (const db of storesBelow(candidate)) out.add(db);
|
|
194
|
+
}
|
|
195
|
+
// These two stores intentionally sit outside the project-root convention. They belong only to
|
|
196
|
+
// the fleet-wide no-argument scan; an explicit root must remain genuinely scoped.
|
|
155
197
|
for (const extra of [path.join(HOME, '.claude/.swarm/memory.db'), path.join(HOME, 'cognitum-trader/.swarm/memory.db')]) {
|
|
156
|
-
|
|
198
|
+
const canonical = realExisting(extra);
|
|
199
|
+
if (canonical) out.add(canonical);
|
|
200
|
+
}
|
|
201
|
+
return [...out].sort();
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function displayStoreName(db, home = HOME) {
|
|
205
|
+
const canonicalDb = realExisting(db) || path.resolve(db);
|
|
206
|
+
const project = path.dirname(path.dirname(canonicalDb));
|
|
207
|
+
const canonicalHome = realExisting(home) || path.resolve(home);
|
|
208
|
+
const relative = path.relative(canonicalHome, project);
|
|
209
|
+
if (relative === '') return '~';
|
|
210
|
+
if (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) {
|
|
211
|
+
return `~/${relative.split(path.sep).join('/')}`;
|
|
157
212
|
}
|
|
158
|
-
return
|
|
213
|
+
return project;
|
|
159
214
|
}
|
|
160
215
|
|
|
161
216
|
export function diagnose(db) {
|
|
162
|
-
const name =
|
|
217
|
+
const name = displayStoreName(db);
|
|
163
218
|
|
|
164
219
|
const ic = q(db, 'PRAGMA integrity_check;');
|
|
165
220
|
if (!ic.ok) {
|
|
@@ -41,8 +41,8 @@ echo $$ > "$LOCK"
|
|
|
41
41
|
trap 'rm -f "$LOCK"' EXIT
|
|
42
42
|
|
|
43
43
|
# NIGHTLY_SMOKE=1 — prove this whole chain (launchd -> heartbeat wrapper -> here -> node -> the repo
|
|
44
|
-
# scan -> the log) WITHOUT a multi-hour rebuild
|
|
45
|
-
#
|
|
44
|
+
# scan -> the log) WITHOUT a multi-hour rebuild. self-update.mjs is dry-run by default, so dropping
|
|
45
|
+
# --apply exercises every link except the build itself. Publication is never part of this job.
|
|
46
46
|
# WHY IT EXISTS (2026-07-13): the 03:15 fire on 07-12 died before it could even write its own log
|
|
47
47
|
# ("/bin/bash: logs/nightly.log: No such file or directory" — relative path, no working directory) and
|
|
48
48
|
# NOBODY KNEW, because the only way to test this chain was to wait until 03:15 and hope. A scheduled job
|
|
@@ -51,40 +51,32 @@ trap 'rm -f "$LOCK"' EXIT
|
|
|
51
51
|
SMOKE="${NIGHTLY_SMOKE:-0}"
|
|
52
52
|
|
|
53
53
|
run_once() {
|
|
54
|
-
local
|
|
55
|
-
|
|
56
|
-
{ echo "===== nightly-wrapper attempt $1 — $(date -u +%FT%TZ) — before: $before ====="
|
|
54
|
+
local rc
|
|
55
|
+
{ echo "===== nightly-wrapper rebuild attempt $1 — $(date -u +%FT%TZ) ====="
|
|
57
56
|
if [ "$SMOKE" = "1" ]; then
|
|
58
|
-
echo "[SMOKE] dry-run: exercising the full chain, NOT building
|
|
57
|
+
echo "[SMOKE] dry-run: exercising the full chain, NOT building"
|
|
59
58
|
/usr/local/bin/node scripts/self-update.mjs --fresh-window 60
|
|
60
59
|
else
|
|
61
|
-
/usr/local/bin/node scripts/self-update.mjs --apply --
|
|
60
|
+
/usr/local/bin/node scripts/self-update.mjs --apply --fresh-window 60
|
|
62
61
|
fi
|
|
63
62
|
} >> "$LOG" 2>&1
|
|
64
63
|
rc=$?
|
|
65
|
-
if [ "$
|
|
66
|
-
|
|
67
|
-
if [ "$rc" -eq 0 ]; then echo "===== attempt $1: SMOKE OK — chain intact (no build, no publish) =====" >> "$LOG"; return 0; fi
|
|
68
|
-
echo "===== attempt $1: SMOKE FAILED exit $rc — the chain is BROKEN, fix before 03:15 =====" >> "$LOG"
|
|
64
|
+
if [ "$rc" -ne 0 ]; then
|
|
65
|
+
echo "===== attempt $1: FAILED exit $rc — rebuild/candidate preparation did not complete =====" >> "$LOG"
|
|
69
66
|
return 1
|
|
70
67
|
fi
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
echo "===== attempt $1:
|
|
74
|
-
rm -f "$MARKER"
|
|
75
|
-
return 0
|
|
76
|
-
elif [ "$rc" -eq 0 ]; then
|
|
77
|
-
echo "===== attempt $1: CLEAN NO-OP, tag unchanged at $after =====" >> "$LOG"
|
|
78
|
-
rm -f "$MARKER"
|
|
68
|
+
if [ "$SMOKE" = "1" ]; then
|
|
69
|
+
# A smoke run must never claim a real outcome, touch the failure marker, or alter the tag story.
|
|
70
|
+
echo "===== attempt $1: SMOKE OK — chain intact (no build, no publish) =====" >> "$LOG"
|
|
79
71
|
return 0
|
|
80
|
-
else
|
|
81
|
-
echo "===== attempt $1: FAILED exit $rc, tag stuck at $after =====" >> "$LOG"
|
|
82
|
-
return 1
|
|
83
72
|
fi
|
|
73
|
+
echo "===== attempt $1: REBUILD COMPLETE — candidate bytes prepared; no release channel was promoted =====" >> "$LOG"
|
|
74
|
+
rm -f "$MARKER"
|
|
75
|
+
return 0
|
|
84
76
|
}
|
|
85
77
|
|
|
86
78
|
# Memory distillation (ADR-174) had gone stale 3 days — same silent-death pattern as everything
|
|
87
|
-
# else tonight. Independent of
|
|
79
|
+
# else tonight. Independent of rebuild success/failure: mine raw memory_entries into structured
|
|
88
80
|
# episodes/reasoning_patterns on every nightly run. Best-effort, never blocks the real job.
|
|
89
81
|
RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory distill run --path .swarm/memory.db >> "$LOG" 2>&1 || true
|
|
90
82
|
# Durability: was a one-off manual snapshot before tonight. Now recurring — WAL-safe, rotates
|
|
@@ -94,7 +86,7 @@ RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db .swarm/memor
|
|
|
94
86
|
# AgentDB drift canary (2026-07-23, P7 wiring sweep) — scripts/memdb-health.sh existed unwired since
|
|
95
87
|
# 2026-07-09/10, the exact nights idx_bridge_key/idx_bridge_ns corruption recurred 3x on this very DB
|
|
96
88
|
# while in WAL mode. Best-effort, same shape as its brain-health/key-health siblings below: never
|
|
97
|
-
# blocks the real
|
|
89
|
+
# blocks the real rebuild, just makes a WAL-mode/integrity regression loud instead of silent.
|
|
98
90
|
echo "===== memdb-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
99
91
|
sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
|
|
100
92
|
&& echo "===== memdb-health canary: OK =====" >> "$LOG" \
|
|
@@ -109,7 +101,7 @@ sh scripts/memdb-health.sh .swarm/memory.db >> "$LOG" 2>&1 \
|
|
|
109
101
|
# is the currency gate on the artifact this writes; if this step stops running, that workflow goes red
|
|
110
102
|
# on staleness rather than everything staying quietly green.
|
|
111
103
|
#
|
|
112
|
-
# Best-effort, same shape as the canaries below: it never blocks the
|
|
104
|
+
# Best-effort, same shape as the canaries below: it never blocks the rebuild. Its exit code is not
|
|
113
105
|
# thrown away though — it is written to the log by name, because 0/1/3/4 are four different facts
|
|
114
106
|
# (PASS / the lesson stopped transferring / the trap was invalidated / it could not be measured) and
|
|
115
107
|
# collapsing them into "failed" is how the seven prior silent-death bugs in this file happened.
|
|
@@ -126,7 +118,7 @@ esac
|
|
|
126
118
|
# ── GONG LAYER 3: brain-health canary (Stuart, 2026-07-12 — the brain must NEVER be dark silently).
|
|
127
119
|
# One real query against the LIVE cache brain every night. forge-ask-all.mjs exits non-zero on a
|
|
128
120
|
# total retrieval failure (all repos erroring) and rings kb/brain-alarm.mjs itself; this adds the
|
|
129
|
-
# guaranteed-nightly cadence plus its own urgent push, independent of the
|
|
121
|
+
# guaranteed-nightly cadence plus its own urgent push, independent of the rebuild job's outcome.
|
|
130
122
|
BRAIN_KB="$HOME/.cache/ruvnet-brain/kb"
|
|
131
123
|
if [ -f "$BRAIN_KB/forge-ask-all.mjs" ]; then
|
|
132
124
|
echo "===== brain-health canary — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
@@ -170,11 +162,11 @@ python3 -c "
|
|
|
170
162
|
import json, datetime
|
|
171
163
|
json.dump({
|
|
172
164
|
'at': datetime.datetime.utcnow().isoformat() + 'Z',
|
|
173
|
-
'
|
|
165
|
+
'last_release_tag_observed': '$AFTER',
|
|
174
166
|
'tail': '''$TAIL''',
|
|
175
167
|
'note': 'Nightly failed twice (immediate + 3min retry). Needs a live session to diagnose — see logs/nightly.log.'
|
|
176
168
|
}, open('$MARKER', 'w'), indent=2)
|
|
177
169
|
"
|
|
178
|
-
sh scripts/notify.sh "🔴 Nightly FAILED twice — needs you" "
|
|
170
|
+
sh scripts/notify.sh "🔴 Nightly FAILED twice — needs you" "candidate rebuild failed; last release remains $AFTER. Last: $TAIL" urgent "rotating_light"
|
|
179
171
|
echo "===== ESCALATED: marker written at $MARKER =====" >> "$LOG"
|
|
180
172
|
exit 1
|
|
@@ -27,7 +27,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
27
27
|
import { spawnSync, execFileSync, spawn } from 'node:child_process';
|
|
28
28
|
|
|
29
29
|
import { auditModel, installedVersion } from './stack-sync.mjs';
|
|
30
|
-
import { findStores, diagnose } from './memory-doctor.mjs';
|
|
30
|
+
import { candidateRoots, findStores, diagnose } from './memory-doctor.mjs';
|
|
31
31
|
import { buildStackRecommendations, buildWiringRecommendations, summarizeWiring, scoreMemoryHealth, buildHealthRecommendations, buildCapabilityRecommendations } from './console-engine.mjs';
|
|
32
32
|
import { planFor } from './remedy-registry.mjs';
|
|
33
33
|
import { auditAll as capabilityAuditAll } from './capability-registry.mjs';
|
|
@@ -79,6 +79,199 @@ const INSTALLED_KB = process.env.RUVNET_BRAIN_KB
|
|
|
79
79
|
const COMPLETE_BRAIN_SOURCE = process.env.RUVNET_BRAIN_COMPLETE_SOURCE
|
|
80
80
|
|| path.join(REPO, 'dist', 'ruvnet-brain');
|
|
81
81
|
const TOKEN = crypto.randomBytes(24).toString('hex');
|
|
82
|
+
const RUNTIME_RECEIPT_DIR = path.join(HOME, '.cache', 'ruvnet-brain', 'console-instances');
|
|
83
|
+
const RUNTIME_PRODUCT = 'ruvnet-brain-console';
|
|
84
|
+
const RUNTIME_SCHEMA = 1;
|
|
85
|
+
const RUNTIME_API_CONTRACT = 1;
|
|
86
|
+
const RUNTIME_SCRIPT = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
87
|
+
const RUNTIME_SOURCE_SHA256 = crypto.createHash('sha256').update(fs.readFileSync(RUNTIME_SCRIPT)).digest('hex');
|
|
88
|
+
|
|
89
|
+
function canonicalScope(cwd = process.cwd()) {
|
|
90
|
+
try { return fs.realpathSync(cwd); } catch { return path.resolve(cwd); }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function runtimeReceiptPath(cwd = process.cwd()) {
|
|
94
|
+
const scope = canonicalScope(cwd);
|
|
95
|
+
const scopeId = crypto.createHash('sha256').update(scope).digest('hex').slice(0, 24);
|
|
96
|
+
return path.join(RUNTIME_RECEIPT_DIR, `${scopeId}.json`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function runtimeIdentity({ port, cwd = process.cwd(), pid = process.pid, startedAt = new Date().toISOString() }) {
|
|
100
|
+
return {
|
|
101
|
+
product: RUNTIME_PRODUCT,
|
|
102
|
+
schema: RUNTIME_SCHEMA,
|
|
103
|
+
apiContract: RUNTIME_API_CONTRACT,
|
|
104
|
+
pid,
|
|
105
|
+
port,
|
|
106
|
+
startedAt,
|
|
107
|
+
scope: canonicalScope(cwd),
|
|
108
|
+
scriptRealpath: RUNTIME_SCRIPT,
|
|
109
|
+
runtimeVersion: brainVersionOnDisk(),
|
|
110
|
+
sourceSha256: RUNTIME_SOURCE_SHA256,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function validRuntimeReceipt(receipt, file) {
|
|
115
|
+
if (!receipt || typeof receipt !== 'object') return false;
|
|
116
|
+
if (process.platform !== 'win32') {
|
|
117
|
+
try { if ((fs.statSync(file).mode & 0o777) !== 0o600) return false; } catch { return false; }
|
|
118
|
+
}
|
|
119
|
+
return receipt.product === RUNTIME_PRODUCT
|
|
120
|
+
&& receipt.schema === RUNTIME_SCHEMA
|
|
121
|
+
&& receipt.apiContract === RUNTIME_API_CONTRACT
|
|
122
|
+
&& Number.isInteger(receipt.pid) && receipt.pid > 0
|
|
123
|
+
&& Number.isInteger(receipt.port) && receipt.port > 0 && receipt.port <= 65535
|
|
124
|
+
&& typeof receipt.startedAt === 'string'
|
|
125
|
+
&& typeof receipt.scope === 'string'
|
|
126
|
+
&& typeof receipt.scriptRealpath === 'string'
|
|
127
|
+
&& typeof receipt.runtimeVersion === 'string'
|
|
128
|
+
&& /^[a-f0-9]{64}$/.test(receipt.sourceSha256 || '')
|
|
129
|
+
&& /^[a-f0-9]{48}$/.test(receipt.controlToken || '');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function publicRuntimeIdentity(receipt) {
|
|
133
|
+
const { controlToken: _secret, ...identity } = receipt;
|
|
134
|
+
return identity;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function writeRuntimeReceipt(receipt, file = runtimeReceiptPath(receipt.scope)) {
|
|
138
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
139
|
+
if (process.platform !== 'win32') fs.chmodSync(path.dirname(file), 0o700);
|
|
140
|
+
const tmp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
141
|
+
try {
|
|
142
|
+
fs.writeFileSync(tmp, `${JSON.stringify(receipt)}\n`, { mode: 0o600, flag: 'wx' });
|
|
143
|
+
if (process.platform !== 'win32') fs.chmodSync(tmp, 0o600);
|
|
144
|
+
fs.renameSync(tmp, file);
|
|
145
|
+
} finally {
|
|
146
|
+
try { if (fs.existsSync(tmp)) fs.unlinkSync(tmp); } catch { /* best effort */ }
|
|
147
|
+
}
|
|
148
|
+
return file;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function removeOwnedRuntimeReceipt(file, controlToken) {
|
|
152
|
+
const current = readJSON(file);
|
|
153
|
+
if (!current || current.controlToken !== controlToken) return false;
|
|
154
|
+
try { fs.unlinkSync(file); return true; } catch { return false; }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function sameRuntimeIdentity(left, right) {
|
|
158
|
+
return ['product', 'schema', 'apiContract', 'pid', 'port', 'startedAt', 'scope',
|
|
159
|
+
'scriptRealpath', 'runtimeVersion', 'sourceSha256'].every((key) => left?.[key] === right?.[key]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function probeRuntime(port, timeout = 800) {
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
const req = http.get({ host: '127.0.0.1', port, path: '/api/runtime', timeout }, (res) => {
|
|
165
|
+
let body = '';
|
|
166
|
+
res.on('data', (chunk) => {
|
|
167
|
+
body += chunk;
|
|
168
|
+
if (body.length > 16_384) req.destroy();
|
|
169
|
+
});
|
|
170
|
+
res.on('end', () => {
|
|
171
|
+
if (res.statusCode !== 200) return resolve(null);
|
|
172
|
+
try { resolve(JSON.parse(body)); } catch { resolve(null); }
|
|
173
|
+
});
|
|
174
|
+
res.on('error', () => resolve(null));
|
|
175
|
+
});
|
|
176
|
+
req.on('error', () => resolve(null));
|
|
177
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function probeHttpEndpoint(port, endpoint = '/api/runtime', timeout = 800) {
|
|
182
|
+
return new Promise((resolve) => {
|
|
183
|
+
const req = http.get({ host: '127.0.0.1', port, path: endpoint, timeout }, (res) => {
|
|
184
|
+
res.resume();
|
|
185
|
+
res.on('end', () => resolve({ reachable: true, status: res.statusCode }));
|
|
186
|
+
});
|
|
187
|
+
req.on('error', () => resolve({ reachable: false, status: null }));
|
|
188
|
+
req.on('timeout', () => { req.destroy(); resolve({ reachable: false, status: null }); });
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function requestRuntimeShutdown(port, controlToken, timeout = 1_500) {
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const body = JSON.stringify({ controlToken });
|
|
195
|
+
const req = http.request({
|
|
196
|
+
host: '127.0.0.1', port, path: '/api/runtime/shutdown', method: 'POST', timeout,
|
|
197
|
+
headers: { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) },
|
|
198
|
+
}, (res) => {
|
|
199
|
+
res.resume();
|
|
200
|
+
res.on('end', () => resolve(res.statusCode === 202));
|
|
201
|
+
});
|
|
202
|
+
req.on('error', () => resolve(false));
|
|
203
|
+
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
204
|
+
req.end(body);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function waitForRuntimeToStop(port, timeout = 3_000) {
|
|
209
|
+
const until = Date.now() + timeout;
|
|
210
|
+
while (Date.now() < until) {
|
|
211
|
+
if (!await probeRuntime(port, 150)) return true;
|
|
212
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
213
|
+
}
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function inspectConsoleRuntime({ cwd = process.cwd(), preferredPort = Number(process.env.CONSOLE_PORT) || 7411 } = {}) {
|
|
218
|
+
const receiptFile = runtimeReceiptPath(cwd);
|
|
219
|
+
if (!fs.existsSync(receiptFile)) {
|
|
220
|
+
const endpoint = await probeHttpEndpoint(preferredPort);
|
|
221
|
+
return endpoint.reachable
|
|
222
|
+
? { state: 'foreign-port', scope: canonicalScope(cwd), port: preferredPort }
|
|
223
|
+
: { state: 'not-running', scope: canonicalScope(cwd) };
|
|
224
|
+
}
|
|
225
|
+
const receipt = readJSON(receiptFile);
|
|
226
|
+
if (!validRuntimeReceipt(receipt, receiptFile)) return { state: 'receipt-invalid', scope: canonicalScope(cwd) };
|
|
227
|
+
const live = await probeRuntime(receipt.port);
|
|
228
|
+
if (!live) {
|
|
229
|
+
const endpoint = await probeHttpEndpoint(receipt.port);
|
|
230
|
+
return endpoint.reachable
|
|
231
|
+
? { state: 'legacy-unowned', scope: canonicalScope(cwd), port: receipt.port }
|
|
232
|
+
: { state: 'stale-receipt', scope: canonicalScope(cwd), port: receipt.port };
|
|
233
|
+
}
|
|
234
|
+
if (!sameRuntimeIdentity(live, publicRuntimeIdentity(receipt))) {
|
|
235
|
+
return { state: 'legacy-unowned', scope: canonicalScope(cwd), port: receipt.port };
|
|
236
|
+
}
|
|
237
|
+
const candidate = runtimeIdentity({ port: receipt.port, cwd, pid: receipt.pid, startedAt: receipt.startedAt });
|
|
238
|
+
const current = sameRuntimeIdentity(live, candidate);
|
|
239
|
+
return {
|
|
240
|
+
state: current ? 'current' : 'stale-running',
|
|
241
|
+
scope: canonicalScope(cwd),
|
|
242
|
+
port: receipt.port,
|
|
243
|
+
live,
|
|
244
|
+
candidate: publicRuntimeIdentity(candidate),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function launchConsole({ port = Number(process.env.CONSOLE_PORT) || 7411, open = false, cwd = process.cwd() } = {}) {
|
|
249
|
+
const status = await inspectConsoleRuntime({ cwd, preferredPort: port });
|
|
250
|
+
if (status.state === 'current') {
|
|
251
|
+
const url = `http://127.0.0.1:${status.port}/`;
|
|
252
|
+
console.log(`\n 🧠 RuvNet Brain — Onboarding Console (already running)\n ${url}\n`);
|
|
253
|
+
if (open) openBrowser(url);
|
|
254
|
+
return { reused: true, port: status.port };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (status.state === 'stale-running') {
|
|
258
|
+
const receiptFile = runtimeReceiptPath(cwd);
|
|
259
|
+
const receipt = readJSON(receiptFile);
|
|
260
|
+
const stopped = validRuntimeReceipt(receipt, receiptFile)
|
|
261
|
+
&& await requestRuntimeShutdown(receipt.port, receipt.controlToken)
|
|
262
|
+
&& await waitForRuntimeToStop(receipt.port);
|
|
263
|
+
if (stopped) {
|
|
264
|
+
console.log(` replacing owned stale Console on port ${receipt.port}…`);
|
|
265
|
+
return { reused: false, server: startServer({ port: receipt.port, open, cwd }) };
|
|
266
|
+
}
|
|
267
|
+
console.error(' owned stale Console did not release its port — starting the current Console separately');
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (status.state === 'stale-receipt' || status.state === 'receipt-invalid') {
|
|
271
|
+
try { fs.unlinkSync(runtimeReceiptPath(cwd)); } catch { /* already gone or unreadable */ }
|
|
272
|
+
}
|
|
273
|
+
return { reused: false, server: startServer({ port, open, cwd }) };
|
|
274
|
+
}
|
|
82
275
|
|
|
83
276
|
const NPX_RUV = /npx\s+(?:-y\s+|--yes\s+)?(?:@claude-flow\/[\w-]+|claude-flow|ruflo|ruvector|ruv-swarm|flow-nexus|metaharness|@metaharness\/[\w-]+|agentic-qe|aqe)(?:@[\w.-]+)?/;
|
|
84
277
|
|
|
@@ -123,27 +316,9 @@ function robustReadJSON(db, sql) {
|
|
|
123
316
|
const VENDOR = ['/clones/', '/node_modules/', '/vendor/', '/upstream/', '.claude-backup', '_snapshots',
|
|
124
317
|
'/ruvnet-repos/', '/ruvnet_repos/'];
|
|
125
318
|
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
// look in the right place". Scan every root that actually exists on THIS machine, plus a user
|
|
130
|
-
// override in config.json (`scanRoots`, absolute paths or relative to $HOME) when present.
|
|
131
|
-
const DEFAULT_SCAN_ROOTS = ['Code', 'code', 'src', 'source', 'projects', 'dev', 'work'];
|
|
132
|
-
function candidateRoots() {
|
|
133
|
-
const cfg = readJSON(CONFIG_PATH) || {};
|
|
134
|
-
const configured = Array.isArray(cfg.scanRoots) && cfg.scanRoots.length > 0
|
|
135
|
-
? cfg.scanRoots.map((r) => (path.isAbsolute(r) ? r : path.join(HOME, r)))
|
|
136
|
-
: DEFAULT_SCAN_ROOTS.map((d) => path.join(HOME, d));
|
|
137
|
-
const seen = new Set();
|
|
138
|
-
const roots = [];
|
|
139
|
-
for (const r of configured) {
|
|
140
|
-
const resolved = path.resolve(r);
|
|
141
|
-
if (seen.has(resolved)) continue;
|
|
142
|
-
seen.add(resolved);
|
|
143
|
-
try { if (fs.statSync(resolved).isDirectory()) roots.push(resolved); } catch { /* doesn't exist on this machine — skip silently */ }
|
|
144
|
-
}
|
|
145
|
-
return roots;
|
|
146
|
-
}
|
|
319
|
+
// memory-doctor.mjs owns candidate-root policy for the standalone CLI, Console, and other callers.
|
|
320
|
+
// Keeping one exported implementation prevents a new project-root convention from fixing one
|
|
321
|
+
// surface while another continues to print a confident but incomplete machine-wide count (#81).
|
|
147
322
|
function findProjects(root) {
|
|
148
323
|
const out = new Set();
|
|
149
324
|
const walk = (dir, depth) => {
|
|
@@ -273,19 +448,7 @@ function probeMemory(projectDir) {
|
|
|
273
448
|
// machine has 100+. That is far too slow to sit on the page's first paint, so it is its own endpoint
|
|
274
449
|
// (/api/memory) and hydrates late, exactly like the stack audit does.
|
|
275
450
|
function scanFleet() {
|
|
276
|
-
|
|
277
|
-
// pass it every candidate root explicitly and de-dupe (it also always appends a couple of known
|
|
278
|
-
// extra paths regardless of root, which the Set below folds together instead of duplicating).
|
|
279
|
-
const seen = new Set();
|
|
280
|
-
const stores = [];
|
|
281
|
-
for (const root of candidateRoots()) {
|
|
282
|
-
for (const db of findStores(root)) {
|
|
283
|
-
const resolved = path.resolve(db);
|
|
284
|
-
if (seen.has(resolved)) continue;
|
|
285
|
-
seen.add(resolved);
|
|
286
|
-
stores.push(db);
|
|
287
|
-
}
|
|
288
|
-
}
|
|
451
|
+
const stores = findStores();
|
|
289
452
|
const fleet = [];
|
|
290
453
|
for (const db of stores) {
|
|
291
454
|
const d = diagnose(db);
|
|
@@ -2477,6 +2640,9 @@ function openBrowser(url) {
|
|
|
2477
2640
|
}
|
|
2478
2641
|
}
|
|
2479
2642
|
function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = false, cwd = process.cwd() } = {}) {
|
|
2643
|
+
const controlToken = crypto.randomBytes(24).toString('hex');
|
|
2644
|
+
let activeRuntime = null;
|
|
2645
|
+
let receiptFile = null;
|
|
2480
2646
|
const server = http.createServer(async (req, res) => {
|
|
2481
2647
|
// DNS-rebinding guard: this server binds 127.0.0.1 only. Reject any request whose Host header
|
|
2482
2648
|
// isn't loopback, so a malicious web page can't rebind a hostname to 127.0.0.1 and read local state.
|
|
@@ -2486,6 +2652,19 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
|
|
|
2486
2652
|
}
|
|
2487
2653
|
try {
|
|
2488
2654
|
const url = req.url.split('?')[0];
|
|
2655
|
+
// This identity endpoint is deliberately cache-independent and never exposes the shutdown
|
|
2656
|
+
// token. The private receipt is ownership proof; this endpoint only proves which generation
|
|
2657
|
+
// is listening before reuse or replacement.
|
|
2658
|
+
if (req.method === 'GET' && url === '/api/runtime') {
|
|
2659
|
+
return sendJSON(res, activeRuntime ? 200 : 503, activeRuntime || { error: 'runtime not ready' });
|
|
2660
|
+
}
|
|
2661
|
+
if (req.method === 'POST' && url === '/api/runtime/shutdown') {
|
|
2662
|
+
const body = await readBody(req);
|
|
2663
|
+
if (body.controlToken !== controlToken) return sendJSON(res, 403, { error: 'bad or missing control token' });
|
|
2664
|
+
sendJSON(res, 202, { ok: true, stopping: true });
|
|
2665
|
+
setImmediate(() => server.close());
|
|
2666
|
+
return;
|
|
2667
|
+
}
|
|
2489
2668
|
// Heavy read-models: ALWAYS cache-first (fast=1 or not — both land here now). The handler
|
|
2490
2669
|
// never blocks the event loop; kickRefresh() recomputes in a detached child. See writeCache/
|
|
2491
2670
|
// serveCached above and the --refresh-cache CLI mode. TOKEN is injected at serve time so it
|
|
@@ -2582,9 +2761,14 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
|
|
|
2582
2761
|
if (e.code === 'EADDRINUSE' && port !== 0) { console.error(` port ${port} busy — trying a free one…`); startServer({ port: 0, open, cwd }); }
|
|
2583
2762
|
else { console.error(` server error: ${e.message}`); process.exit(1); }
|
|
2584
2763
|
});
|
|
2764
|
+
server.on('close', () => {
|
|
2765
|
+
if (receiptFile) removeOwnedRuntimeReceipt(receiptFile, controlToken);
|
|
2766
|
+
});
|
|
2585
2767
|
server.listen(port, '127.0.0.1', () => {
|
|
2586
2768
|
const actual = server.address().port;
|
|
2587
2769
|
const url = `http://127.0.0.1:${actual}/`;
|
|
2770
|
+
activeRuntime = runtimeIdentity({ port: actual, cwd });
|
|
2771
|
+
receiptFile = writeRuntimeReceipt({ ...activeRuntime, controlToken });
|
|
2588
2772
|
console.log(`\n 🧠 RuvNet Brain — Onboarding Console`);
|
|
2589
2773
|
console.log(` ${url}`);
|
|
2590
2774
|
console.log(` read-only until you click · token-gated · ^C to stop\n`);
|
|
@@ -2629,6 +2813,7 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-consol
|
|
|
2629
2813
|
const args = process.argv.slice(2);
|
|
2630
2814
|
if (args.includes('--print-state')) { console.log(JSON.stringify(gatherState(process.cwd()), null, 2)); }
|
|
2631
2815
|
else if (args.includes('--print-stack')) { console.log(JSON.stringify(gatherStack(), null, 2)); }
|
|
2816
|
+
else if (args.includes('--runtime-status')) { console.log(JSON.stringify(await inspectConsoleRuntime())); }
|
|
2632
2817
|
else if (args.includes('--refresh-cache')) {
|
|
2633
2818
|
// Runs as a DETACHED CHILD of the server (kickRefresh) — or standalone to pre-warm. Computes the
|
|
2634
2819
|
// heavy read-models HERE, in a separate process, so the server's event loop is never blocked, and
|
|
@@ -2705,26 +2890,13 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-consol
|
|
|
2705
2890
|
process.exit(0);
|
|
2706
2891
|
}
|
|
2707
2892
|
else if (args.includes('--serve') || args.length === 0) {
|
|
2708
|
-
|
|
2709
|
-
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
const url = `http://127.0.0.1:${port}/`;
|
|
2713
|
-
const alive = await new Promise((resolve) => {
|
|
2714
|
-
const req = http.get({ host: '127.0.0.1', port, path: '/', timeout: 800 }, (res) => {
|
|
2715
|
-
let b = ''; res.on('data', (c) => { b += c; if (b.length > 4096) res.destroy(); });
|
|
2716
|
-
res.on('end', () => resolve(res.statusCode === 200 && /RuvNet Brain/.test(b)));
|
|
2717
|
-
res.on('error', () => resolve(false));
|
|
2718
|
-
});
|
|
2719
|
-
req.on('error', () => resolve(false));
|
|
2720
|
-
req.on('timeout', () => { req.destroy(); resolve(false); });
|
|
2893
|
+
await launchConsole({
|
|
2894
|
+
port: Number(process.env.CONSOLE_PORT) || 7411,
|
|
2895
|
+
open: args.includes('--open'),
|
|
2896
|
+
cwd: process.cwd(),
|
|
2721
2897
|
});
|
|
2722
|
-
if (alive) {
|
|
2723
|
-
console.log(`\n 🧠 RuvNet Brain — Onboarding Console (already running)\n ${url}\n`);
|
|
2724
|
-
if (open) openBrowser(url);
|
|
2725
|
-
} else { startServer({ port, open, cwd: process.cwd() }); }
|
|
2726
2898
|
}
|
|
2727
|
-
else { console.log(`\n onboarding-console — the RuvNet Brain configure page\n\n --serve [--open] start the local server
|
|
2899
|
+
else { console.log(`\n onboarding-console — the RuvNet Brain configure page\n\n --serve [--open] start or safely replace the scoped local server\n --runtime-status print candidate, receipt, and live runtime status\n --print-state print the read-only state JSON and exit (for tests)\n --print-stack print the stack audit JSON and exit\n`); }
|
|
2728
2900
|
}
|
|
2729
2901
|
|
|
2730
2902
|
export {
|
|
@@ -2747,3 +2919,4 @@ export {
|
|
|
2747
2919
|
// Exported for the cross-project cache-isolation test (console-cache-scope.test.mjs). serveCached's
|
|
2748
2920
|
// scopeKey is the guard that stops one project's cached state being served for another.
|
|
2749
2921
|
export { serveCached, writeCache, kickRefresh };
|
|
2922
|
+
export { inspectConsoleRuntime, launchConsole, runtimeReceiptPath, startServer };
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Local publication is deliberately impossible. The one publisher may mutate public channels only
|
|
3
|
+
// when the reviewer-protected GitHub workflow carries a fully valid candidate seal into it.
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { evaluateCandidateReceipt } from './release-proof.mjs';
|
|
8
|
+
import { getVersion } from './version.mjs';
|
|
9
|
+
|
|
10
|
+
export const PROTECTED_WORKFLOW = 'protected-release';
|
|
11
|
+
export const PROTECTED_VERSION = getVersion();
|
|
12
|
+
|
|
13
|
+
const hex = (value, length) => new RegExp(`^[0-9a-f]{${length}}$`).test(String(value || ''));
|
|
14
|
+
const digestOf = (receipt) => String(receipt?.artifact?.sha256 || '').replace(/^sha256:/, '');
|
|
15
|
+
|
|
16
|
+
export function validateProtectedPublishInvocation({ root = process.cwd(), env = process.env } = {}) {
|
|
17
|
+
const failures = [];
|
|
18
|
+
let releaseMode = 'unknown';
|
|
19
|
+
if (env.GITHUB_ACTIONS !== 'true' || env.GITHUB_WORKFLOW !== PROTECTED_WORKFLOW) {
|
|
20
|
+
failures.push('publication is allowed only inside the protected-release GitHub workflow');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const expectedSha = String(env.RUVNET_EXPECTED_SHA || '');
|
|
24
|
+
const expectedDigest = String(env.RUVNET_EXPECTED_ARTIFACT_SHA256 || '').replace(/^sha256:/, '');
|
|
25
|
+
const expectedVersion = String(env.RUVNET_EXPECTED_VERSION || '');
|
|
26
|
+
if (!hex(expectedSha, 40)) failures.push('expected candidate SHA is missing or malformed');
|
|
27
|
+
if (!hex(expectedDigest, 64)) failures.push('expected artifact digest is missing or malformed');
|
|
28
|
+
if (expectedVersion !== PROTECTED_VERSION) failures.push(`release generation must be ${PROTECTED_VERSION}`);
|
|
29
|
+
|
|
30
|
+
const receiptRelative = String(env.RUVNET_CANDIDATE_RECEIPT || '');
|
|
31
|
+
const evidenceRoot = path.resolve(root, 'release-evidence');
|
|
32
|
+
const receiptPath = path.resolve(root, receiptRelative || '.missing-candidate-receipt');
|
|
33
|
+
if (!receiptRelative || !(receiptPath === evidenceRoot || receiptPath.startsWith(`${evidenceRoot}${path.sep}`))) {
|
|
34
|
+
failures.push('candidate receipt must be inside release-evidence');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let receipt = null;
|
|
38
|
+
try {
|
|
39
|
+
receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
|
|
40
|
+
} catch (error) {
|
|
41
|
+
failures.push(`candidate receipt unavailable: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (receipt) {
|
|
45
|
+
const seal = evaluateCandidateReceipt(receipt);
|
|
46
|
+
if (seal.verdict !== 'PASS') failures.push(`candidate seal failed: ${seal.failures.map(({ code }) => code).join(',')}`);
|
|
47
|
+
const receiptDigest = digestOf(receipt);
|
|
48
|
+
if (receipt.sha !== expectedSha) failures.push('candidate receipt SHA mismatch');
|
|
49
|
+
if (receiptDigest !== expectedDigest) failures.push('candidate artifact digest mismatch');
|
|
50
|
+
if (receipt.version !== expectedVersion) failures.push('candidate version mismatch');
|
|
51
|
+
const expectedMode = receipt.phase === 'stabilization-candidate' ? 'stabilization' : 'strict';
|
|
52
|
+
releaseMode = expectedMode;
|
|
53
|
+
if (env.RUVNET_RELEASE_MODE !== expectedMode) failures.push(`release mode must be ${expectedMode} for this receipt`);
|
|
54
|
+
|
|
55
|
+
const artifactPath = path.resolve(root, String(receipt.artifact?.path || '.missing-artifact'));
|
|
56
|
+
if (!(artifactPath === evidenceRoot || artifactPath.startsWith(`${evidenceRoot}${path.sep}`))) {
|
|
57
|
+
failures.push('candidate artifact must be inside release-evidence');
|
|
58
|
+
} else {
|
|
59
|
+
try {
|
|
60
|
+
const actual = crypto.createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');
|
|
61
|
+
if (actual !== expectedDigest) failures.push('candidate artifact bytes do not match the seal');
|
|
62
|
+
} catch (error) {
|
|
63
|
+
failures.push(`candidate artifact unavailable: ${error.message}`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
verdict: failures.length === 0 ? 'PASS' : 'FAIL',
|
|
70
|
+
sha: expectedSha,
|
|
71
|
+
artifactSha256: expectedDigest,
|
|
72
|
+
version: expectedVersion,
|
|
73
|
+
mode: releaseMode,
|
|
74
|
+
failures,
|
|
75
|
+
};
|
|
76
|
+
}
|