ruvnet-brain 3.9.134-dev → 4.0.2
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/.claude-plugin/marketplace.json +14 -0
- package/README.md +5 -5
- package/bin/install.mjs +382 -36
- package/console/CONTRACT.md +172 -0
- package/console/activity.js +753 -0
- package/console/app.js +4189 -0
- package/console/architecture.html +1221 -0
- package/console/assets/depth-1.webp +0 -0
- package/console/assets/depth-2.webp +0 -0
- package/console/assets/depth-3.webp +0 -0
- package/console/assets/harness-vs-plain.svg +259 -0
- package/console/assets/hero.webp +0 -0
- package/console/assets/memory.webp +0 -0
- package/console/assets/metaharness.svg +247 -0
- package/console/index.html +777 -0
- package/console/install-architecture.html +162 -0
- package/console/install-mockup.html +543 -0
- package/console/style.css +2144 -0
- package/console/tips.css +926 -0
- package/console/tips.html +858 -0
- package/console/tips.js +128 -0
- package/docs/RELEASE-NOTES-4.0.md +88 -0
- package/kb/model-requirements.mjs +37 -6
- package/kb/zip-extract.mjs +53 -14
- package/keys/ruvnet-brain-signing.pub.pem +3 -0
- package/package.json +14 -22
- package/plugin/.claude-plugin/marketplace.json +14 -0
- package/plugin/.claude-plugin/plugin.json +22 -0
- package/plugin/.codex-plugin/plugin.json +21 -0
- package/plugin/.mcp.json +8 -0
- package/plugin/commands/brain-console.md +16 -0
- package/plugin/commands/configure.md +33 -0
- package/plugin/commands/rvbc.md +79 -0
- package/plugin/commands/rvcb.md +16 -0
- package/plugin/commands/whats-new.md +57 -0
- package/plugin/hooks/codex-hooks.json +160 -0
- package/plugin/hooks/hook-contracts.json +77 -0
- package/plugin/hooks/hooks.json +202 -0
- package/plugin/mcp/managed-cli-interface.mjs +47 -4
- package/plugin/mcp/server.mjs +56 -6
- package/plugin/scripts/anticipate.sh +534 -0
- package/plugin/scripts/codex-hook-adapter.mjs +96 -0
- package/plugin/scripts/continuation-gate.mjs +267 -0
- package/plugin/scripts/design-wall.sh +137 -0
- package/plugin/scripts/detach.mjs +182 -0
- package/plugin/scripts/first-session-worker.mjs +38 -0
- package/plugin/scripts/gate-receipt.sh +35 -0
- package/plugin/scripts/ground-before-write.sh +199 -0
- package/plugin/scripts/ground-ruvnet.sh +517 -0
- package/plugin/scripts/grounding-stamp.sh +113 -0
- package/plugin/scripts/grounding-substance.mjs +595 -0
- package/plugin/scripts/hijack-ruvnet.sh +81 -0
- package/plugin/scripts/hook-input.mjs +558 -0
- package/plugin/scripts/hook-shim-bash.mjs +55 -0
- package/plugin/scripts/hook-shim.mjs +303 -0
- package/plugin/scripts/host-update.mjs +58 -0
- package/plugin/scripts/kling-preflight.sh +146 -0
- package/plugin/scripts/learn-capture.sh +173 -0
- package/plugin/scripts/learn-flush.mjs +155 -0
- package/plugin/scripts/lesson-hooks.sh +213 -0
- package/plugin/scripts/md-stamp.mjs +219 -0
- package/plugin/scripts/protect-brain-state.sh +84 -0
- package/plugin/scripts/route-dispatch.sh +147 -0
- package/plugin/scripts/routing-outcome-capture.mjs +89 -0
- package/plugin/scripts/runtime-preferences.mjs +269 -0
- package/plugin/scripts/session-start-core.mjs +477 -0
- package/plugin/scripts/session-start.sh +13 -0
- package/plugin/scripts/signal-watch.mjs +193 -0
- package/plugin/scripts/unprompted-runtime.mjs +377 -0
- package/plugin/scripts/update-apply.mjs +419 -0
- package/plugin/scripts/verify-interface.sh +53 -0
- package/plugin/scripts/version-bump-gate.sh +112 -0
- package/plugin/skills/brain-build/SKILL.md +123 -0
- package/plugin/skills/brain-console/SKILL.md +22 -0
- package/plugin/skills/brain-prompt/SKILL.md +83 -0
- package/plugin/skills/brain-score/SKILL.md +101 -0
- package/plugin/skills/release-proof/SKILL.md +81 -0
- package/plugin/skills/release-proof/agents/openai.yaml +4 -0
- package/plugin/skills/release-proof/references/receipt-contract.md +38 -0
- package/plugin/skills/release-proof/scripts/release-proof.mjs +210 -0
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +121 -0
- package/plugin/skills/ruvnet-brain/SKILL.md +234 -0
- package/plugin/skills/rvbc/SKILL.md +23 -0
- package/plugin/skills/savings/SKILL.md +46 -0
- package/plugin/skills/whats-new/SKILL.md +22 -0
- package/scripts/adr-backfill.mjs +107 -0
- package/scripts/advocacy-outcomes.mjs +808 -0
- package/scripts/agentdb-context.mjs +216 -0
- package/scripts/agentdb-fleet-doctor.mjs +101 -0
- package/scripts/ascii-drift.mjs +236 -0
- package/scripts/behavioral-l1-l4.mjs +210 -0
- package/scripts/brain-capability-check.mjs +72 -0
- package/scripts/brain-grade-groundtruth.mjs +100 -0
- package/scripts/brain-latency-50.mjs +227 -0
- package/scripts/brain-novice-50.mjs +189 -0
- package/scripts/brain-stamp.mjs +94 -0
- package/scripts/brain-state.mjs +212 -0
- package/scripts/build-bundle.mjs +522 -0
- package/scripts/build-concepts.mjs +132 -0
- package/scripts/build-l2.mjs +71 -0
- package/scripts/build-primer.mjs +73 -0
- package/scripts/build-symbols.mjs +68 -0
- package/scripts/calibrate-router.mjs +97 -0
- package/scripts/capability-audit.mjs +321 -0
- package/scripts/capability-registry.mjs +876 -0
- package/scripts/check-indexation.mjs +108 -0
- package/scripts/check-legibility.mjs +189 -0
- package/scripts/ci/build-fixture-kb.mjs +67 -0
- package/scripts/ci/learning-replay-codex-adapter.mjs +62 -0
- package/scripts/ci/learning-replay-recorder.mjs +59 -0
- package/scripts/ci/mutate-hook-timeout.mjs +70 -0
- package/scripts/ci/stranger-fixture-stage.mjs +17 -0
- package/scripts/ci/stranger-scenario.mjs +228 -0
- package/scripts/ci/stranger-timeout.mjs +25 -0
- package/scripts/ci-verdict.mjs +29 -0
- package/scripts/claims-verify.mjs +710 -0
- package/scripts/clear-claude-tmp.sh +31 -0
- package/scripts/console-engine.mjs +434 -0
- package/scripts/console-engine.test.mjs +125 -0
- package/scripts/corpus-qa.mjs +250 -0
- package/scripts/correction-detect-embed.mjs +346 -0
- package/scripts/correction-detect-measure.mjs +270 -0
- package/scripts/correction-detect.mjs +686 -0
- package/scripts/count-chunks.mjs +54 -0
- package/scripts/described-questions.json +30 -0
- package/scripts/design-grade.mjs +58 -0
- package/scripts/dev-plugin-link.sh +105 -0
- package/scripts/distill-project.mjs +200 -0
- package/scripts/doc-currency.mjs +801 -0
- package/scripts/eval-brain.mjs +244 -0
- package/scripts/fix-metaharness-memretrieve.mjs +121 -0
- package/scripts/full-hints.mjs +87 -0
- package/scripts/gate.sh +39 -0
- package/scripts/gates.mjs +146 -0
- package/scripts/gen-console-images.mjs +54 -0
- package/scripts/gen-images.mjs +47 -0
- package/scripts/git-clone-refresh.mjs +52 -0
- package/scripts/git-hooks/pre-push +126 -0
- package/scripts/goal-match.mjs +398 -0
- package/scripts/goldie-research.mjs +223 -0
- package/scripts/goldie-weekly.sh +67 -0
- package/scripts/health-repair.mjs +250 -0
- package/scripts/helix-scenario-questions.json +10 -0
- package/scripts/ingest-gists.mjs +230 -0
- package/scripts/ingest-meeting.mjs +115 -0
- package/scripts/ingest-repo.mjs +79 -0
- package/scripts/install-npx-witness.sh +49 -0
- package/scripts/issue-fix.mjs +639 -0
- package/scripts/issue-watch.mjs +276 -0
- package/scripts/issue4-close-note.md +31 -0
- package/scripts/key-canary.mjs +91 -0
- package/scripts/latency-to-surface.mjs +233 -0
- package/scripts/learning-enable.mjs +380 -0
- package/scripts/learning-replay.mjs +1570 -0
- package/scripts/learnings.mjs +62 -0
- package/scripts/lesson-gate.mjs +680 -0
- package/scripts/lesson-lifecycle.mjs +449 -0
- package/scripts/lesson-promote.mjs +262 -0
- package/scripts/lesson-ratify.mjs +98 -0
- package/scripts/lesson-seed.mjs +252 -0
- package/scripts/lesson-store.mjs +447 -0
- package/scripts/loop-checkpoint.mjs +86 -0
- package/scripts/memdb-health.sh +14 -0
- package/scripts/memory-doctor.mjs +271 -0
- package/scripts/model-catalog.mjs +79 -0
- package/scripts/nightly-controller.mjs +66 -0
- package/scripts/nightly-gists.sh +72 -0
- package/scripts/nightly-wrapper.sh +180 -0
- package/scripts/notify.sh +12 -0
- package/scripts/npx-witness.sh +56 -0
- package/scripts/onboarding-console.mjs +2749 -0
- package/scripts/private-fence.mjs +69 -0
- package/scripts/proactivity-metrics.mjs +118 -0
- package/scripts/proof-questions.json +56 -0
- package/scripts/prove.mjs +95 -0
- package/scripts/proxy/claude-proxied.sh +57 -0
- package/scripts/proxy/proxy-revert.sh +59 -0
- package/scripts/proxy/proxy-up.sh +60 -0
- package/scripts/proxy/proxy-verify.mjs +142 -0
- package/scripts/published-surface-probe.mjs +241 -0
- package/scripts/qe/card-lane-gate.mjs +162 -0
- package/scripts/qe/session-start-gate.mjs +229 -0
- package/scripts/qe/ux-suite.mjs +323 -0
- package/scripts/reconcile-project.mjs +0 -0
- package/scripts/record-lesson.mjs +113 -0
- package/scripts/refresh-model-catalog.mjs +99 -0
- package/scripts/release-proof.mjs +9 -0
- package/scripts/release-vector.mjs +281 -0
- package/scripts/release.mjs +395 -0
- package/scripts/remedy-registry.mjs +247 -0
- package/scripts/rerank-cap-eval.mjs +265 -0
- package/scripts/rerank-cap-warm-ab.mjs +129 -0
- package/scripts/route-cheap.mjs +20 -15
- package/scripts/router-utilization.mjs +182 -0
- package/scripts/routing-flywheel.mjs +596 -0
- package/scripts/rvf-generation.mjs +104 -0
- package/scripts/rvf-index-audit.mjs +138 -0
- package/scripts/self-update.mjs +508 -0
- package/scripts/selfcheck.mjs +7 -1
- package/scripts/sign-bundle.mjs +69 -0
- package/scripts/signal-watch.mjs +171 -0
- package/scripts/stack-sync.mjs +469 -0
- package/scripts/stamp-existing-rvf-generations.mjs +53 -0
- package/scripts/stamp-sweep.mjs +144 -0
- package/scripts/status-honesty.mjs +102 -0
- package/scripts/sync-version.mjs +217 -0
- package/scripts/token-report.mjs +102 -0
- package/scripts/top100-benchmark.mjs +479 -0
- package/scripts/top100-corpus.mjs +112 -0
- package/scripts/top100-semantic-assertions.mjs +449 -0
- package/scripts/update-apply.mjs +9 -0
- package/scripts/upgrade-notice.mjs +14 -0
- package/scripts/verify-bundle.mjs +51 -0
- package/scripts/verify-channels.mjs +184 -0
- package/scripts/verify-model-catalog.mjs +104 -0
- package/scripts/verify-nightly-close-issue4.sh +31 -0
- package/scripts/version.mjs +40 -0
- package/scripts/wired-check.mjs +864 -0
|
@@ -0,0 +1,2749 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// onboarding-console.mjs — the Onboarding Console server (ADR-0013 / DDD-0002).
|
|
3
|
+
//
|
|
4
|
+
// A locally-served page that renders RuvNet Brain's view of YOUR machine from real, measured state,
|
|
5
|
+
// and — only when you explicitly click, and only after telling you in plain words what it does —
|
|
6
|
+
// applies reversible fixes.
|
|
7
|
+
//
|
|
8
|
+
// The design law, encoded here rather than promised:
|
|
9
|
+
// • READ-ONLY BY DEFAULT. Serving the page and building /api/state writes nothing. (The stack
|
|
10
|
+
// audit reaches the npm registry over the network but mutates no user file.) Provable by running
|
|
11
|
+
// against a read-only filesystem: nothing in the render path opens a file for writing.
|
|
12
|
+
// • THE ONLY WRITER is the apply/save path, reached only by an authenticated POST the user triggered.
|
|
13
|
+
// • RE-VERIFY BEFORE WRITE. Apply re-measures the world and refuses any item that is no longer true
|
|
14
|
+
// (already fixed, or the machine moved) — the stale-read-then-write pattern that clobbered a memory
|
|
15
|
+
// checkpoint on 2026-07-12 is structurally avoided.
|
|
16
|
+
// • RECORD THE INVERSE FIRST. The undo is journalled before the mutation runs.
|
|
17
|
+
// • NEVER RE-IMPLEMENT A MUTATION. Every machine change dispatches to a script that already backs up,
|
|
18
|
+
// verifies against disk, and is idempotent (stack-sync.mjs --sync, reconcile-project.mjs --apply).
|
|
19
|
+
// • Bind 127.0.0.1 only; mint a random per-launch token; every mutating POST must echo it (else 403).
|
|
20
|
+
|
|
21
|
+
import http from 'node:http';
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import crypto from 'node:crypto';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { spawnSync, execFileSync, spawn } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
import { auditModel, installedVersion } from './stack-sync.mjs';
|
|
30
|
+
import { findStores, diagnose } from './memory-doctor.mjs';
|
|
31
|
+
import { buildStackRecommendations, buildWiringRecommendations, summarizeWiring, scoreMemoryHealth, buildHealthRecommendations, buildCapabilityRecommendations } from './console-engine.mjs';
|
|
32
|
+
import { planFor } from './remedy-registry.mjs';
|
|
33
|
+
import { auditAll as capabilityAuditAll } from './capability-registry.mjs';
|
|
34
|
+
import { getVersion } from './version.mjs';
|
|
35
|
+
// L5 (ADR-028): the audit is the one place that observes live capability state, so it is where an
|
|
36
|
+
// OFFERED-then-now-`on` transition becomes an APPLIED — the numerator of the precision metric that
|
|
37
|
+
// tells the owner whether advocacy is landing or nagging. Both are pure reads/appends and never throw.
|
|
38
|
+
import { reconcileApplied, reconcileIgnored, pendingOffers, precision as advocacyPrecision } from './advocacy-outcomes.mjs';
|
|
39
|
+
import { recordObservation as recordCapabilityStates } from './latency-to-surface.mjs';
|
|
40
|
+
import { loadCatalog as engineCatalog, catalogSource as engineCatalogSource, loadProfile as engineProfile, applyProfile, PROFILE_PATH } from './model-router-engine.mjs';
|
|
41
|
+
import { effectivePrices, loadLabelledRows, MIN_LABELS, OUTCOMES } from './metaharness-router.mjs';
|
|
42
|
+
import { utilization } from './router-utilization.mjs';
|
|
43
|
+
import { loadCatalog, detectProvider, frontierFor } from './model-catalog.mjs';
|
|
44
|
+
import { learnings } from './learnings.mjs';
|
|
45
|
+
import { gatesSurvey } from './gates.mjs';
|
|
46
|
+
// The write-safety primitives, borrowed rather than re-implemented. See saveConfig for why.
|
|
47
|
+
import { withLock, writeAtomic, LOCK_WAIT_MS, loadSettings, saveSettings, SETTINGS_SCHEMA as USER_SETTINGS_SCHEMA } from './user-settings.mjs';
|
|
48
|
+
// The brain on/off switch (ADR-054). The sentinel is the enforcement artifact; settings.json holds
|
|
49
|
+
// only a mirror. The console is the ONE surface allowed to flip it — protect-brain-state.sh walls
|
|
50
|
+
// the file off from agent edits — so both halves of the write live here, in saveBrainPower().
|
|
51
|
+
import { isBrainOff, readOffState, setBrainOff, setBrainOn, disagreement } from './brain-state.mjs';
|
|
52
|
+
import {
|
|
53
|
+
PROFILE_COMPLETE,
|
|
54
|
+
PROFILE_RUVECTOR,
|
|
55
|
+
applyBrainProfile,
|
|
56
|
+
discoverStoreFamilies,
|
|
57
|
+
measureBrainProfile,
|
|
58
|
+
restoreCompleteProfile,
|
|
59
|
+
} from '../kb/brain-profile.mjs';
|
|
60
|
+
// Lessons: read model + the two user verbs. Every mutation goes through lesson-store's own
|
|
61
|
+
// updateLessons/ratify/demote/restore — this file adds a SURFACE, never a second writer.
|
|
62
|
+
import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, STATUS } from './lesson-store.mjs';
|
|
63
|
+
import {
|
|
64
|
+
openRouterCredentialStatus,
|
|
65
|
+
saveOpenRouterCredential,
|
|
66
|
+
} from '../plugin/scripts/runtime-preferences.mjs';
|
|
67
|
+
import { applyNightlyChoice, nightlyStatus } from './nightly-controller.mjs';
|
|
68
|
+
|
|
69
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
70
|
+
const REPO = path.dirname(__dirname);
|
|
71
|
+
const CONSOLE_DIR = path.join(REPO, 'console');
|
|
72
|
+
const HOME = os.homedir();
|
|
73
|
+
const NPM_PREFIX = path.join(HOME, '.npm-global');
|
|
74
|
+
const CONFIG_DIR = path.join(HOME, '.claude/ruvnet-brain');
|
|
75
|
+
const CONFIG_PATH = path.join(CONFIG_DIR, 'config.json');
|
|
76
|
+
const UNDO_JOURNAL = path.join(HOME, '.cache/ruvnet-brain/console-undo.jsonl');
|
|
77
|
+
const INSTALLED_KB = process.env.RUVNET_BRAIN_KB
|
|
78
|
+
|| path.join(HOME, '.cache', 'ruvnet-brain', 'kb');
|
|
79
|
+
const COMPLETE_BRAIN_SOURCE = process.env.RUVNET_BRAIN_COMPLETE_SOURCE
|
|
80
|
+
|| path.join(REPO, 'dist', 'ruvnet-brain');
|
|
81
|
+
const TOKEN = crypto.randomBytes(24).toString('hex');
|
|
82
|
+
|
|
83
|
+
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
|
+
|
|
85
|
+
// ── tiny read helpers (all read-only) ────────────────────────────────────────────────────────────
|
|
86
|
+
const stamp = () => new Date().toISOString().replace(/[:.]/g, '-');
|
|
87
|
+
function readJSON(file) { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } }
|
|
88
|
+
// Read-only sqlite scalar with a WAL-safe fallback. A database being actively WRITTEN right now — the
|
|
89
|
+
// current project's OWN store, mid-session — can refuse a plain read-only open with SQLITE_CANTOPEN(14)
|
|
90
|
+
// because it cannot set up the -wal/-shm shared memory read-only. That is a sign of a LIVE, in-use
|
|
91
|
+
// store, NOT a broken one (misreading it as "broken" is the exact false-alarm memory-doctor's header
|
|
92
|
+
// warns against). So we retry with immutable=1, which reads the main file directly without WAL/SHM,
|
|
93
|
+
// and only give up if BOTH fail. Never throws, never writes. Returns { ok, value, mode }.
|
|
94
|
+
function robustRead(db, sql) {
|
|
95
|
+
let lastErr = null;
|
|
96
|
+
for (const mode of ['mode=ro', 'immutable=1']) {
|
|
97
|
+
try {
|
|
98
|
+
const uri = `file:${encodeURI(db)}?${mode}`;
|
|
99
|
+
const v = execFileSync('sqlite3', [uri, sql], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
100
|
+
return { ok: true, value: v === '' ? null : v, mode };
|
|
101
|
+
} catch (e) { lastErr = e; }
|
|
102
|
+
}
|
|
103
|
+
return { ok: false, value: null, mode: null, err: String(lastErr && lastErr.message || 'unreadable') };
|
|
104
|
+
}
|
|
105
|
+
// Row-returning sibling of robustRead: same WAL-safe two-mode ladder, `sqlite3 -json` output.
|
|
106
|
+
function robustReadJSON(db, sql) {
|
|
107
|
+
for (const mode of ['mode=ro', 'immutable=1']) {
|
|
108
|
+
try {
|
|
109
|
+
const uri = `file:${encodeURI(db)}?${mode}`;
|
|
110
|
+
const v = execFileSync('sqlite3', ['-json', uri, sql], { encoding: 'utf8', timeout: 15000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
|
|
111
|
+
return { ok: true, rows: v ? JSON.parse(v) : [], mode };
|
|
112
|
+
} catch { /* try next mode */ }
|
|
113
|
+
}
|
|
114
|
+
return { ok: false, rows: [], mode: null };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Wiring survey (read-only): how do this machine's projects launch rUv tools? ───────────────────
|
|
118
|
+
// Directories that are somebody else's code sitting on your disk. Their hook wiring is not YOUR
|
|
119
|
+
// wiring: you will never "fix" it, and counting it makes the card describe a machine you don't have.
|
|
120
|
+
// `ruvnet-repos` was the expensive omission — 98 of 768 sites (13% of the card) came from clones of
|
|
121
|
+
// rUv's OWN repos, including a directory literally named tests/init-test, and 18 of the 21 npx call
|
|
122
|
+
// sites the card warned about were his test fixtures rather than anything Stuart configured.
|
|
123
|
+
const VENDOR = ['/clones/', '/node_modules/', '/vendor/', '/upstream/', '.claude-backup', '_snapshots',
|
|
124
|
+
'/ruvnet-repos/', '/ruvnet_repos/'];
|
|
125
|
+
|
|
126
|
+
// ── Candidate scan roots (issue #19) ────────────────────────────────────────────────────────────
|
|
127
|
+
// A single hardcoded `~/Code` silently reports "0" on any machine that keeps projects somewhere
|
|
128
|
+
// else (a reporter's `~/source`, `~/dev`, `~/work`, …) — a confident zero that just means "didn't
|
|
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
|
+
}
|
|
147
|
+
function findProjects(root) {
|
|
148
|
+
const out = new Set();
|
|
149
|
+
const walk = (dir, depth) => {
|
|
150
|
+
if (depth > 4) return;
|
|
151
|
+
let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
152
|
+
for (const e of ents) {
|
|
153
|
+
const p = path.join(dir, e.name);
|
|
154
|
+
if (VENDOR.some((m) => (p + '/').includes(m))) continue;
|
|
155
|
+
if (e.isDirectory()) {
|
|
156
|
+
if (e.name === '.claude') { out.add(dir); continue; }
|
|
157
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
158
|
+
walk(p, depth + 1);
|
|
159
|
+
} else if (e.name === '.mcp.json') out.add(dir);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
walk(root, 0);
|
|
163
|
+
return [...out].sort();
|
|
164
|
+
}
|
|
165
|
+
// Text that PRINTS the word npx is not an npx call site. Two of the sites this card warned about were
|
|
166
|
+
// `echo "Session ended. Run: npx aqe learn status"` — advice being displayed to the user, matched as
|
|
167
|
+
// though the machine were executing it. Strip quoted echo/printf payloads before classifying.
|
|
168
|
+
const stripPrinted = (cmd) => String(cmd)
|
|
169
|
+
.replace(/\b(?:echo|printf)\s+(['"])(?:\\.|(?!\1)[\s\S])*?\1/g, ' ')
|
|
170
|
+
.replace(/\b(?:echo|printf)\s+[^|;&]*/g, ' ');
|
|
171
|
+
function classifyCommand(cmd) {
|
|
172
|
+
if (typeof cmd !== 'string' || !cmd.trim()) return null;
|
|
173
|
+
if (NPX_RUV.test(stripPrinted(cmd))) return 'NPX';
|
|
174
|
+
if (/\.npm-global\/bin\/(ruflo|ruvector|ruv-swarm|flow-nexus)/.test(cmd) || /hook-handler\.cjs/.test(cmd)) return 'GLOBAL_BINARY';
|
|
175
|
+
if (/CLAUDE_PLUGIN_ROOT/.test(cmd)) return 'PLUGIN';
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
function wiringSurvey() {
|
|
179
|
+
const sites = [];
|
|
180
|
+
// Scan every candidate root (issue #19), de-duped by resolved path — a symlinked or nested root
|
|
181
|
+
// must never count the same project twice.
|
|
182
|
+
const seenProjects = new Set();
|
|
183
|
+
const projects = [];
|
|
184
|
+
for (const root of candidateRoots()) {
|
|
185
|
+
for (const proj of findProjects(root)) {
|
|
186
|
+
const resolved = path.resolve(proj);
|
|
187
|
+
if (seenProjects.has(resolved)) continue;
|
|
188
|
+
seenProjects.add(resolved);
|
|
189
|
+
projects.push({ proj, root });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
for (const { proj, root } of projects) {
|
|
193
|
+
// Relative to the root it was actually found under, so "myproj" stays "myproj" instead of
|
|
194
|
+
// becoming an ugly full path when the machine only has one root (the common case).
|
|
195
|
+
const projName = path.relative(root, proj);
|
|
196
|
+
for (const f of ['.claude/settings.json', '.claude/settings.local.json']) {
|
|
197
|
+
const s = readJSON(path.join(proj, f));
|
|
198
|
+
if (!s?.hooks) continue;
|
|
199
|
+
for (const [event, groups] of Object.entries(s.hooks)) {
|
|
200
|
+
const list = Array.isArray(groups) ? groups : [groups];
|
|
201
|
+
for (const g of list) {
|
|
202
|
+
const hookArr = Array.isArray(g?.hooks) ? g.hooks : (g?.command ? [g] : []);
|
|
203
|
+
for (const h of hookArr) {
|
|
204
|
+
const mech = classifyCommand(h?.command);
|
|
205
|
+
if (mech) sites.push({ scope: 'project', project: projName, file: f, event, matcher: g?.matcher ?? '*', spec: String(h.command).slice(0, 160), mechanism: mech });
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const mcp = readJSON(path.join(proj, '.mcp.json'));
|
|
211
|
+
for (const [name, v] of Object.entries(mcp?.mcpServers || {})) {
|
|
212
|
+
const full = [v.command, ...(v.args || [])].join(' ');
|
|
213
|
+
const mech = NPX_RUV.test(full) ? 'NPX' : (/\bnpx\b/.test(full) ? null : 'MCP');
|
|
214
|
+
if (mech) sites.push({ scope: 'project', project: projName, file: '.mcp.json', event: 'MCP', matcher: name, spec: full.slice(0, 160), mechanism: mech });
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
return { sites, summary: summarizeWiring(sites) };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Memory health (read-only probes for the project the console was launched from) ────────────────
|
|
221
|
+
function sessionHookExists() {
|
|
222
|
+
return fs.existsSync(path.join(HOME, '.claude/hooks/agentdb-ensure.sh')) || fs.existsSync(path.join(HOME, '.claude/hooks'));
|
|
223
|
+
}
|
|
224
|
+
function probeMemory(projectDir) {
|
|
225
|
+
const db = path.join(projectDir, '.swarm/memory.db');
|
|
226
|
+
const probes = {};
|
|
227
|
+
// compaction survival + session surfacing are filesystem facts, always checkable
|
|
228
|
+
const snap = fs.existsSync(path.join(projectDir, 'agentdb-sessions.jsonl')) || fs.existsSync(path.join(projectDir, '.swarm/agentdb-sessions.jsonl'));
|
|
229
|
+
probes.compactionSurvival = snap ? { status: 'ok', detail: 'a PreCompact snapshot file is present' } : { status: 'warn', detail: 'no PreCompact snapshot found for this project yet' };
|
|
230
|
+
probes.sessionSurfacing = sessionHookExists() ? { status: 'ok', detail: 'the global SessionStart hook surfaces project state at launch' } : { status: 'warn', detail: 'no SessionStart recall hook found' };
|
|
231
|
+
// recall quality: honestly NOT probed at render (a true probe needs an embedding query; left for an explicit deep test)
|
|
232
|
+
probes.recallQuality = { status: 'notTested', detail: 'not checked this session — a real recall probe needs an embedding round-trip, which render deliberately avoids' };
|
|
233
|
+
|
|
234
|
+
if (!fs.existsSync(db)) {
|
|
235
|
+
probes.liveness = { status: 'fail', detail: 'this project has no memory store (.swarm/memory.db) yet' };
|
|
236
|
+
probes.coverage = { status: 'warn', detail: 'no checkpoint — no store has been created here' };
|
|
237
|
+
return probes;
|
|
238
|
+
}
|
|
239
|
+
// Liveness from a WAL-safe read. Existing-but-unopenable means the store is being written RIGHT NOW
|
|
240
|
+
// (a live store) — reported as "not checked this instant", never as a capping failure. Only a real
|
|
241
|
+
// corruption (integrity_check ≠ ok) is a fail.
|
|
242
|
+
const integ = robustRead(db, 'PRAGMA integrity_check;');
|
|
243
|
+
if (!integ.ok) {
|
|
244
|
+
probes.liveness = { status: 'notTested', detail: 'store is in active use right now — could not open a read-only snapshot this instant (normal for a live database being written; not a failure)' };
|
|
245
|
+
probes.coverage = { status: 'notTested', detail: 'store busy this instant — checkpoint presence not checked' };
|
|
246
|
+
return probes;
|
|
247
|
+
}
|
|
248
|
+
const integrity = (integ.value || '').split('\n')[0] || 'unknown';
|
|
249
|
+
const totalR = robustRead(db, 'SELECT count(*) FROM memory_entries;');
|
|
250
|
+
const embR = robustRead(db, "SELECT count(*) FROM memory_entries WHERE embedding IS NOT NULL AND length(embedding)>0;");
|
|
251
|
+
const total = totalR.ok ? (totalR.value === null ? 0 : parseInt(totalR.value, 10)) : null;
|
|
252
|
+
const embedded = embR.ok && embR.value !== null ? parseInt(embR.value, 10) : null;
|
|
253
|
+
const liveNote = integ.mode === 'immutable=1' ? ' and in active use' : '';
|
|
254
|
+
if (integrity !== 'ok') probes.liveness = { status: 'fail', detail: `store is corrupt (integrity_check: ${integrity})` };
|
|
255
|
+
else if (total === null) probes.liveness = { status: 'notTested', detail: 'store opened but counts were unavailable this instant' };
|
|
256
|
+
else if (total > 0) probes.liveness = { status: 'ok', detail: `store is live${liveNote}, integrity ok, ${total} entries${embedded != null && total ? `, ${Math.round((embedded / total) * 100)}% embedded` : ''} (read-only)` };
|
|
257
|
+
else probes.liveness = { status: 'warn', detail: 'store exists but is empty' };
|
|
258
|
+
|
|
259
|
+
const cp = robustRead(db, "SELECT max(updated_at) FROM memory_entries WHERE key LIKE 'project-state-current%';");
|
|
260
|
+
if (cp.ok && cp.value) {
|
|
261
|
+
const ageH = (Date.now() - Number(cp.value) * (String(cp.value).length <= 10 ? 1000 : 1)) / 3.6e6;
|
|
262
|
+
probes.coverage = Number.isFinite(ageH) && ageH < 48
|
|
263
|
+
? { status: 'ok', detail: `project checkpoint present, ~${Math.max(0, ageH).toFixed(0)}h old` }
|
|
264
|
+
: { status: 'warn', detail: 'project checkpoint present but stale (>2 days)' };
|
|
265
|
+
} else if (cp.ok) {
|
|
266
|
+
probes.coverage = { status: 'warn', detail: 'no project-state checkpoint found in this store' };
|
|
267
|
+
} else {
|
|
268
|
+
probes.coverage = { status: 'notTested', detail: 'store busy this instant — checkpoint presence not checked' };
|
|
269
|
+
}
|
|
270
|
+
return probes;
|
|
271
|
+
}
|
|
272
|
+
// The fleet-wide scan opens and queries every memory store on the machine — ~90ms each, and a real
|
|
273
|
+
// machine has 100+. That is far too slow to sit on the page's first paint, so it is its own endpoint
|
|
274
|
+
// (/api/memory) and hydrates late, exactly like the stack audit does.
|
|
275
|
+
function scanFleet() {
|
|
276
|
+
// memory-doctor.mjs's findStores() defaults to ~/Code and cannot be edited here (issue #19) — so
|
|
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
|
+
}
|
|
289
|
+
const fleet = [];
|
|
290
|
+
for (const db of stores) {
|
|
291
|
+
const d = diagnose(db);
|
|
292
|
+
if (d.unreadable || d.schemaless) { fleet.push({ name: d.name, unreadable: d.unreadable || 'no memory schema', total: 0, learns: false, findings: d.findings }); continue; }
|
|
293
|
+
if ((d.total || 0) === 0) continue;
|
|
294
|
+
fleet.push({ name: d.name, total: d.total, embedded: d.embedded, coverPct: +(d.cover * 100).toFixed(1), patterns: d.patterns ?? 0, learns: !!d.learns, findings: d.findings });
|
|
295
|
+
}
|
|
296
|
+
fleet.sort((a, b) => (b.total || 0) - (a.total || 0));
|
|
297
|
+
return fleet;
|
|
298
|
+
}
|
|
299
|
+
function gatherMemory(cwd, { fleet = true } = {}) {
|
|
300
|
+
// health = for the project the console was launched from (fall back to this repo)
|
|
301
|
+
const project = fs.existsSync(path.join(cwd, '.swarm/memory.db')) ? cwd : REPO;
|
|
302
|
+
const projName = project.replace(HOME + '/Code/', '').replace(HOME + '/', '~/');
|
|
303
|
+
const health = scoreMemoryHealth({ project: projName, probes: probeMemory(project) });
|
|
304
|
+
return { fleet: fleet ? scanFleet() : null, health };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Savings ledger (receipts only) ────────────────────────────────────────────────────────────────
|
|
308
|
+
function gatherSavings() {
|
|
309
|
+
// Primary source is the real routing-receipts ledger written by scripts/route-cheap.mjs.
|
|
310
|
+
const files = [
|
|
311
|
+
path.join(HOME, '.claude/metaharness/routing-receipts.jsonl'),
|
|
312
|
+
path.join(HOME, '.cache/ruvnet-brain/metaharness-receipts.jsonl'),
|
|
313
|
+
// Canonical user-level ledger (issue #36 — the hooks no longer scatter per-CWD copies).
|
|
314
|
+
path.join(HOME, '.cache/ruvnet-brain/token-ledger.jsonl'),
|
|
315
|
+
// Legacy location, still read so an existing user's history is not orphaned by the move.
|
|
316
|
+
path.join(REPO, 'plugin/scripts/.ruvnet-brain/token-ledger.jsonl'),
|
|
317
|
+
];
|
|
318
|
+
const receipts = [];
|
|
319
|
+
let baselineUsd = 0;
|
|
320
|
+
let skippedUnmeasured = 0; // rows with neither a $ nor a time saving — counted so labels can say so
|
|
321
|
+
for (const f of files) {
|
|
322
|
+
if (!fs.existsSync(f)) continue;
|
|
323
|
+
for (const line of fs.readFileSync(f, 'utf8').split('\n')) {
|
|
324
|
+
if (!line.trim()) continue;
|
|
325
|
+
const r = (() => { try { return JSON.parse(line); } catch { return null; } })();
|
|
326
|
+
if (!r) continue;
|
|
327
|
+
// MEASURED $ saved: explicit field, else frontier cost minus chosen cost.
|
|
328
|
+
let usd = Number(r.measuredUsd ?? r.savedUsd ?? r.usd ?? r.saved);
|
|
329
|
+
if (!Number.isFinite(usd) && Number.isFinite(Number(r.est_frontier_cost)) && Number.isFinite(Number(r.est_cost))) {
|
|
330
|
+
usd = Number(r.est_frontier_cost) - Number(r.est_cost);
|
|
331
|
+
}
|
|
332
|
+
// MEASURED time saved: explicit field, else baseline duration minus routed duration.
|
|
333
|
+
let ms = Number(r.measuredMs ?? r.savedMs ?? r.ms);
|
|
334
|
+
if (!Number.isFinite(ms) && Number.isFinite(Number(r.baseline_duration_ms)) && Number.isFinite(Number(r.duration_ms))) {
|
|
335
|
+
ms = Number(r.baseline_duration_ms) - Number(r.duration_ms);
|
|
336
|
+
}
|
|
337
|
+
if (!Number.isFinite(usd) && !Number.isFinite(ms)) { skippedUnmeasured += 1; continue; }
|
|
338
|
+
const base = Number(r.est_frontier_cost);
|
|
339
|
+
if (Number.isFinite(base)) baselineUsd += base;
|
|
340
|
+
receipts.push({
|
|
341
|
+
at: r.at ?? r.ts ?? null,
|
|
342
|
+
capability: r.capability ?? r.tool ?? r.source ?? 'routing',
|
|
343
|
+
task: r.task ?? r.label ?? '',
|
|
344
|
+
chosenTier: r.chosenTier ?? r.tier ?? r.model ?? '',
|
|
345
|
+
baselineTier: r.baselineTier ?? r.baseline ?? r.frontier_ref ?? '',
|
|
346
|
+
measuredMs: Number.isFinite(ms) ? ms : null,
|
|
347
|
+
measuredUsd: Number.isFinite(usd) ? usd : null,
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const usdSaved = +receipts.reduce((a, r) => a + (r.measuredUsd || 0), 0).toFixed(4);
|
|
352
|
+
const totals = receipts.length ? {
|
|
353
|
+
count: receipts.length,
|
|
354
|
+
usdSaved,
|
|
355
|
+
msSaved: receipts.reduce((a, r) => a + (r.measuredMs || 0), 0),
|
|
356
|
+
baselineUsd: +baselineUsd.toFixed(4),
|
|
357
|
+
pctSaved: baselineUsd > 0 ? Math.round((usdSaved / baselineUsd) * 100) : null,
|
|
358
|
+
} : null;
|
|
359
|
+
return { totals, note: 'receipts only — no modelled, projected, or “up to” savings', skippedUnmeasured, receipts: receipts.slice(-25).reverse() };
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// ── Config (user-level) ──────────────────────────────────────────────────────────────────────────
|
|
363
|
+
const CONFIG_SCHEMA = [
|
|
364
|
+
{ key: 'openrouterKey', label: 'OpenRouter API key', type: 'secret', secret: true, help: 'Unlocks cheap-model routing and the self-improvement loop. Stored only in your user folder.' },
|
|
365
|
+
{ key: 'provider', label: 'Your model house', type: 'enum', options: ['auto', 'anthropic', 'openai', 'codex', 'google', 'xai'], help: 'Which stack is yours? Sets your frontier model + savings baseline — Claude → Fable 5, ChatGPT → GPT-5.6 Sol, Codex → Sol, Gemini → 3.1 Pro, Grok → 4.5. “auto” detects from your keys.' },
|
|
366
|
+
{ key: 'nightly', label: 'Nightly brain refresh', type: 'bool', help: 'Rebuild the knowledge base from pinned versions overnight so answers stay current.' },
|
|
367
|
+
{ key: 'routing', label: 'Token-smart routing', type: 'enum', options: ['auto', 'off'], help: 'Send cheap, mechanical tasks to smaller, cheaper models automatically.' },
|
|
368
|
+
{ key: 'qeFleet', label: 'On-demand QE test fleet', type: 'bool', help: 'Let RuvNet Brain spin up an Agentic-QE test fleet when you ask it to.' },
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
// Every field below has a runtime owner. Platform-specific controls (currently the macOS nightly
|
|
372
|
+
// scheduler) are removed from the editable schema when that owner is unavailable and are reported
|
|
373
|
+
// honestly through `unavailable`.
|
|
374
|
+
const CONFIG_CONTROL_SUPPORT = Object.freeze({});
|
|
375
|
+
|
|
376
|
+
function unsupportedConfigControls() {
|
|
377
|
+
return CONFIG_SCHEMA
|
|
378
|
+
.filter((field) => Object.hasOwn(CONFIG_CONTROL_SUPPORT, field.key))
|
|
379
|
+
.map((field) => ({ key: field.key, label: field.label, reason: CONFIG_CONTROL_SUPPORT[field.key] }));
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* NOT-CHOSEN IS ITS OWN ANSWER, and collapsing it into "on" was this file's version of the exact lie
|
|
383
|
+
* the whole console was built to kill.
|
|
384
|
+
*
|
|
385
|
+
* `nightly: cfg.nightly !== false` and `routing: cfg.routing === 'off' ? 'off' : 'auto'` both derive
|
|
386
|
+
* ON from the ABSENCE of a key. On a machine with no config file at all — the empty-first case the
|
|
387
|
+
* bar names explicitly — that produced three contradictory answers to one question in a single render:
|
|
388
|
+
*
|
|
389
|
+
* Savings card -> green chip "✓ Smart routing: ON"
|
|
390
|
+
* Capabilities card -> "cheap-model-routing: absent — agentic-flow is not installed and no
|
|
391
|
+
* routing receipts exist"
|
|
392
|
+
* That card's own
|
|
393
|
+
* subtitle -> "Off by default — rUv would rather you chose it"
|
|
394
|
+
*
|
|
395
|
+
* Worse, these two keys are PREFERENCES, not switches: nothing outside this console reads either one
|
|
396
|
+
* (grepped — the only readers are gatherConfig and the savings CTA). So "ON" was not even reporting a
|
|
397
|
+
* setting that did something; it was reporting a default the user had never seen, about a feature
|
|
398
|
+
* that was not installed.
|
|
399
|
+
*
|
|
400
|
+
* `null` means the person has not chosen. It is rendered as "not chosen", never as on and never as
|
|
401
|
+
* off, and the Settings form shows the shipped default beside it as a recommendation rather than as
|
|
402
|
+
* a fact about their machine.
|
|
403
|
+
*/
|
|
404
|
+
function gatherConfig() {
|
|
405
|
+
const cfg = readJSON(CONFIG_PATH) || {};
|
|
406
|
+
const credential = openRouterCredentialStatus({ cwd: process.cwd() });
|
|
407
|
+
const schedule = nightlyStatus();
|
|
408
|
+
const bool = (v) => (v === true ? true : v === false ? false : null);
|
|
409
|
+
const unavailable = unsupportedConfigControls();
|
|
410
|
+
if (!schedule.artifact.supported) {
|
|
411
|
+
unavailable.push({
|
|
412
|
+
key: 'nightly',
|
|
413
|
+
label: 'Nightly brain refresh',
|
|
414
|
+
reason: schedule.evidence,
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
return {
|
|
418
|
+
path: CONFIG_PATH.replace(HOME, '~'),
|
|
419
|
+
exists: fs.existsSync(CONFIG_PATH),
|
|
420
|
+
values: {
|
|
421
|
+
openrouterKey: credential.configured, // boolean only — never the secret itself
|
|
422
|
+
provider: typeof cfg.provider === 'string' && cfg.provider ? cfg.provider : null,
|
|
423
|
+
nightly: schedule.state === 'on' ? true : schedule.state === 'off' ? false : null,
|
|
424
|
+
routing: cfg.routing === 'off' ? 'off' : cfg.routing === 'auto' ? 'auto' : null,
|
|
425
|
+
qeFleet: bool(cfg.qeFleet),
|
|
426
|
+
},
|
|
427
|
+
// What the project would pick FOR you, kept separate from what you actually picked. The form can
|
|
428
|
+
// then say "recommended: on" without ever claiming that is the current state.
|
|
429
|
+
defaults: { provider: 'auto', nightly: true, routing: 'auto', qeFleet: false },
|
|
430
|
+
schema: CONFIG_SCHEMA.filter((field) =>
|
|
431
|
+
!Object.hasOwn(CONFIG_CONTROL_SUPPORT, field.key)
|
|
432
|
+
&& (field.key !== 'nightly' || schedule.artifact.supported)),
|
|
433
|
+
unavailable,
|
|
434
|
+
runtime: {
|
|
435
|
+
openrouterKey: credential,
|
|
436
|
+
nightly: schedule,
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/**
|
|
442
|
+
* ── The advocacy dial (user-settings.mjs) — a SEPARATE store from config.json, on purpose ─────────
|
|
443
|
+
*
|
|
444
|
+
* `advocacy` lives in ~/.config/ruvnet-brain/settings.json (user-settings.mjs STORE_PATH), not this
|
|
445
|
+
* console's own CONFIG_PATH — because anticipate.sh, the one emitter that gates on it, reads that
|
|
446
|
+
* exact file. Folding it into CONFIG_SCHEMA/saveConfig would give the console its own copy of the
|
|
447
|
+
* value, free to drift from the one the emitter actually reads. So this reads and writes through
|
|
448
|
+
* user-settings.mjs's own `loadSettings`/`saveSettings` — the same functions its CLI (`node
|
|
449
|
+
* user-settings.mjs`) and its test suite already exercise — rather than growing a second writer.
|
|
450
|
+
*
|
|
451
|
+
* All four ordinary user preferences are served now. Their consumers are: learning capture/flush,
|
|
452
|
+
* the advocacy hook, the console's guarded remedy loop, and SessionStart project-default seeding.
|
|
453
|
+
*/
|
|
454
|
+
const LIVE_USER_SETTING_KEYS = Object.freeze(['learningScope', 'advocacy', 'autoApply', 'newProjectDefaults']);
|
|
455
|
+
const LIVE_USER_FIELDS = USER_SETTINGS_SCHEMA.filter((field) => LIVE_USER_SETTING_KEYS.includes(field.key));
|
|
456
|
+
|
|
457
|
+
function gatherAdvocacy() {
|
|
458
|
+
const state = loadSettings(); // validated: respects RUVNET_SETTINGS_FILE, degrades on corrupt/future files
|
|
459
|
+
// NOT-CHOSEN IS ITS OWN ANSWER — same rule gatherConfig() applies above. loadSettings() always hands
|
|
460
|
+
// back a COMPLETE values object (defaults filled in for any key the file never mentions), so the only
|
|
461
|
+
// way to tell "the user picked the default on purpose" apart from "the user never touched this key"
|
|
462
|
+
// is to peek at what was actually written, the same way gatherConfig() reads CONFIG_PATH raw.
|
|
463
|
+
const raw = readJSON(state.path);
|
|
464
|
+
const chosen = raw && typeof raw === 'object' && raw.settings && typeof raw.settings === 'object'
|
|
465
|
+
? raw.settings : {};
|
|
466
|
+
return {
|
|
467
|
+
path: state.path.replace(HOME, '~'),
|
|
468
|
+
exists: state.exists,
|
|
469
|
+
values: Object.fromEntries(LIVE_USER_FIELDS.map((field) => [
|
|
470
|
+
field.key,
|
|
471
|
+
Object.hasOwn(chosen, field.key) ? state.values[field.key] : null,
|
|
472
|
+
])),
|
|
473
|
+
defaults: Object.fromEntries(LIVE_USER_FIELDS.map((field) => [field.key, field.default])),
|
|
474
|
+
schema: LIVE_USER_FIELDS,
|
|
475
|
+
unavailable: [],
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* SAVE — through user-settings.mjs's own `saveSettings`, never a hand-rolled second writer. That
|
|
481
|
+
* function already owns the lock, the atomic rename and the backup-before-write for this exact file;
|
|
482
|
+
* re-implementing any of it here would be the precise duplication saveConfig's own header warns about.
|
|
483
|
+
*
|
|
484
|
+
* Takes a `values` object, the SAME shape saveConfig() takes, so the console's settings form can post
|
|
485
|
+
* to either endpoint with identical client code — only the URL and the target file differ.
|
|
486
|
+
*/
|
|
487
|
+
function saveAdvocacy(values) {
|
|
488
|
+
const supplied = values && typeof values === 'object'
|
|
489
|
+
? Object.fromEntries(Object.entries(values).filter(([key]) => LIVE_USER_SETTING_KEYS.includes(key)))
|
|
490
|
+
: {};
|
|
491
|
+
if (!Object.keys(supplied).length) {
|
|
492
|
+
return { ok: false, log: 'nothing was saved — no recognised settings were supplied' };
|
|
493
|
+
}
|
|
494
|
+
const rejected = [];
|
|
495
|
+
for (const [key, value] of Object.entries(supplied)) {
|
|
496
|
+
const field = LIVE_USER_FIELDS.find((candidate) => candidate.key === key);
|
|
497
|
+
if (field.type === 'bool' && typeof value !== 'boolean') {
|
|
498
|
+
rejected.push({ key, reason: `expected true or false, got ${JSON.stringify(value)}` });
|
|
499
|
+
} else if (field.type === 'enum' && !field.options.includes(value)) {
|
|
500
|
+
rejected.push({ key, reason: `expected one of ${field.options.join(', ')}, got ${JSON.stringify(value)}` });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (rejected.length) {
|
|
504
|
+
return {
|
|
505
|
+
ok: false,
|
|
506
|
+
rejected,
|
|
507
|
+
log: `nothing was saved — ${rejected.map((entry) => `${entry.key}: ${entry.reason}`).join('; ')}`,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
const result = saveSettings(supplied);
|
|
511
|
+
if (!result.ok) return { ok: false, rejected: result.errors || [], log: result.log };
|
|
512
|
+
publishSettingsToCache();
|
|
513
|
+
return {
|
|
514
|
+
ok: true,
|
|
515
|
+
backup: result.backup ? result.backup.replace(HOME, '~') : null,
|
|
516
|
+
values: Object.fromEntries(LIVE_USER_SETTING_KEYS.map((key) => [key, result.values[key]])),
|
|
517
|
+
log: result.log,
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* ── THE MASTER SWITCH (ADR-054) — its OWN section, deliberately not folded into the dial above ───
|
|
523
|
+
*
|
|
524
|
+
* `brainEnabled` lives in the SAME settings.json as `advocacy`, but it is served and saved
|
|
525
|
+
* separately, and the separation is the design rather than an accident of growth:
|
|
526
|
+
*
|
|
527
|
+
* 1. IT IS NOT A SETTING, IT IS A SWITCH. The enforcement artifact is the sentinel file
|
|
528
|
+
* (scripts/brain-state.mjs); the settings key is a MIRROR kept so the choice is visible where a
|
|
529
|
+
* user looks for their choices. Saving it therefore has to write TWO things, and a save path
|
|
530
|
+
* that writes two things must not be the same one that writes the ordinary dials — the moment
|
|
531
|
+
* it is, an unrelated dial save starts touching the switch.
|
|
532
|
+
* 2. THE TWO CAN LEGITIMATELY DISAGREE (an older release drops the mirror key — see gate test 1),
|
|
533
|
+
* so this section carries `disagreement` and the ordinary dial section has nothing like it.
|
|
534
|
+
* 3. IT MUST DISCLOSE WHAT KEEPS RUNNING. `notes` carries the maintenance-continues line, because
|
|
535
|
+
* the one thing worse than background work while "off" is UNDISCLOSED background work — GPT-5.6's
|
|
536
|
+
* half of the duel's single genuine disagreement.
|
|
537
|
+
*
|
|
538
|
+
* Same widget, same consent gate, same save/undo handling as the advocacy dial (the page renders it
|
|
539
|
+
* through the shared buildSettingsForm), just a different endpoint and a different store semantics.
|
|
540
|
+
*/
|
|
541
|
+
const BRAIN_FIELD = USER_SETTINGS_SCHEMA.find((s) => s.key === 'brainEnabled');
|
|
542
|
+
const BRAIN_PROFILE_FIELD = USER_SETTINGS_SCHEMA.find((s) => s.key === 'brainProfile');
|
|
543
|
+
|
|
544
|
+
function gatherBrainProfile() {
|
|
545
|
+
const settings = loadSettings();
|
|
546
|
+
const installed = measureBrainProfile(INSTALLED_KB);
|
|
547
|
+
const actual = !installed.stores.includes(PROFILE_RUVECTOR)
|
|
548
|
+
? null
|
|
549
|
+
: installed.stores.length === 1
|
|
550
|
+
? PROFILE_RUVECTOR
|
|
551
|
+
: PROFILE_COMPLETE;
|
|
552
|
+
const source = measureBrainProfile(COMPLETE_BRAIN_SOURCE);
|
|
553
|
+
const updaterAvailable = fs.existsSync(path.join(INSTALLED_KB, 'forge-update.mjs'));
|
|
554
|
+
return {
|
|
555
|
+
path: INSTALLED_KB.replace(HOME, '~'),
|
|
556
|
+
values: { brainProfile: actual },
|
|
557
|
+
stored: settings.values.brainProfile,
|
|
558
|
+
disagreement: settings.values.brainProfile !== actual,
|
|
559
|
+
defaults: { brainProfile: BRAIN_PROFILE_FIELD.default },
|
|
560
|
+
schema: [BRAIN_PROFILE_FIELD],
|
|
561
|
+
installed,
|
|
562
|
+
choices: {
|
|
563
|
+
complete: {
|
|
564
|
+
available: (source.stores.includes(PROFILE_RUVECTOR) && source.storeCount > 1)
|
|
565
|
+
|| updaterAvailable,
|
|
566
|
+
storeCount: source.storeCount,
|
|
567
|
+
bytes: source.bytes,
|
|
568
|
+
},
|
|
569
|
+
ruvector: {
|
|
570
|
+
available: installed.stores.includes(PROFILE_RUVECTOR)
|
|
571
|
+
|| source.stores.includes(PROFILE_RUVECTOR),
|
|
572
|
+
storeCount: 1,
|
|
573
|
+
bytes: installed.byStore.ruvector ?? source.byStore.ruvector ?? null,
|
|
574
|
+
},
|
|
575
|
+
},
|
|
576
|
+
restoreSource: COMPLETE_BRAIN_SOURCE.replace(HOME, '~'),
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function gatherBrainPower() {
|
|
581
|
+
const state = readOffState();
|
|
582
|
+
const settings = loadSettings();
|
|
583
|
+
return {
|
|
584
|
+
off: state.off,
|
|
585
|
+
since: state.since,
|
|
586
|
+
reason: state.reason,
|
|
587
|
+
switchPath: state.path.replace(HOME, '~'),
|
|
588
|
+
// The RESOLVED answer — what the machine actually does — not the mirror's opinion of it.
|
|
589
|
+
values: { brainEnabled: !state.off },
|
|
590
|
+
defaults: { brainEnabled: BRAIN_FIELD.default },
|
|
591
|
+
schema: [BRAIN_FIELD],
|
|
592
|
+
profile: gatherBrainProfile(),
|
|
593
|
+
disagreement: disagreement(settings.values.brainEnabled),
|
|
594
|
+
// Stated on the surface, not buried in a doc. Every line here is a thing that KEEPS HAPPENING
|
|
595
|
+
// while the brain is off; if one of them ever stops being true, this list is what has to change.
|
|
596
|
+
notes: state.off
|
|
597
|
+
? [
|
|
598
|
+
'Still running while off: version updates, the health alarm, and the open-issue banner — an off machine has to be able to receive the fix for an off-state bug.',
|
|
599
|
+
'Stopped while off: retrieval from rUv\'s source, the grounding gate on your write path, everything the brain volunteers, and learning from this session.',
|
|
600
|
+
'Already-running Claude Code and Codex sessions pick this up on their next hook or next search; a tool DESCRIPTION they cached at startup refreshes at their next restart.',
|
|
601
|
+
]
|
|
602
|
+
: [
|
|
603
|
+
'While the brain is on it retrieves from rUv\'s real source before answering, and its hooks watch your write path.',
|
|
604
|
+
'Switching it off stops retrieval, the grounding gate, everything it volunteers, and learning — updates and health alarms keep running, and you can pause those separately.',
|
|
605
|
+
],
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function saveBrainProfile(values) {
|
|
610
|
+
const profile = values && typeof values === 'object' ? values.brainProfile : undefined;
|
|
611
|
+
if (![PROFILE_COMPLETE, PROFILE_RUVECTOR].includes(profile)) {
|
|
612
|
+
const reason = `expected complete or ruvector, got ${JSON.stringify(profile)}`;
|
|
613
|
+
return { ok: false, rejected: [{ key: 'brainProfile', reason }], log: `nothing was changed — ${reason}` };
|
|
614
|
+
}
|
|
615
|
+
const before = measureBrainProfile(INSTALLED_KB);
|
|
616
|
+
if (!before.stores.includes(PROFILE_RUVECTOR)) {
|
|
617
|
+
return { ok: false, log: `nothing was changed — no RuVector RVF store exists in ${INSTALLED_KB}` };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
let changed;
|
|
621
|
+
try {
|
|
622
|
+
if (profile === PROFILE_RUVECTOR) {
|
|
623
|
+
changed = applyBrainProfile(INSTALLED_KB, profile);
|
|
624
|
+
} else {
|
|
625
|
+
const localComplete = measureBrainProfile(COMPLETE_BRAIN_SOURCE);
|
|
626
|
+
if (localComplete.storeCount > 1) {
|
|
627
|
+
changed = restoreCompleteProfile(INSTALLED_KB, COMPLETE_BRAIN_SOURCE);
|
|
628
|
+
} else {
|
|
629
|
+
const updater = path.join(INSTALLED_KB, 'forge-update.mjs');
|
|
630
|
+
if (!fs.existsSync(updater)) {
|
|
631
|
+
throw new Error('the complete release is not cached here and forge-update.mjs is unavailable');
|
|
632
|
+
}
|
|
633
|
+
const restored = spawnSync(process.execPath, [
|
|
634
|
+
updater,
|
|
635
|
+
'--apply',
|
|
636
|
+
'--restore-complete',
|
|
637
|
+
PROFILE_RUVECTOR,
|
|
638
|
+
], {
|
|
639
|
+
cwd: INSTALLED_KB,
|
|
640
|
+
env: process.env,
|
|
641
|
+
encoding: 'utf8',
|
|
642
|
+
timeout: 30 * 60 * 1000,
|
|
643
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
644
|
+
});
|
|
645
|
+
if (restored.status !== 0) {
|
|
646
|
+
const detail = String(restored.stderr || restored.stdout || `exit ${restored.status}`).trim().slice(-1200);
|
|
647
|
+
throw new Error(`signed complete-bundle restore failed: ${detail}`);
|
|
648
|
+
}
|
|
649
|
+
changed = { profile: PROFILE_COMPLETE, stores: discoverStoreFamilies(INSTALLED_KB) };
|
|
650
|
+
if (changed.stores.length < 2) {
|
|
651
|
+
throw new Error('the signed updater completed but the complete repository stores did not land');
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
} catch (error) {
|
|
656
|
+
return { ok: false, log: `nothing was changed — ${error.message}` };
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const mirrored = saveSettings({ brainProfile: profile });
|
|
660
|
+
publishBrainPowerToCache();
|
|
661
|
+
return {
|
|
662
|
+
ok: true,
|
|
663
|
+
profile,
|
|
664
|
+
values: { brainProfile: profile },
|
|
665
|
+
stores: changed.stores,
|
|
666
|
+
removed: changed.removed || [],
|
|
667
|
+
bytesFreed: changed.bytesFreed || 0,
|
|
668
|
+
mirrored: mirrored.ok,
|
|
669
|
+
backup: mirrored.backup ? mirrored.backup.replace(HOME, '~') : null,
|
|
670
|
+
log: mirrored.ok
|
|
671
|
+
? (profile === PROFILE_RUVECTOR
|
|
672
|
+
? `RuVector Only is active — ${changed.removed.length} unselected artifact(s) removed`
|
|
673
|
+
: `Complete Brain is active — ${changed.stores.length} repository stores available`)
|
|
674
|
+
: `${profile === PROFILE_RUVECTOR ? 'RuVector Only' : 'Complete Brain'} is active on disk, but the settings mirror could not be updated (${mirrored.log})`,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* PUSH THE NEW SWITCH POSITION INTO THE STATE CACHE, IMMEDIATELY.
|
|
680
|
+
*
|
|
681
|
+
* FOUND BY A LIVE HTTP SMOKE, not by a unit test, and it is worth saying which: every unit
|
|
682
|
+
* assertion around saveBrainPower() passed while the real server, queried over real HTTP one second
|
|
683
|
+
* after a successful `off` save, answered `off: false`. `/api/state` is cache-first by design (the
|
|
684
|
+
* 2026-07-17 outage bargain: never compute inline on a request), and nothing invalidated the cache
|
|
685
|
+
* on this write — so the page's own master switch would have rendered ON for a machine that was OFF
|
|
686
|
+
* until a background refresh happened to land. That is the exact class of statement this console
|
|
687
|
+
* exists to make impossible.
|
|
688
|
+
*
|
|
689
|
+
* PATCH, then back-date — not `expireCachesEmbedding`, and both halves are deliberate:
|
|
690
|
+
* • PATCH the value, because for THIS field "stale" is not an acceptable stand-in for "wrong".
|
|
691
|
+
* Back-dating alone still hands the next reader `off: false`, merely labelled old.
|
|
692
|
+
* • BACK-DATE anyway, so the record is honestly marked as a measurement due for replacement and
|
|
693
|
+
* serveCached's kickRefresh() produces a wholly fresh one. Staleness here is caused by a WRITE,
|
|
694
|
+
* not by time passing — the doctrine expireCachesEmbedding's own header states.
|
|
695
|
+
* • NOT the shared helper: it calls writeCache WITHOUT a scope, and STATE_CACHE is project-scoped.
|
|
696
|
+
* Dropping the scope would make the next read a scope MISMATCH, which takes the COLD path and
|
|
697
|
+
* computes inline — reintroducing the multi-second freeze on the very next page load.
|
|
698
|
+
*/
|
|
699
|
+
function publishBrainPowerToCache() {
|
|
700
|
+
try {
|
|
701
|
+
const c = readJSON(STATE_CACHE);
|
|
702
|
+
if (!c || !c.data || !c.data.sections) return; // nothing cached yet — the next read is cold and correct
|
|
703
|
+
c.data.sections.brainPower = gatherBrainPower();
|
|
704
|
+
writeCache(STATE_CACHE, new Date(0).toISOString(), c.data, c.scope ?? null);
|
|
705
|
+
} catch { /* a cache we cannot rewrite is one the next refresh replaces anyway */ }
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
* Publish the two ordinary settings read-models immediately after their writers succeed.
|
|
710
|
+
*
|
|
711
|
+
* `/api/state` is intentionally cache-first, so a successful save followed by reload otherwise
|
|
712
|
+
* repaints the previous choices until a background measurement lands. A live browser test caught
|
|
713
|
+
* exactly that failure for provider and advocacy. Patch the fields whose authoritative stores were
|
|
714
|
+
* just written, retain the project scope, and withdraw the surrounding measurement so the detached
|
|
715
|
+
* refresh still replaces every other section.
|
|
716
|
+
*/
|
|
717
|
+
function publishSettingsToCache() {
|
|
718
|
+
try {
|
|
719
|
+
const c = readJSON(STATE_CACHE);
|
|
720
|
+
if (!c || !c.data || !c.data.sections) return;
|
|
721
|
+
c.data.sections.config = gatherConfig();
|
|
722
|
+
c.data.sections.userSettings = gatherAdvocacy();
|
|
723
|
+
writeCache(STATE_CACHE, new Date(0).toISOString(), c.data, c.scope ?? null);
|
|
724
|
+
} catch { /* the authoritative stores are already correct; refresh will replace an unreadable cache */ }
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* SAVE — the sentinel FIRST, the mirror second, and the receipt tells the truth about both.
|
|
729
|
+
*
|
|
730
|
+
* Order matters and is not arbitrary. The sentinel is what every reader enforces from; the mirror is
|
|
731
|
+
* a record. If the mirror write fails after the switch has flipped, the machine is in the state the
|
|
732
|
+
* user asked for and one display is stale — recoverable, and reported. If it were the other way
|
|
733
|
+
* round, a failed sentinel write would leave a settings file claiming a state the machine is not in,
|
|
734
|
+
* which is the console showing a toggle wired to nothing.
|
|
735
|
+
*
|
|
736
|
+
* The mirror goes through user-settings.mjs's own saveSettings — same lock, same atomic rename, same
|
|
737
|
+
* backup-before-write as every other key. No second writer.
|
|
738
|
+
*/
|
|
739
|
+
function saveBrainPower(values) {
|
|
740
|
+
const value = values && typeof values === 'object' ? values.brainEnabled : undefined;
|
|
741
|
+
if (value === undefined) return { ok: false, log: 'nothing was saved — no recognised settings were supplied' };
|
|
742
|
+
if (typeof value !== 'boolean') {
|
|
743
|
+
const reason = `expected true or false, got ${JSON.stringify(value)}`;
|
|
744
|
+
return { ok: false, rejected: [{ key: 'brainEnabled', reason }], log: `nothing was saved — brainEnabled: ${reason}` };
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
const flipped = value ? setBrainOn() : setBrainOff('switched off from the console');
|
|
748
|
+
if (!flipped.ok) return { ok: false, log: `nothing was changed — ${flipped.log}` };
|
|
749
|
+
|
|
750
|
+
const mirrored = saveSettings({ brainEnabled: value });
|
|
751
|
+
const state = readOffState();
|
|
752
|
+
publishBrainPowerToCache();
|
|
753
|
+
return {
|
|
754
|
+
ok: true,
|
|
755
|
+
off: state.off,
|
|
756
|
+
since: state.since,
|
|
757
|
+
backup: mirrored.backup ? mirrored.backup.replace(HOME, '~') : null,
|
|
758
|
+
values: { brainEnabled: !state.off },
|
|
759
|
+
// Honest about the half-failure rather than reporting a clean success: the switch is what counts
|
|
760
|
+
// and it moved, but say so plainly if the visible record did not follow it.
|
|
761
|
+
log: mirrored.ok
|
|
762
|
+
? (value ? 'the brain is on' : 'the brain is off — updates and health alarms keep running')
|
|
763
|
+
: `the brain is ${value ? 'on' : 'off'}, but your settings file could not be updated to match (${mirrored.log})`,
|
|
764
|
+
mirrored: mirrored.ok,
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* ── LESSONS: the surface the store was written for and never got ────────────────────────────────
|
|
770
|
+
*
|
|
771
|
+
* lesson-store.mjs:391 says of `pending()`: "what the management surface must show first." There was
|
|
772
|
+
* no management surface. Sixteen lessons — thirteen of them the owner's own words, one of them
|
|
773
|
+
* enforcing at BLOCK level — lived in a JSON file with a CLI over it, which is the owner's exact
|
|
774
|
+
* complaint: "murky things in a .claude file nobody sees." A rule you cannot SEE is a rule you
|
|
775
|
+
* cannot consent to, and an unconsented rule that blocks your work is the fastest route to someone
|
|
776
|
+
* deleting the whole product.
|
|
777
|
+
*
|
|
778
|
+
* Two honesty constraints, both learned the hard way in this repo:
|
|
779
|
+
*
|
|
780
|
+
* 1. NO JARGON IN THE PRIMARY LINE. `origin: 'user-stated'` renders as "you taught me this";
|
|
781
|
+
* `enforcement: 'block'` renders as what it DOES to you, not what it is called internally.
|
|
782
|
+
* 2. THE STATE IS READ, NEVER ASSERTED. Every field below comes from the store on this request.
|
|
783
|
+
*/
|
|
784
|
+
const TRIGGER_BY_KEY = new Map(Object.values(TRIGGERS).map((t) => [t.key, t]));
|
|
785
|
+
|
|
786
|
+
// What each enforcement level actually DOES to the user — the info-bubble text. Written as
|
|
787
|
+
// consequence-to-you, because "checklist" is a word about our implementation, not about their day.
|
|
788
|
+
const ENFORCEMENT_MEANING = {
|
|
789
|
+
block: { label: 'Stops me', detail: 'I am interrupted at this moment and cannot continue until the check passes. This is the strongest level, and only lessons you stated yourself can reach it.' },
|
|
790
|
+
checklist: { label: 'Checklist', detail: 'I get a checklist item I have to tick at this moment. It does not stop me — it makes skipping it a visible choice rather than an accident.' },
|
|
791
|
+
review: { label: 'Reminder', detail: 'I am reminded at this moment. No stop, no checklist — it shapes what I pay attention to.' },
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
function gatherLessons() {
|
|
795
|
+
let all;
|
|
796
|
+
try { all = loadLessons(); }
|
|
797
|
+
catch (e) {
|
|
798
|
+
// TASK 3: stamped at the read that just failed, not before it was attempted.
|
|
799
|
+
return { ok: false, error: String(e && e.message || e), lessons: [], counts: null, ...freshnessOf(new Date().toISOString()) };
|
|
800
|
+
}
|
|
801
|
+
// TASK 3: the observation instant is loadLessons() finishing, right above — not whenever the
|
|
802
|
+
// .map()/.sort() derived-computation below happens to finish building the response.
|
|
803
|
+
const measuredAt = new Date().toISOString();
|
|
804
|
+
|
|
805
|
+
const lessons = all.map((l) => {
|
|
806
|
+
const trig = TRIGGER_BY_KEY.get(l.trigger);
|
|
807
|
+
const meaning = ENFORCEMENT_MEANING[l.enforcement] || { label: l.enforcement, detail: '' };
|
|
808
|
+
const userStated = l.origin === ORIGIN.USER_STATED;
|
|
809
|
+
return {
|
|
810
|
+
id: l.id,
|
|
811
|
+
statement: l.statement,
|
|
812
|
+
// The moment it fires, in the second person. This is the load-bearing column: a lesson with no
|
|
813
|
+
// observable moment is prose, and the store refuses to construct one (lesson-store.mjs:131).
|
|
814
|
+
when: trig ? `when I'm ${trig.label}` : '(no trigger — this lesson cannot fire)',
|
|
815
|
+
surface: trig ? trig.surface : null,
|
|
816
|
+
enforcement: l.enforcement,
|
|
817
|
+
enforcementLabel: meaning.label,
|
|
818
|
+
enforcementDetail: meaning.detail,
|
|
819
|
+
// Provenance drives trust, so it is stated plainly and never flattened into a badge colour.
|
|
820
|
+
origin: userStated ? 'you taught me this' : 'I inferred this from what happened',
|
|
821
|
+
userStated,
|
|
822
|
+
taughtCount: l.repeatCount || 0,
|
|
823
|
+
projects: Array.isArray(l.projects) ? l.projects : [],
|
|
824
|
+
evidence: l.evidence || null,
|
|
825
|
+
weight: Number(weightOf(l).toFixed(4)),
|
|
826
|
+
status: l.status,
|
|
827
|
+
ratified: l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE,
|
|
828
|
+
demoted: !!l.demoted,
|
|
829
|
+
// The one thing the user is being ASKED, as opposed to merely shown.
|
|
830
|
+
awaitingYou: l.status === STATUS.CANDIDATE && !l.demoted,
|
|
831
|
+
// Honest ceiling: ratifying a model-inferred lesson can NOT raise it to block
|
|
832
|
+
// (lesson-store.mjs:380). Say so before they click, not after.
|
|
833
|
+
canReachBlock: userStated,
|
|
834
|
+
intendedEnforcement: l.intendedEnforcement || null,
|
|
835
|
+
};
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
// Highest-consequence first — blast radius, not alphabetical. Something that STOPS me outranks a
|
|
839
|
+
// reminder; among equals, the one I have taught most often.
|
|
840
|
+
const rank = { block: 0, checklist: 1, review: 2 };
|
|
841
|
+
lessons.sort((a, b) => {
|
|
842
|
+
if (a.awaitingYou !== b.awaitingYou) return a.awaitingYou ? -1 : 1;
|
|
843
|
+
if (a.demoted !== b.demoted) return a.demoted ? 1 : -1;
|
|
844
|
+
const r = (rank[a.enforcement] ?? 9) - (rank[b.enforcement] ?? 9);
|
|
845
|
+
if (r) return r;
|
|
846
|
+
return b.taughtCount - a.taughtCount;
|
|
847
|
+
});
|
|
848
|
+
|
|
849
|
+
return {
|
|
850
|
+
ok: true,
|
|
851
|
+
lessons,
|
|
852
|
+
counts: {
|
|
853
|
+
total: lessons.length,
|
|
854
|
+
active: lessons.filter((l) => l.ratified && !l.demoted).length,
|
|
855
|
+
awaitingYou: lessons.filter((l) => l.awaitingYou).length,
|
|
856
|
+
off: lessons.filter((l) => l.demoted).length,
|
|
857
|
+
blocking: lessons.filter((l) => l.enforcement === 'block' && l.ratified && !l.demoted).length,
|
|
858
|
+
},
|
|
859
|
+
// TASK 2: this endpoint bypasses serveCached entirely and had NO timestamp of any kind. It is
|
|
860
|
+
// never cached — loadLessons() reads the live file on every call — so this is always fresh, but
|
|
861
|
+
// said so through the SAME envelope every other card uses rather than a bespoke "no age" shape.
|
|
862
|
+
...freshnessOf(measuredAt),
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The three user verbs, each one an existing lesson-store function. `updateLessons` re-reads the
|
|
868
|
+
* store inside its own transform, so a second console session cannot clobber this one — the same
|
|
869
|
+
* property saveConfig gets from withLock, obtained here from the store rather than re-built.
|
|
870
|
+
*/
|
|
871
|
+
const LESSON_ACTIONS = {
|
|
872
|
+
ratify: { fn: ratify, past: 'turned on' },
|
|
873
|
+
demote: { fn: demote, past: 'turned off' },
|
|
874
|
+
restore: { fn: restore, past: 'restored' },
|
|
875
|
+
};
|
|
876
|
+
|
|
877
|
+
function setLesson(body) {
|
|
878
|
+
const id = body && typeof body.id === 'string' ? body.id : null;
|
|
879
|
+
const action = body && typeof body.action === 'string' ? body.action : null;
|
|
880
|
+
if (!id) return { ok: false, log: 'nothing changed — no lesson id supplied' };
|
|
881
|
+
const spec = LESSON_ACTIONS[action];
|
|
882
|
+
if (!spec) {
|
|
883
|
+
return { ok: false, log: `nothing changed — action must be one of ${Object.keys(LESSON_ACTIONS).join(', ')}, got ${JSON.stringify(action)}` };
|
|
884
|
+
}
|
|
885
|
+
const before = loadLessons().find((l) => l.id === id);
|
|
886
|
+
if (!before) return { ok: false, log: `nothing changed — no lesson with id ${id}` };
|
|
887
|
+
|
|
888
|
+
try {
|
|
889
|
+
updateLessons((fresh) => spec.fn(id, fresh));
|
|
890
|
+
} catch (e) {
|
|
891
|
+
return { ok: false, log: `could not save: ${String(e && e.message || e)}` };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// THE WRITE LANDED, SO EVERY CACHE THAT SPEAKS ABOUT LESSONS IS NOW WRONG.
|
|
895
|
+
//
|
|
896
|
+
// `capability-registry.mjs` derives two rows from this exact store — `lessons-in-force` (it reads
|
|
897
|
+
// ratified-vs-candidate counts) and `cross-project-lessons`. Left alone, the capabilities card
|
|
898
|
+
// would keep asserting the pre-click state for up to a full ceiling, one card away from the lessons
|
|
899
|
+
// card showing the truth, on the same screen. That is the original two-day-old incident in
|
|
900
|
+
// miniature, and this time WE would have caused it.
|
|
901
|
+
//
|
|
902
|
+
// Expired, not deleted — see expireCachesEmbedding for why deleting would resurrect the hang.
|
|
903
|
+
expireCachesEmbedding([CAPABILITY_CACHE]);
|
|
904
|
+
|
|
905
|
+
const after = loadLessons().find((l) => l.id === id);
|
|
906
|
+
// Report what MOVED, read back from disk. An "ok" that was never re-read is the failure mode
|
|
907
|
+
// user-settings.mjs was built to end: every writer returned ok:true while losing the write.
|
|
908
|
+
return {
|
|
909
|
+
ok: true,
|
|
910
|
+
id,
|
|
911
|
+
action,
|
|
912
|
+
log: `${id} ${spec.past}.`,
|
|
913
|
+
now: after ? { status: after.status, enforcement: after.enforcement, demoted: !!after.demoted } : null,
|
|
914
|
+
was: { status: before.status, enforcement: before.enforcement, demoted: !!before.demoted },
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
// ── Brain activity read-model (ADR-0018) — read-only, file reads + sqlite3 CLI only ──────────────
|
|
919
|
+
// Fleet scan is cached: ~50-100 stores × a CLI spawn each is fine once, not per poll.
|
|
920
|
+
// 2026-07-17 (Stuart: "work faster" — measured 49s cold vs 1.8s warm): the cache PERSISTS to disk and
|
|
921
|
+
// hydrates at boot, so a fresh server paints real data instantly with its honest "scanned at HH:MM"
|
|
922
|
+
// stamp. 2026-07-26 (RVBC-INSTANT-SPEC #5): the SCAN ITSELF now runs only in the detached
|
|
923
|
+
// --refresh-cache child. A request never scans — not inline on a first-ever run, and not via
|
|
924
|
+
// setImmediate either (deferring synchronous work still blocks the loop when it runs). A machine with
|
|
925
|
+
// no fleet scan yet is reported `warming`, never as a fabricated zero.
|
|
926
|
+
let ACTIVITY_MACHINE_CACHE = null;
|
|
927
|
+
const CONSOLE_CACHE_PATH = path.join(HOME, '.cache/ruvnet-brain/console-cache.json');
|
|
928
|
+
|
|
929
|
+
// ── Warm-cache serving (2026-07-17, the demo-hang fix) ─────────────────────────────────────────────
|
|
930
|
+
// Every read-model here does multi-second synchronous work: gatherState ~13s, gatherStack ~22s,
|
|
931
|
+
// scanFleet ~40s+ (each opens 100+ SQLite stores or walks ~/Code). Node is single-threaded, so a
|
|
932
|
+
// SINGLE inline compute freezes the WHOLE server — which is exactly why fresh loads returned nothing
|
|
933
|
+
// (curl saw 000) roughly one request in three while a scan held the event loop. setImmediate does
|
|
934
|
+
// NOT help: deferring synchronous work still blocks the loop when it finally runs.
|
|
935
|
+
// The fix: the request handler NEVER computes inline once a cache exists. It serves the last cache
|
|
936
|
+
// (instant, a file read) and kicks a DETACHED CHILD PROCESS (`--refresh-cache`) to recompute off the
|
|
937
|
+
// server's event loop entirely. A truly cold machine (no cache at all) eats ONE inline compute to
|
|
938
|
+
// seed the cache, then is warm forever. Caches persist across restarts, so cold is rare.
|
|
939
|
+
const STATE_CACHE = path.join(CONFIG_DIR, 'state-cache.json');
|
|
940
|
+
const STACK_CACHE = path.join(CONFIG_DIR, 'stack-audit-cache.json');
|
|
941
|
+
const MEMORY_CACHE = path.join(CONFIG_DIR, 'memory-cache.json');
|
|
942
|
+
const CAPABILITY_CACHE = path.join(CONFIG_DIR, 'capability-cache.json');
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* READ-AFTER-WRITE INVALIDATION — the hole a wall clock cannot close.
|
|
946
|
+
*
|
|
947
|
+
* Fable 5, 2026-07-24: age-based freshness gives you "stale by at most N minutes", which is NOT the
|
|
948
|
+
* product's promise. The promise is that it never lies about your machine. The gap is exact and
|
|
949
|
+
* demonstrable: the user toggles a lesson; `/api/lessons` re-reads live and tells the truth; and
|
|
950
|
+
* `/api/capabilities` goes on serving its `lessons-in-force` row from a cache that is under the
|
|
951
|
+
* ceiling, correctly stamped, fully compliant with the new freshness contract — and false, on the
|
|
952
|
+
* same screen, one card away, **caused by the user's own click.**
|
|
953
|
+
*
|
|
954
|
+
* That is the ORIGINAL incident (a cache speaking over the lesson store) reappearing inside the fix
|
|
955
|
+
* written for it. No ceiling short of zero closes it, because the staleness is not caused by time
|
|
956
|
+
* passing — it is caused by a write.
|
|
957
|
+
*
|
|
958
|
+
* EXPIRE, DO NOT DELETE. The obvious move is `unlink`. That would be a bug: with no cache file the
|
|
959
|
+
* next request takes the COLD path, which computes inline — reintroducing the 13-49s server freeze
|
|
960
|
+
* fixed one commit ago. Instead we back-date the stamp. The next reader gets the old value marked
|
|
961
|
+
* `stale: true` with an honest age (fast, non-blocking) and the detached refresher replaces it. The
|
|
962
|
+
* claim is withdrawn the instant the user's write lands, without any request paying for it.
|
|
963
|
+
*
|
|
964
|
+
* PRECISION IS PART OF THE CONTRACT: expire only caches whose payload actually embeds the mutated
|
|
965
|
+
* fact. Blanket-expiring everything would be cheap to write and would turn every toggle into a
|
|
966
|
+
* machine-wide re-scan, which is how a correctness fix becomes a performance complaint.
|
|
967
|
+
*/
|
|
968
|
+
/**
|
|
969
|
+
* The capability read-model, computed in ONE place because it has TWO writers.
|
|
970
|
+
*
|
|
971
|
+
* It was inline in the `/api/capabilities` handler, and the background refresher did not write this
|
|
972
|
+
* cache at all. Adding the refresher meant either duplicating this logic or extracting it — and the
|
|
973
|
+
* duplicate was already half-written when the MEMORY_CACHE comment forty lines below caught it: that
|
|
974
|
+
* exact mistake ("a cache writer that knew about half the payload") once made a background refresh
|
|
975
|
+
* silently ERASE the advocacy block, so the page showed recommendations on the first request and
|
|
976
|
+
* none ever after. The draft here reproduced it precisely, omitting `advocacy`.
|
|
977
|
+
*
|
|
978
|
+
* One computer, two callers. A shape that cannot drift because there is only one of it.
|
|
979
|
+
*/
|
|
980
|
+
function computeCapabilities() {
|
|
981
|
+
let rows = [];
|
|
982
|
+
let reconciled = [];
|
|
983
|
+
let reconciledIgnored = [];
|
|
984
|
+
try {
|
|
985
|
+
rows = capabilityAuditAll();
|
|
986
|
+
// Credit APPLIED for anything we offered that the user has since switched on. Derived from this
|
|
987
|
+
// live audit, never guessed; safe on a read (idempotent — a resolved offer is no longer pending).
|
|
988
|
+
reconciled = reconcileApplied(rows);
|
|
989
|
+
// THE DENOMINATOR'S MISSING THIRD (ADR-028 L5): an offer that has sat PENDING, still off, for a
|
|
990
|
+
// full day is `ignored`. Runs AFTER reconcileApplied so a capability the user just switched on is
|
|
991
|
+
// never miscounted as ignored in the same pass.
|
|
992
|
+
reconciledIgnored = reconcileIgnored(findStaleOffers(rows));
|
|
993
|
+
// LATENCY-TO-SURFACE's missing half (ADR-028:103, "the single best summary metric"). The
|
|
994
|
+
// registry is a pure detector with no memory: it can say "this is off", never "this has been off
|
|
995
|
+
// since Tuesday" — so the subtraction had no left-hand side and the metric was uncomputable.
|
|
996
|
+
// Appending state TRANSITIONS here, on the audit that already runs, supplies it.
|
|
997
|
+
//
|
|
998
|
+
// Deliberately inside the try and deliberately non-fatal: a capability audit must never fail
|
|
999
|
+
// because a metric could not be written. recordObservation() already swallows its own IO errors
|
|
1000
|
+
// and returns [] — this is the second belt, because the console rendering is load-bearing for
|
|
1001
|
+
// the user and the measurement is not.
|
|
1002
|
+
try { recordCapabilityStates(rows); } catch { /* the metric is never worth breaking the page for */ }
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
// A failed audit must NOT render as "everything is off" — the precise lie this surface kills.
|
|
1005
|
+
rows = [{ key: 'audit', label: 'Capability audit', state: 'unknown', scope: 'machine',
|
|
1006
|
+
whatItBuysYou: 'a clear picture of what you own and what is switched on',
|
|
1007
|
+
evidence: `the audit could not run: ${String(e && e.message || e).slice(0, 160)}` }];
|
|
1008
|
+
}
|
|
1009
|
+
// THE CAPABILITY ⇄ RECOMMENDATION BRIDGE. `recId` is stamped by the SERVER, and only when
|
|
1010
|
+
// buildCapabilityRecommendations() actually constructed a schema-gated rec for this row — never
|
|
1011
|
+
// guessed, never derived client-side. This is the field console/app.js's capCheckboxEligible() reads
|
|
1012
|
+
// to decide whether a row earns a checkbox at all; see console-engine.mjs's header on that function
|
|
1013
|
+
// for why the bar is proven-undo, not merely has-a-command. Wrapped in its own try/catch so a bug in
|
|
1014
|
+
// the bridge degrades to "no checkbox anywhere" (recId: null everywhere), never a broken page — the
|
|
1015
|
+
// same non-fatal discipline every other enrichment in this function already holds to.
|
|
1016
|
+
try {
|
|
1017
|
+
const wantIds = new Set(buildCapabilityRecommendations({ capabilities: rows }).map((r) => r.id));
|
|
1018
|
+
for (const row of rows) row.recId = wantIds.has(`enable:${row.key}`) ? `enable:${row.key}` : null;
|
|
1019
|
+
} catch { for (const row of rows) row.recId = null; }
|
|
1020
|
+
// null (not 0) until enough offers have resolved — an honest "not yet judgeable", never a
|
|
1021
|
+
// fabricated score. Computed AFTER both reconciles so a freshly-resolved outcome is reflected.
|
|
1022
|
+
const prec = advocacyPrecision();
|
|
1023
|
+
return { at: new Date().toISOString(), data: { rows, advocacy: { precision: prec, reconciled, reconciledIgnored } } };
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
export function expireCachesEmbedding(files) {
|
|
1027
|
+
for (const f of files) {
|
|
1028
|
+
try {
|
|
1029
|
+
const j = readJSON(f);
|
|
1030
|
+
if (!j || !j.data) continue;
|
|
1031
|
+
j.at = new Date(0).toISOString(); // epoch ⇒ unambiguously past any ceiling
|
|
1032
|
+
// SCOPE SURVIVES THE EXPIRY (fixed 2026-07-26, RVBC-INSTANT-SPEC #3). This was
|
|
1033
|
+
// `writeCache(f, j.at, j.data)` — three arguments — and writeCache's fourth parameter defaults
|
|
1034
|
+
// to null, so every expiry silently erased WHICH PROJECT the measurement belonged to. Against a
|
|
1035
|
+
// project-scoped read that null is a scope MISMATCH, and a mismatch is treated as cold. So the
|
|
1036
|
+
// helper written to avoid the freeze ("EXPIRE, DO NOT DELETE — with no cache file the next
|
|
1037
|
+
// request takes the COLD path") reintroduced the cold path by another door: not by deleting the
|
|
1038
|
+
// file, by deleting its identity. Cold no longer computes inline, but a de-scoped cache still
|
|
1039
|
+
// throws away a perfectly good measurement and blanks the page until the child lands.
|
|
1040
|
+
writeCache(f, j.at, j.data, j.scope ?? null);
|
|
1041
|
+
} catch { /* a cache we cannot rewrite is one the next reader will recompute anyway */ }
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* THE THIRD OUTCOME, WIRED (ADR-028 L5). `ignored` had ZERO callers: precision = applied ÷
|
|
1047
|
+
* (applied+dismissed+ignored) silently shrank its own denominator, the mirror image of the
|
|
1048
|
+
* "record only the applies" fabrication advocacy-outcomes.mjs's own header names. This is the
|
|
1049
|
+
* caller advocacy-outcomes.mjs's own docs ask for: it computes staleness (the ledger deliberately
|
|
1050
|
+
* does not — see reconcileIgnored()'s header), this file only ever verifies the staleness ledger
|
|
1051
|
+
* already has evidence for.
|
|
1052
|
+
*
|
|
1053
|
+
* THE RULE, AND WHY IT IS THE CHEAP ONE TO DEFEND: an offer is `ignored` once it has been PENDING
|
|
1054
|
+
* (never applied nor dismissed) for at least `IGNORE_AFTER_MS` AND the audit, run again right now,
|
|
1055
|
+
* still finds the capability `off`. Both halves are load-bearing:
|
|
1056
|
+
* - "still off" rules out the one honest reason silence could mean something OTHER than a miss —
|
|
1057
|
+
* the user already acted and reconcileApplied() simply has not been called yet in THIS request
|
|
1058
|
+
* (it always runs first, immediately above, in the same audit pass).
|
|
1059
|
+
* - "pending ≥ IGNORE_AFTER_MS" is wall-clock time, not a session count, because THIS endpoint has
|
|
1060
|
+
* no session concept of its own (it is a cached HTTP read-model, polled on whatever cadence the
|
|
1061
|
+
* console page happens to be open) — inventing a session counter here would be evidence this
|
|
1062
|
+
* file does not have. 24h is a full day of the capability sitting there, in the one place a user
|
|
1063
|
+
* would see it (the console, `/api/capabilities`'s own consumer), still off, with no dismiss and
|
|
1064
|
+
* no apply — long enough that "hasn't looked yet" stops being the more likely explanation.
|
|
1065
|
+
* A day is also symmetric with anticipate.sh's own once-per-project-per-day fallback session key, so
|
|
1066
|
+
* the two surfaces do not disagree about what "already had a fair chance to react" means.
|
|
1067
|
+
*
|
|
1068
|
+
* PURE (besides the ledger read `pendingOffers()` performs) — `now` is a parameter so a test can
|
|
1069
|
+
* pass a fixed instant instead of asserting against a moving `Date.now()`.
|
|
1070
|
+
*/
|
|
1071
|
+
const IGNORE_AFTER_MS = 24 * 60 * 60 * 1000;
|
|
1072
|
+
export function findStaleOffers(rows, { file, now = Date.now() } = {}) {
|
|
1073
|
+
const stillOff = new Set((Array.isArray(rows) ? rows : []).filter((r) => r && r.state === 'off').map((r) => r.key));
|
|
1074
|
+
return pendingOffers({ file })
|
|
1075
|
+
.filter((p) => stillOff.has(p.id) && typeof p.at === 'string' && (now - Date.parse(p.at)) >= IGNORE_AFTER_MS)
|
|
1076
|
+
.map((p) => p.id);
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const SELF = fileURLToPath(import.meta.url);
|
|
1080
|
+
let LAST_REFRESH_KICK = 0;
|
|
1081
|
+
/**
|
|
1082
|
+
* TEMP-THEN-RENAME — every cache writer in this file goes through this, never a bare writeFileSync.
|
|
1083
|
+
*
|
|
1084
|
+
* A bare writeFileSync truncates the target before the new bytes land. This file writes each cache
|
|
1085
|
+
* from at least two independent code paths per refresh cycle (the detached `--refresh-cache` child,
|
|
1086
|
+
* PLUS gatherState()/gatherStack() self-caching whenever called directly — see those two functions),
|
|
1087
|
+
* and a crash or kill mid-write leaves a TORN, half-written JSON file behind. readJSON()'s JSON.parse
|
|
1088
|
+
* then throws on that file, which is indistinguishable from "no cache yet" to every `!c || !c.data`
|
|
1089
|
+
* cold-path check in this file — so a torn cache silently demotes the NEXT request into the exact
|
|
1090
|
+
* expensive inline compute (13-49s) this caching exists to avoid.
|
|
1091
|
+
*
|
|
1092
|
+
* NOT hand-rolled: this reuses `writeAtomic` from user-settings.mjs (already imported above, line 46)
|
|
1093
|
+
* rather than growing a second copy of open/write/rename — it already does temp-then-rename PLUS an
|
|
1094
|
+
* fsync before the rename (a rename alone can land while the new bytes are still in the page cache;
|
|
1095
|
+
* without the flush, a power loss can leave an atomically-renamed but EMPTY file). Matches
|
|
1096
|
+
* lesson-store.mjs's saveLessons() in spirit, the store this class of fix was hardened for after a
|
|
1097
|
+
* real data-loss incident (see that file's own header).
|
|
1098
|
+
*/
|
|
1099
|
+
function atomicWriteJSON(file, obj) {
|
|
1100
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
1101
|
+
writeAtomic(file, JSON.stringify(obj));
|
|
1102
|
+
}
|
|
1103
|
+
function writeCache(file, at, data, scope = null) {
|
|
1104
|
+
try { atomicWriteJSON(file, { at, data, scope }); }
|
|
1105
|
+
catch { /* a cache write must never break a response */ }
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Spawn the detached `--refresh-cache` child that does ALL the measuring.
|
|
1109
|
+
*
|
|
1110
|
+
* @param {{force?: boolean}} opts `force` bypasses the 15s time debounce. It exists for the two
|
|
1111
|
+
* callers where a debounced no-op would be a LIE to the user: a COLD/scope-mismatched read (there
|
|
1112
|
+
* is nothing to serve, so "we'll get to it within 15 seconds" means a blank page for 15 seconds)
|
|
1113
|
+
* and the explicit Refresh button (it must answer "yes, I started one", not silently drop the
|
|
1114
|
+
* click and still return ok — RVBC-INSTANT-SPEC #3).
|
|
1115
|
+
* @returns {boolean} whether a child was actually started. Reported to the page rather than
|
|
1116
|
+
* swallowed: a refresh that did not start must not render as one that did.
|
|
1117
|
+
*
|
|
1118
|
+
* ONE SCAN AT A TIME, force or not. The page opens four heavy endpoints at once and then polls, so a
|
|
1119
|
+
* force that ignored an in-flight child would fan out into six concurrent full-machine scans — the
|
|
1120
|
+
* cure becoming the disease. The in-flight guard has its own expiry (a child that has not exited in
|
|
1121
|
+
* five minutes is presumed wedged, not working) so one bad scan can never disable refreshing for the
|
|
1122
|
+
* life of the server.
|
|
1123
|
+
*/
|
|
1124
|
+
let REFRESH_CHILD = null;
|
|
1125
|
+
const REFRESH_WEDGED_MS = 5 * 60 * 1000;
|
|
1126
|
+
function kickRefresh({ force = false } = {}) {
|
|
1127
|
+
if (process.env.RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH === '1') return false;
|
|
1128
|
+
const now = Date.now();
|
|
1129
|
+
if (REFRESH_CHILD && now - LAST_REFRESH_KICK < REFRESH_WEDGED_MS) return false; // one at a time
|
|
1130
|
+
if (!force && now - LAST_REFRESH_KICK < 15000) return false; // debounce: at most one background refresh / 15s
|
|
1131
|
+
LAST_REFRESH_KICK = now;
|
|
1132
|
+
try {
|
|
1133
|
+
// cwd = the SERVED project, NOT REPO. This was `cwd: REPO` and it was a real console-honesty bug
|
|
1134
|
+
// (found 2026-07-24): the refresh child calls gatherState(process.cwd()), so with cwd=REPO it
|
|
1135
|
+
// recomputed PROJECT-SCOPED capabilities (memory-distillation, workflow-pattern-learning) for the
|
|
1136
|
+
// PLUGIN's own directory and wrote them to the shared cache — meaning that after the first 15s
|
|
1137
|
+
// refresh, /api/capabilities and /api/state reported the wrong directory's state for whatever
|
|
1138
|
+
// project the user actually opened. That is precisely the "looks on but isn't" failure this
|
|
1139
|
+
// console exists to prevent. The server's process.cwd() IS the served project, so the child must
|
|
1140
|
+
// inherit it. Machine-level caches (stack/activity/trust) don't depend on cwd, so this is safe
|
|
1141
|
+
// for them; it only fixes the project-scoped ones. NOT a change to the withhold-vs-recompute
|
|
1142
|
+
// contract (the 2026-07-17 outage) — only to which project the background compute is about.
|
|
1143
|
+
const child = spawn(process.execPath, [SELF, '--refresh-cache'], { detached: true, stdio: 'ignore', cwd: process.cwd() });
|
|
1144
|
+
REFRESH_CHILD = child;
|
|
1145
|
+
// unref() only releases the event-loop hold; these listeners still fire while the server lives.
|
|
1146
|
+
child.on('exit', () => { REFRESH_CHILD = null; });
|
|
1147
|
+
child.on('error', () => { REFRESH_CHILD = null; });
|
|
1148
|
+
child.unref(); // let it outlive this request; it writes the caches and exits on its own
|
|
1149
|
+
return true;
|
|
1150
|
+
} catch { REFRESH_CHILD = null; return false; /* a failed spawn just means the cache ages until the next kick */ }
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
/**
|
|
1154
|
+
* COMPLETION SIGNAL — "it's live, take a look at your page."
|
|
1155
|
+
*
|
|
1156
|
+
* The cold path prints "first run — scanning… ~15 seconds", then the detached refresh child
|
|
1157
|
+
* (kickRefresh, stdio:'ignore') does the scanning and the parent NEVER learns when it finished — so
|
|
1158
|
+
* the page just quietly filled in and nothing in the terminal ever said "done". The owner asked for
|
|
1159
|
+
* exactly this, verbatim: "a countdown or something that then eventually tells them, okay it's live,
|
|
1160
|
+
* take a look at your page." This supplies it, honestly: "live" is defined as "the state cache the
|
|
1161
|
+
* page paints first now exists, written by THIS launch" — an observed fact, not a guess or a fixed
|
|
1162
|
+
* timer. We watch STATE_CACHE's mtime (the same file the refresh child writes and the page reads
|
|
1163
|
+
* first) and print one line when it lands, or a still-scanning line if it runs long. Never holds the
|
|
1164
|
+
* process open (unref) and never fires on the warm path — a warm re-open paints instantly and needs
|
|
1165
|
+
* no signal.
|
|
1166
|
+
*/
|
|
1167
|
+
function announceWhenLive(url) {
|
|
1168
|
+
const startedAt = Date.now();
|
|
1169
|
+
const deadline = startedAt + 45000; // generous: a cold gatherState is ~13s; fleet longer
|
|
1170
|
+
let lastPrint = startedAt; // for the countdown ticks
|
|
1171
|
+
const timer = setInterval(() => {
|
|
1172
|
+
let landed = false;
|
|
1173
|
+
try { landed = fs.existsSync(STATE_CACHE) && fs.statSync(STATE_CACHE).mtimeMs >= startedAt - 1000; } catch { /* not yet */ }
|
|
1174
|
+
const now = Date.now();
|
|
1175
|
+
const waited = Math.round((now - startedAt) / 1000);
|
|
1176
|
+
if (landed) {
|
|
1177
|
+
clearInterval(timer);
|
|
1178
|
+
console.log(` ✓ it's live — open ${url} (or refresh the tab) to see your machine · ${waited}s\n`);
|
|
1179
|
+
} else if (now >= deadline) {
|
|
1180
|
+
clearInterval(timer);
|
|
1181
|
+
console.log(` still scanning after ${waited}s — the page fills in as data lands · ${url}`);
|
|
1182
|
+
} else if (now - lastPrint >= 2000) {
|
|
1183
|
+
// The owner asked for "a COUNTDOWN or something" — one start line then silence reads as a hang
|
|
1184
|
+
// on a slow scan. A tick every ~2s keeps the terminal alive (never a silent gap > 3s) and tells
|
|
1185
|
+
// the user the scan is still moving, until the honest "it's live" lands. Measured 2026-07-24: the
|
|
1186
|
+
// UX QE suite's max-dead-air WARN fired at ~3s with only start+end lines; a 2s tick on a 500ms
|
|
1187
|
+
// poll fires at ~2s (not the old 3s boundary), keeping every gap safely under the 3s bar.
|
|
1188
|
+
lastPrint = now;
|
|
1189
|
+
console.log(` …scanning (${waited}s)`);
|
|
1190
|
+
}
|
|
1191
|
+
}, 500);
|
|
1192
|
+
timer.unref?.(); // the server keeps the loop alive; never hold it open just for this announcer
|
|
1193
|
+
}
|
|
1194
|
+
// Serve <file>'s cached data instantly; on a cold miss, compute once via <compute>, seed the cache,
|
|
1195
|
+
// and serve that. Always kicks a background refresh so the next reader gets fresher data.
|
|
1196
|
+
/* HARD CEILING ON CACHED TRUTH.
|
|
1197
|
+
*
|
|
1198
|
+
* Measured 2026-07-24: this function served a capability cache stamped 2026-07-22T04:52Z — TWO DAYS
|
|
1199
|
+
* OLD — as the present-tense state of the user's machine. It reported "all 12 recorded lessons are
|
|
1200
|
+
* still candidates … none of them can influence anything yet" while the live store held 16 lessons
|
|
1201
|
+
* with 13 ratified and in force. The registry was right the whole time; the cache spoke over it.
|
|
1202
|
+
*
|
|
1203
|
+
* The defect was structural, not a wrong number: there was NO age limit. Any cache file that existed
|
|
1204
|
+
* was served, forever, with a background refresh that only ever helped the NEXT visitor. So a user
|
|
1205
|
+
* could open the console, read a confident sentence about their own machine, and be told something
|
|
1206
|
+
* false — which is the single failure this product cannot survive, because every other claim it
|
|
1207
|
+
* makes is then worth nothing.
|
|
1208
|
+
*
|
|
1209
|
+
* Stale data is still useful (a 49s cold scan is why the cache exists). What is not acceptable is
|
|
1210
|
+
* stale data WEARING THE COSTUME OF FRESH DATA.
|
|
1211
|
+
*
|
|
1212
|
+
* ── CORRECTED, SAME DAY, BEFORE IT REACHED ANYONE (Fable 5, 2026-07-24) ──────────────────────────
|
|
1213
|
+
*
|
|
1214
|
+
* The first version of this fix said: "over the ceiling, refuse to serve it and measure again,
|
|
1215
|
+
* IN-BAND, even though that costs the user a slow page. A slow honest page beats a fast lying one."
|
|
1216
|
+
*
|
|
1217
|
+
* That reintroduced the outage this very file documents forty lines above (see "the demo-hang fix",
|
|
1218
|
+
* 2026-07-17): every read-model here does multi-second SYNCHRONOUS work — gatherState ~13s,
|
|
1219
|
+
* gatherStack ~22s, scanFleet ~40s+ — and Node is single-threaded, so one inline compute freezes the
|
|
1220
|
+
* WHOLE server. `curl` saw 000 on roughly one request in three. The rule established then was
|
|
1221
|
+
* absolute: THE REQUEST HANDLER NEVER COMPUTES INLINE ONCE A CACHE EXISTS.
|
|
1222
|
+
*
|
|
1223
|
+
* And the console is opened occasionally, not polled — so "older than the ceiling" is the COMMON
|
|
1224
|
+
* case, not the rare one. The first version therefore made the documented hang the DEFAULT path,
|
|
1225
|
+
* while every other endpoint, POST and static file on the server froze behind it.
|
|
1226
|
+
*
|
|
1227
|
+
* The error was treating "honest" and "fast" as the only two options and picking honest. There is a
|
|
1228
|
+
* third, and this repo's own DDD-0011 had already named it: INV-4 makes WITHHOLDING a first-class
|
|
1229
|
+
* outcome, and the domain-event table says MeasurementExpired triggers "re-measure OR withhold."
|
|
1230
|
+
*
|
|
1231
|
+
* So: past the ceiling we serve the value with `stale: true` and its real age — the claim is
|
|
1232
|
+
* WITHDRAWN, not disguised — and kick the detached refresher. The renderer's job is to present a
|
|
1233
|
+
* withdrawn claim as withdrawn ("last measured 2 hours ago, re-measuring now"), never as current.
|
|
1234
|
+
* Honest AND non-blocking. Inline compute survives for exactly one case: no prior measurement
|
|
1235
|
+
* exists at all, where there is nothing to withhold and nothing older to serve. */
|
|
1236
|
+
const CACHE_MAX_AGE_MS = 15 * 60 * 1000;
|
|
1237
|
+
|
|
1238
|
+
/**
|
|
1239
|
+
* ONE ceiling, ONE shape, for every JSON response that carries measured machine state.
|
|
1240
|
+
*
|
|
1241
|
+
* GPT-5.6-Sol's review of the fix above, verbatim: "Four freshness policies is zero freshness
|
|
1242
|
+
* policies." serveCached() got a real ceiling while ACTIVITY_MACHINE_CACHE, TRUST_CACHE, and the
|
|
1243
|
+
* always-live /api/lessons read each kept (or lacked) a PRIVATE one — so two cards on the same page,
|
|
1244
|
+
* both "compliant" with their own rule, could disagree about whether a given age counts as current.
|
|
1245
|
+
* A user cannot tell which promise a card is making, which is the same failure as making none.
|
|
1246
|
+
*
|
|
1247
|
+
* Pure function of a timestamp the CALLER already took — never `Date.now()` computed in here — so it
|
|
1248
|
+
* can never paper over a stamp taken at the wrong moment (see gatherStack()/gatherActivity()/
|
|
1249
|
+
* gatherLessons() below, where THAT bug lived). A missing or unparseable `measuredAt` reads as
|
|
1250
|
+
* maximally stale, not silently fresh: a card that cannot prove its own age must never claim to be
|
|
1251
|
+
* current.
|
|
1252
|
+
*/
|
|
1253
|
+
function freshnessOf(measuredAt) {
|
|
1254
|
+
const t = typeof measuredAt === 'string' ? Date.parse(measuredAt) : NaN;
|
|
1255
|
+
const ageMs = Number.isFinite(t) ? Date.now() - t : Infinity;
|
|
1256
|
+
return {
|
|
1257
|
+
measuredAt: typeof measuredAt === 'string' ? measuredAt : null,
|
|
1258
|
+
ageMs: Number.isFinite(ageMs) ? ageMs : null,
|
|
1259
|
+
stale: !(ageMs <= CACHE_MAX_AGE_MS),
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* THE WARMING ANSWER — one shape, every endpoint, always instant.
|
|
1265
|
+
*
|
|
1266
|
+
* `warming: true` is a first-class response, not an error and not an empty payload: "no measurement
|
|
1267
|
+
* exists for this project yet; one is being taken; ask again in a moment." The page reads it and
|
|
1268
|
+
* KEEPS its skeletons — it must never render a warming answer as empty sections, which would say
|
|
1269
|
+
* "you have nothing configured" to someone whose machine simply has not been looked at yet.
|
|
1270
|
+
*
|
|
1271
|
+
* `stale: true` is deliberate and not a contradiction: there is no current measurement here, so
|
|
1272
|
+
* every consumer of the shared freshness contract must treat this as "do not present as fact."
|
|
1273
|
+
*/
|
|
1274
|
+
function warmingAnswer(scopeKey, kicked) {
|
|
1275
|
+
return { warming: true, scope: scopeKey ?? null, kicked, fromCache: false, measuredAt: null, ageMs: null, stale: true };
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
function serveCached(res, file, decorate = (d) => d, scopeKey = null) {
|
|
1279
|
+
const c = readJSON(file);
|
|
1280
|
+
|
|
1281
|
+
// WRONG PROJECT IS AS GOOD AS NO DATA. When a caller passes a scopeKey (the served project, for the
|
|
1282
|
+
// project-specific caches), a cached record computed for a DIFFERENT project must never be served —
|
|
1283
|
+
// that is the cross-project "looks on but isn't" bug (found 2026-07-24: two consoles, or one opened
|
|
1284
|
+
// in project B after A, sharing a user-level cache file).
|
|
1285
|
+
const scopeMismatch = scopeKey != null && (!c || c.scope !== scopeKey);
|
|
1286
|
+
|
|
1287
|
+
// ── COLD, OR THE WRONG PROJECT: ANSWER IN MICROSECONDS, MEASURE IN A CHILD ──────────────────────
|
|
1288
|
+
//
|
|
1289
|
+
// THE THREE MINUTES OF DEAD AIR (owner, 2026-07-26/27) ENDED ON THIS BRANCH. It used to read
|
|
1290
|
+
// "this one request eats the compute to seed the cache — the 2026-07-17 bargain", and the bargain
|
|
1291
|
+
// was mispriced on two counts:
|
|
1292
|
+
//
|
|
1293
|
+
// 1. COLD IS NOT RARE, IT IS THE SECOND PROJECT. The caches are single user-level files keyed by
|
|
1294
|
+
// one `scope`. Open the console in project B after project A and B is a scope mismatch —
|
|
1295
|
+
// i.e. cold — every time. "Cold once, then warm forever" was only ever true of a machine with
|
|
1296
|
+
// exactly one project on it.
|
|
1297
|
+
// 2. IT WAS NEVER ONE REQUEST. Node is single-threaded: an inline gather freezes the WHOLE
|
|
1298
|
+
// server — the static page, every other endpoint, the token check, all of it — and the page
|
|
1299
|
+
// opens four heavy endpoints at once, so the freezes queued end to end. Measured on this
|
|
1300
|
+
// file's own fixture at the moment of the fix: /api/stack alone answered cold in 23,640 ms.
|
|
1301
|
+
//
|
|
1302
|
+
// The child already computes every one of these caches (see `--refresh-cache` at the bottom of
|
|
1303
|
+
// this file) and writes each one the moment it is ready. So there is nothing for a request handler
|
|
1304
|
+
// to do here except say so and get out of the way. There is no longer a `compute` parameter to
|
|
1305
|
+
// pass: the guarantee is now structural rather than a rule someone has to remember, and the
|
|
1306
|
+
// duplicate compute closures the handlers used to carry (which had ALREADY drifted from the
|
|
1307
|
+
// child's copies once — see the MEMORY_CACHE note in --refresh-cache) are gone with it.
|
|
1308
|
+
if (!c || !c.data || scopeMismatch) {
|
|
1309
|
+
return sendJSON(res, 200, warmingAnswer(scopeKey, kickRefresh({ force: true })));
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
// WARM — including over-ceiling. Never compute inline here; hand back what we measured, say when,
|
|
1313
|
+
// and let the detached child produce the next one.
|
|
1314
|
+
const fresh = freshnessOf(c.at);
|
|
1315
|
+
kickRefresh();
|
|
1316
|
+
return sendJSON(res, 200, {
|
|
1317
|
+
...decorate(c.data),
|
|
1318
|
+
fromCache: true,
|
|
1319
|
+
cachedAt: c.at, // legacy alias — `fresh.measuredAt` (from `...fresh` below) is the contract name (DDD-0011)
|
|
1320
|
+
...fresh,
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* @returns {boolean} whether anything was actually restored — the caller uses this to decide
|
|
1325
|
+
* whether to warn a first-run user that the page starts empty. It used to return undefined, so a
|
|
1326
|
+
* truthiness check on it was always false; reporting what it really did keeps the caller honest.
|
|
1327
|
+
*/
|
|
1328
|
+
function loadConsoleCache() {
|
|
1329
|
+
let restored = false;
|
|
1330
|
+
try {
|
|
1331
|
+
const j = JSON.parse(fs.readFileSync(CONSOLE_CACHE_PATH, 'utf8'));
|
|
1332
|
+
if (j.activity && j.activity.at) { ACTIVITY_MACHINE_CACHE = j.activity; restored = true; }
|
|
1333
|
+
if (j.trust && j.trust.at) { TRUST_CACHE = j.trust; restored = true; }
|
|
1334
|
+
} catch { /* no cache yet — first ever boot */ }
|
|
1335
|
+
return restored;
|
|
1336
|
+
}
|
|
1337
|
+
function saveConsoleCache() {
|
|
1338
|
+
// Same torn-write risk as every other cache in this file (Task 4) — routed through the same atomic
|
|
1339
|
+
// helper rather than its own bare writeFileSync.
|
|
1340
|
+
//
|
|
1341
|
+
// MERGE, DON'T CLOBBER (2026-07-26). This file holds TWO independent measurements and, since the
|
|
1342
|
+
// fleet scan moved off the request path, TWO independent writers: the server (which has a TRUST_CACHE
|
|
1343
|
+
// and, after adoptFleetFromDisk, a fleet) and the detached --refresh-cache child (which scans the
|
|
1344
|
+
// fleet and has no trust at all). Writing the in-memory pair wholesale meant the child's fleet write
|
|
1345
|
+
// would have blanked the trust card's cache to null every single refresh — the same "a cache writer
|
|
1346
|
+
// that knew about half the payload" defect this file has already paid for once, in --refresh-cache's
|
|
1347
|
+
// MEMORY_CACHE. Each half now falls back to what is already on disk.
|
|
1348
|
+
try {
|
|
1349
|
+
const prev = readJSON(CONSOLE_CACHE_PATH) || {};
|
|
1350
|
+
atomicWriteJSON(CONSOLE_CACHE_PATH, {
|
|
1351
|
+
activity: ACTIVITY_MACHINE_CACHE ?? prev.activity ?? null,
|
|
1352
|
+
trust: TRUST_CACHE ?? prev.trust ?? null,
|
|
1353
|
+
});
|
|
1354
|
+
} catch { /* cache persistence must never break a read */ }
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Pick up a fleet scan performed by ANOTHER process (the detached --refresh-cache child).
|
|
1359
|
+
*
|
|
1360
|
+
* STRICTLY NEWER ONLY. Adopting an equal-or-older record would let a slow child's write walk a live
|
|
1361
|
+
* server backwards to a scan it has already superseded. Cheap: one small JSON read, no scan.
|
|
1362
|
+
*/
|
|
1363
|
+
function adoptFleetFromDisk() {
|
|
1364
|
+
try {
|
|
1365
|
+
const j = readJSON(CONSOLE_CACHE_PATH);
|
|
1366
|
+
if (j && j.activity && j.activity.at && (!ACTIVITY_MACHINE_CACHE || j.activity.at > ACTIVITY_MACHINE_CACHE.at)) {
|
|
1367
|
+
ACTIVITY_MACHINE_CACHE = j.activity;
|
|
1368
|
+
}
|
|
1369
|
+
} catch { /* no cache yet — the caller kicks a child and reports `warming` */ }
|
|
1370
|
+
}
|
|
1371
|
+
function refreshFleetCache() {
|
|
1372
|
+
const projects = [];
|
|
1373
|
+
let total = 0;
|
|
1374
|
+
const seen = new Set();
|
|
1375
|
+
// Scan every candidate root (issue #19) — this is what made machine-wide totals read 0 on a
|
|
1376
|
+
// machine whose projects live under ~/source instead of ~/Code.
|
|
1377
|
+
for (const root of candidateRoots()) {
|
|
1378
|
+
for (const s of findMemoryStores(root)) {
|
|
1379
|
+
const resolved = path.resolve(s.project);
|
|
1380
|
+
if (seen.has(resolved)) continue; // a project visible under two roots (e.g. a symlink) counts once
|
|
1381
|
+
seen.add(resolved);
|
|
1382
|
+
const n = Number(robustRead(s.db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0);
|
|
1383
|
+
if (n > 0) {
|
|
1384
|
+
// MAX(updated_at) = when this project was last actively worked — the memory store doubles
|
|
1385
|
+
// as the attention signal (relevance ordering, Stuart 2026-07-15).
|
|
1386
|
+
const lastTouched = Number(robustRead(s.db, 'SELECT MAX(updated_at) FROM memory_entries').value || 0);
|
|
1387
|
+
// rel = the root-relative path — the SAME key reconcile:<id> recommendations use (wiringSurvey
|
|
1388
|
+
// computes projName the same way, relative to whichever root the project was found under).
|
|
1389
|
+
projects.push({ name: path.basename(s.project), rel: path.relative(root, s.project), memories: n, lastTouched });
|
|
1390
|
+
total += n;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
projects.sort((a, b) => b.memories - a.memories);
|
|
1395
|
+
ACTIVITY_MACHINE_CACHE = { at: Date.now(), projects, totalMemories: total };
|
|
1396
|
+
saveConsoleCache();
|
|
1397
|
+
}
|
|
1398
|
+
function findMemoryStores(root) {
|
|
1399
|
+
const out = [];
|
|
1400
|
+
const walk = (dir, depth) => {
|
|
1401
|
+
if (depth > 3) return;
|
|
1402
|
+
let ents; try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
1403
|
+
for (const e of ents) {
|
|
1404
|
+
if (!e.isDirectory()) continue;
|
|
1405
|
+
const p = path.join(dir, e.name);
|
|
1406
|
+
if (VENDOR.some((m) => (p + '/').includes(m))) continue;
|
|
1407
|
+
if (e.name === '.swarm') {
|
|
1408
|
+
if (fs.existsSync(path.join(p, 'memory.db'))) out.push({ project: dir, db: path.join(p, 'memory.db') });
|
|
1409
|
+
continue;
|
|
1410
|
+
}
|
|
1411
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
1412
|
+
walk(p, depth + 1);
|
|
1413
|
+
}
|
|
1414
|
+
};
|
|
1415
|
+
walk(root, 0);
|
|
1416
|
+
return out;
|
|
1417
|
+
}
|
|
1418
|
+
function gatherActivity(cwd) {
|
|
1419
|
+
const project = fs.existsSync(path.join(cwd, '.swarm/memory.db')) ? cwd : REPO;
|
|
1420
|
+
const db = path.join(project, '.swarm/memory.db');
|
|
1421
|
+
if (!fs.existsSync(db)) {
|
|
1422
|
+
// No store to read — the existence check above IS the entire measurement, so it IS the
|
|
1423
|
+
// observation instant. Stamped here, not with a value taken before the check ran.
|
|
1424
|
+
const measuredAt = new Date().toISOString();
|
|
1425
|
+
return { generatedAt: measuredAt, project: path.basename(project), hasStore: false, ...freshnessOf(measuredAt) };
|
|
1426
|
+
}
|
|
1427
|
+
const out = { project: path.basename(project), hasStore: true };
|
|
1428
|
+
const rows = (sql) => robustReadJSON(db, sql).rows;
|
|
1429
|
+
out.totals = {
|
|
1430
|
+
memories: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE status='active'").value || 0),
|
|
1431
|
+
lessons: Number(robustRead(db, "SELECT COUNT(*) FROM memory_entries WHERE namespace='lessons' AND status='active'").value || 0),
|
|
1432
|
+
};
|
|
1433
|
+
out.lessons = rows("SELECT key, access_count, date(created_at/1000,'unixepoch') AS learned, substr(content,1,600) AS excerpt FROM memory_entries WHERE namespace='lessons' AND status='active' ORDER BY created_at DESC");
|
|
1434
|
+
out.recent = rows("SELECT key, namespace, type, datetime(updated_at/1000,'unixepoch','localtime') AS at FROM memory_entries WHERE status='active' ORDER BY updated_at DESC LIMIT 18");
|
|
1435
|
+
out.breakdown = rows("SELECT namespace, COUNT(*) AS n FROM memory_entries WHERE status='active' GROUP BY namespace ORDER BY n DESC");
|
|
1436
|
+
out.growth = rows("SELECT date(created_at/1000,'unixepoch') AS day, COUNT(*) AS n FROM memory_entries WHERE status='active' GROUP BY 1 ORDER BY 1");
|
|
1437
|
+
// TASK 3: stamped HERE, after every sqlite3 shell-out above has actually returned — not at the top
|
|
1438
|
+
// of this function (the previous `out = { generatedAt: new Date().toISOString(), ... }`), which
|
|
1439
|
+
// dated the whole response before a single row of it had been read.
|
|
1440
|
+
const measuredAt = new Date().toISOString();
|
|
1441
|
+
|
|
1442
|
+
// MACHINE-WIDE FLEET SCAN — NEVER ON THIS THREAD (RVBC-INSTANT-SPEC #5).
|
|
1443
|
+
//
|
|
1444
|
+
// This walk opens 100+ SQLite stores across every scan root: 40s+ on a real machine. It used to run
|
|
1445
|
+
// here two ways, both of them on the server's only thread:
|
|
1446
|
+
// • `refreshFleetCache()` outright, whenever no fleet had ever been scanned — i.e. on the very
|
|
1447
|
+
// first open, the one moment a new user is watching a blank tab;
|
|
1448
|
+
// • `setImmediate(refreshFleetCache)` when the fleet was over the ceiling. setImmediate is not
|
|
1449
|
+
// backgrounding — deferring synchronous work still blocks the loop when it finally runs, one
|
|
1450
|
+
// tick later, which is a distinction this file learned the hard way in 2026-07-17 and then
|
|
1451
|
+
// re-introduced here.
|
|
1452
|
+
// Both are now the detached child's job. A request only ever READS.
|
|
1453
|
+
//
|
|
1454
|
+
// adoptFleetFromDisk() is what closes the loop: the child is a separate process, so it cannot
|
|
1455
|
+
// hand this one an in-memory result — it writes console-cache.json and this picks it up, strictly
|
|
1456
|
+
// newer only, on the next read. Without it the server would sit on `warming` forever.
|
|
1457
|
+
adoptFleetFromDisk();
|
|
1458
|
+
if (!ACTIVITY_MACHINE_CACHE) {
|
|
1459
|
+
// NOTHING TO WITHHOLD AND NOTHING TO FAKE: say it is being measured, and say nothing else.
|
|
1460
|
+
// `totalMemories: null` (not 0) on purpose — a zero here would read as "no memories anywhere on
|
|
1461
|
+
// your machine", which is the product's cardinal lie, told about the one number this card exists
|
|
1462
|
+
// for. The frontend renders `warming` as a skeleton, never as an empty fleet.
|
|
1463
|
+
kickRefresh();
|
|
1464
|
+
out.machine = { warming: true, projects: [], totalMemories: null, scannedAt: null, measuredAt: null, ageMs: null, stale: true };
|
|
1465
|
+
return { generatedAt: measuredAt, ...out, ...freshnessOf(measuredAt) };
|
|
1466
|
+
}
|
|
1467
|
+
if (Date.now() - ACTIVITY_MACHINE_CACHE.at > CACHE_MAX_AGE_MS) kickRefresh();
|
|
1468
|
+
const machineMeasuredAt = new Date(ACTIVITY_MACHINE_CACHE.at).toISOString();
|
|
1469
|
+
out.machine = {
|
|
1470
|
+
projects: ACTIVITY_MACHINE_CACHE.projects,
|
|
1471
|
+
totalMemories: ACTIVITY_MACHINE_CACHE.totalMemories,
|
|
1472
|
+
scannedAt: machineMeasuredAt, // legacy field, unchanged shape
|
|
1473
|
+
...freshnessOf(machineMeasuredAt),
|
|
1474
|
+
};
|
|
1475
|
+
return { generatedAt: measuredAt, ...out, ...freshnessOf(measuredAt) };
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
// ── Router engine read-model ─────────────────────────────────────────────────────────────────────
|
|
1479
|
+
// 2026-07-16 (Stuart: "if MetaHarness does all of this then let it do it, but let us add user-
|
|
1480
|
+
// selected constraints"). This panel previously displayed router-optimizer.mjs — a parallel,
|
|
1481
|
+
// subscription-blind re-derivation of routing strategy that bypassed the REAL engine wired on
|
|
1482
|
+
// 2026-07-13 (model-router-engine.mjs → @metaharness/router). The replica is deleted. This
|
|
1483
|
+
// read-model contains ZERO routing logic: it shows the engine's own inputs (catalog × this user's
|
|
1484
|
+
// profile → effective marginal prices — the ONLY thing the local layer owns) and the engine's own
|
|
1485
|
+
// recent decisions from its append-only log. Nothing here can disagree with what actually routes.
|
|
1486
|
+
function gatherRouterEngine() {
|
|
1487
|
+
const profile = engineProfile();
|
|
1488
|
+
const candidates = applyProfile(engineCatalog(), profile);
|
|
1489
|
+
const prices = effectivePrices(candidates, profile);
|
|
1490
|
+
const { rows, unusable } = loadLabelledRows();
|
|
1491
|
+
const installed = fs.existsSync(path.join(__dirname, '..', 'node_modules', '@metaharness', 'router', 'package.json'));
|
|
1492
|
+
const list = (c) => (typeof c.costPerMTok === 'number' ? c.costPerMTok
|
|
1493
|
+
: c.costPerMTok && typeof c.costPerMTok.in === 'number' ? +(((c.costPerMTok.in + c.costPerMTok.out) / 2).toFixed(3))
|
|
1494
|
+
: null);
|
|
1495
|
+
const decisions = [];
|
|
1496
|
+
try {
|
|
1497
|
+
const log = path.join(os.homedir(), '.claude', 'metaharness', 'routing-decisions.jsonl');
|
|
1498
|
+
const lines = fs.readFileSync(log, 'utf8').trim().split('\n');
|
|
1499
|
+
for (const l of lines.slice(-8).reverse()) {
|
|
1500
|
+
try { const d = JSON.parse(l); decisions.push({ ts: d.ts, model: d.model, tier: d.tier, routedBy: d.routedBy, reason: d.reason }); } catch { /* skip bad line */ }
|
|
1501
|
+
}
|
|
1502
|
+
} catch { /* no decisions yet */ }
|
|
1503
|
+
const cfg = readJSON(CONFIG_PATH) || {};
|
|
1504
|
+
// User-constraint detection (Brain-side by design — a fact about THIS user, not routing logic):
|
|
1505
|
+
// an OpenRouter key decides whether metered cross-provider candidates are even reachable.
|
|
1506
|
+
let openrouterKey = !!process.env.OPENROUTER_API_KEY;
|
|
1507
|
+
if (!openrouterKey) openrouterKey = !!(cfg.openrouterKey && String(cfg.openrouterKey).length > 8);
|
|
1508
|
+
// House (issue #21): three mechanisms used to disagree — Settings wrote config.json's `provider`,
|
|
1509
|
+
// but the chip strip derived "yours" from whichever pool candidate happened to be
|
|
1510
|
+
// subscriptionCovered first, sourced from profile.json (a file nothing in the console writes). The
|
|
1511
|
+
// user's Settings choice is now the single source of truth, via the SAME detectProvider() the
|
|
1512
|
+
// savings.utilization frontier calc already uses (config → env → catalog default) — so this and
|
|
1513
|
+
// the frontier calc can never disagree either.
|
|
1514
|
+
let house, providerKeys = {};
|
|
1515
|
+
try {
|
|
1516
|
+
const hcat = loadCatalog();
|
|
1517
|
+
house = detectProvider(hcat, { provider: cfg.provider });
|
|
1518
|
+
// Per-provider credential presence (issue #24): the old chip strip hardcoded "not detected" for
|
|
1519
|
+
// every provider that wasn't the current house, so it could never tell "not your house" from "no
|
|
1520
|
+
// key found". Read each provider's real detect_env vars — minus the CLAUDECODE / CLAUDE_CODE_ENTRYPOINT
|
|
1521
|
+
// run-context markers (which are not credentials), exactly as detectProvider() itself filters them —
|
|
1522
|
+
// so the UI's "key found / not found" is now true instead of decorative.
|
|
1523
|
+
const IGNORE_ENV = new Set(['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT']);
|
|
1524
|
+
for (const [name, p] of Object.entries(hcat.providers || {})) {
|
|
1525
|
+
providerKeys[name] = (p.detect_env || []).some((k) => !IGNORE_ENV.has(k) && !!process.env[k]);
|
|
1526
|
+
}
|
|
1527
|
+
} catch { house = { provider: cfg.provider && cfg.provider !== 'auto' ? cfg.provider : 'anthropic', source: 'default' }; }
|
|
1528
|
+
return {
|
|
1529
|
+
engine: {
|
|
1530
|
+
package: '@metaharness/router', installed,
|
|
1531
|
+
labels: rows.length, needed: MIN_LABELS, unusableLabels: unusable,
|
|
1532
|
+
mode: !installed ? 'UNAVAILABLE' : rows.length >= MIN_LABELS ? 'LEARNED' : 'COLD-START',
|
|
1533
|
+
outcomesLog: OUTCOMES.replace(os.homedir(), '~'),
|
|
1534
|
+
},
|
|
1535
|
+
keys: { openrouter: openrouterKey, ...providerKeys },
|
|
1536
|
+
// Paid seats, found at USER level. `keys` above is env-var API keys only, which is exactly why a
|
|
1537
|
+
// user with ChatGPT Max and Claude Max read as "auto" — neither plan puts a key in the
|
|
1538
|
+
// environment. These two fields are what let the UI say "you already have this" instead of
|
|
1539
|
+
// asking someone to paste a credential they are already paying not to need.
|
|
1540
|
+
subscriptions: detectSubscriptions(),
|
|
1541
|
+
preferredSeat: preferredSeat(detectSubscriptions()),
|
|
1542
|
+
profile: { present: !!profile, path: PROFILE_PATH.replace(os.homedir(), '~') },
|
|
1543
|
+
catalogSource: engineCatalogSource(), // 'catalog' | 'built-in-fallback' — so the UI never calls the stub a real catalog
|
|
1544
|
+
house,
|
|
1545
|
+
pool: candidates
|
|
1546
|
+
.map((c) => ({
|
|
1547
|
+
id: c.id, provider: c.provider, tier: c.tier || null, harness: c.harness || [],
|
|
1548
|
+
marginalPerMTok: Number.isFinite(prices[c.id]) ? prices[c.id] : null,
|
|
1549
|
+
listPerMTok: list(c),
|
|
1550
|
+
// From the profile fact, never inferred from a $0 price — a mispriced metered model must
|
|
1551
|
+
// not display as "yours" (exactly the bug this read-model caught on 2026-07-16).
|
|
1552
|
+
subscriptionCovered: (c.subscription || []).some((h) => profile?.harnesses?.[h]?.subscription === true),
|
|
1553
|
+
verified: c.verified || null, note: c.note || null,
|
|
1554
|
+
}))
|
|
1555
|
+
.sort((a, b) => (a.marginalPerMTok ?? Infinity) - (b.marginalPerMTok ?? Infinity)),
|
|
1556
|
+
decisions,
|
|
1557
|
+
};
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
// ── Trust & provenance read-model (v3.3 preview; ADR-0013 follow-on) ─────────────────────────────
|
|
1561
|
+
// Two measurements are REAL today: the release bundle's published sha256, read live from the latest
|
|
1562
|
+
// GitHub release's .sha256 asset (read-only metadata — the same class of network touch as the stack
|
|
1563
|
+
// registry audit), and the local CycloneDX SBOM at sbom/ruvnet-brain.cdx.json (v3.3, `npm run sbom`)
|
|
1564
|
+
// when it has been generated on this machine. Install channel is read from the plugin cache on disk.
|
|
1565
|
+
// Advisor Mode is v3.3 and is reported as an honest empty state by the frontend — this read-model
|
|
1566
|
+
// never fabricates.
|
|
1567
|
+
const TRUST_REPO = 'stuinfla/ruvnet-brain';
|
|
1568
|
+
const SBOM_PATH = path.join(REPO, 'sbom', 'ruvnet-brain.cdx.json');
|
|
1569
|
+
// Local-file read, no network: the SBOM is generated by `npm run sbom` (CycloneDX 1.6 via
|
|
1570
|
+
// @cyclonedx/cyclonedx-npm, --omit dev) and committed alongside releases. Absent = honest empty
|
|
1571
|
+
// state, matching the "coming v3.3" language already shipped on the console card.
|
|
1572
|
+
function readSbom() {
|
|
1573
|
+
const rel = path.relative(REPO, SBOM_PATH);
|
|
1574
|
+
if (!fs.existsSync(SBOM_PATH)) return { present: false, path: rel };
|
|
1575
|
+
try {
|
|
1576
|
+
const j = JSON.parse(fs.readFileSync(SBOM_PATH, 'utf8'));
|
|
1577
|
+
const components = Array.isArray(j.components) ? j.components : [];
|
|
1578
|
+
return {
|
|
1579
|
+
present: true,
|
|
1580
|
+
path: rel,
|
|
1581
|
+
componentCount: components.length,
|
|
1582
|
+
specVersion: j.specVersion || null,
|
|
1583
|
+
bomFormat: j.bomFormat || null,
|
|
1584
|
+
generatedAt: (j.metadata && j.metadata.timestamp) || null,
|
|
1585
|
+
mainComponent: (j.metadata && j.metadata.component && j.metadata.component.name) || null,
|
|
1586
|
+
mainVersion: (j.metadata && j.metadata.component && j.metadata.component.version) || null,
|
|
1587
|
+
};
|
|
1588
|
+
} catch (e) {
|
|
1589
|
+
return { present: false, path: rel, error: String((e && e.message) || e) };
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
let TRUST_CACHE = null; // successful release reads cached; failures are never cached
|
|
1593
|
+
let TRUST_REFRESHING = false;
|
|
1594
|
+
let LAST_TRUST_KICK = 0;
|
|
1595
|
+
/**
|
|
1596
|
+
* Background refresh for TRUST_CACHE, fired only once we are PAST CACHE_MAX_AGE_MS — see gatherTrust()
|
|
1597
|
+
* below. Debounced the same way kickRefresh() debounces the other caches' detached child, so a console
|
|
1598
|
+
* tab left open and polling /api/trust every few seconds cannot turn into a GitHub API hammer.
|
|
1599
|
+
*
|
|
1600
|
+
* Not a detached child process like kickRefresh(): fetchReleaseDigest() is network I/O, not a
|
|
1601
|
+
* synchronous CPU-bound scan, so it does not block the event loop the way gatherStack()/scanFleet() do
|
|
1602
|
+
* — an un-awaited fetch() already satisfies "never block the request that asked".
|
|
1603
|
+
*/
|
|
1604
|
+
function kickTrustRefresh() {
|
|
1605
|
+
const now = Date.now();
|
|
1606
|
+
if (TRUST_REFRESHING || now - LAST_TRUST_KICK < 15000) return;
|
|
1607
|
+
LAST_TRUST_KICK = now;
|
|
1608
|
+
TRUST_REFRESHING = true;
|
|
1609
|
+
fetchReleaseDigest()
|
|
1610
|
+
.then((release) => {
|
|
1611
|
+
if (release.ok) {
|
|
1612
|
+
const generatedAt = new Date().toISOString();
|
|
1613
|
+
TRUST_CACHE = { at: Date.parse(generatedAt), data: { generatedAt, release } };
|
|
1614
|
+
saveConsoleCache();
|
|
1615
|
+
}
|
|
1616
|
+
})
|
|
1617
|
+
.catch(() => { /* keep serving the last good measurement — a failed refresh must not erase it */ })
|
|
1618
|
+
.finally(() => { TRUST_REFRESHING = false; });
|
|
1619
|
+
}
|
|
1620
|
+
async function fetchReleaseDigest() {
|
|
1621
|
+
const ua = { 'user-agent': 'ruvnet-brain-console' };
|
|
1622
|
+
const rel = await fetch(`https://api.github.com/repos/${TRUST_REPO}/releases/latest`,
|
|
1623
|
+
{ headers: { ...ua, accept: 'application/vnd.github+json' }, signal: AbortSignal.timeout(8000) });
|
|
1624
|
+
if (!rel.ok) throw new Error(`GitHub answered HTTP ${rel.status}`);
|
|
1625
|
+
const j = await rel.json();
|
|
1626
|
+
const assets = Array.isArray(j.assets) ? j.assets : [];
|
|
1627
|
+
const shaAsset = assets.find((a) => String(a.name).endsWith('.sha256'));
|
|
1628
|
+
const sigAsset = assets.find((a) => String(a.name).endsWith('.sig'));
|
|
1629
|
+
let sha256 = null;
|
|
1630
|
+
let file = null;
|
|
1631
|
+
if (shaAsset) {
|
|
1632
|
+
const r2 = await fetch(shaAsset.browser_download_url, { headers: ua, redirect: 'follow', signal: AbortSignal.timeout(8000) });
|
|
1633
|
+
if (r2.ok) {
|
|
1634
|
+
const m = (await r2.text()).trim().match(/^([0-9a-f]{64})\s+\*?(\S+)/i);
|
|
1635
|
+
if (m) { sha256 = m[1]; file = m[2]; }
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
return {
|
|
1639
|
+
ok: !!sha256,
|
|
1640
|
+
tag: j.tag_name || null,
|
|
1641
|
+
publishedAt: j.published_at || null,
|
|
1642
|
+
asset: file || (shaAsset ? String(shaAsset.name).replace(/\.sha256$/, '') : null),
|
|
1643
|
+
sha256,
|
|
1644
|
+
sig: !!sigAsset,
|
|
1645
|
+
source: `github.com/${TRUST_REPO}/releases/latest`,
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
// The header wears the product version openly (owner, 2026-07-24: "put the version of RuvNet-Brain
|
|
1649
|
+
// in the heading of the console"). Plugin-cache dir first — the truth on installed machines — then
|
|
1650
|
+
// the repo's plugin.json for dev checkouts. null hides the chip rather than guessing.
|
|
1651
|
+
function brainVersionOnDisk() {
|
|
1652
|
+
try { const v = readInstallChannel().version; if (v) return String(v).replace(/^v/, ''); } catch { /* fall through */ }
|
|
1653
|
+
try { return getVersion(); } catch { return null; }
|
|
1654
|
+
}
|
|
1655
|
+
function readInstallChannel() {
|
|
1656
|
+
const reg = readJSON(path.join(HOME, '.claude/plugins/installed_plugins.json'));
|
|
1657
|
+
const entries = reg && reg.plugins && reg.plugins['ruvnet-brain@ruvnet-brain'];
|
|
1658
|
+
const e = Array.isArray(entries) ? entries[0] : null;
|
|
1659
|
+
if (!e || !e.installPath || !fs.existsSync(e.installPath)) return { installed: false };
|
|
1660
|
+
const km = readJSON(path.join(HOME, '.claude/plugins/known_marketplaces.json'));
|
|
1661
|
+
const src = km && km['ruvnet-brain'] && km['ruvnet-brain'].source;
|
|
1662
|
+
const pinned = !!(src && (src.ref || src.tag || src.commit)); // no pin recorded → tracking latest
|
|
1663
|
+
return {
|
|
1664
|
+
installed: true,
|
|
1665
|
+
version: path.basename(e.installPath) || e.version || null, // the plugin cache version dir IS the truth
|
|
1666
|
+
channel: pinned ? 'pinned' : 'latest',
|
|
1667
|
+
lastUpdated: e.lastUpdated || null,
|
|
1668
|
+
cacheDir: String(e.installPath).replace(HOME, '~'),
|
|
1669
|
+
repo: (src && src.repo) || null,
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* TASK 1: TRUST_CACHE now obeys the SAME ceiling (CACHE_MAX_AGE_MS) as every other cache in this
|
|
1674
|
+
* file, and past it we WITHHOLD rather than recompute in-band.
|
|
1675
|
+
*
|
|
1676
|
+
* The previous version's own age check (a bespoke 600000, not CACHE_MAX_AGE_MS) fell straight through
|
|
1677
|
+
* to `await fetchReleaseDigest()` — a GitHub network round-trip with an 8s timeout — INSIDE the
|
|
1678
|
+
* request that asked. That is precisely the in-band-recompute-past-the-ceiling pattern the big
|
|
1679
|
+
* comment above serveCached() documents as the reintroduced 2026-07-17 outage, just for a network
|
|
1680
|
+
* call instead of a synchronous scan. It also meant a cache RESTORED from disk at boot
|
|
1681
|
+
* (loadConsoleCache()), which is virtually always older than 10 minutes by the time anyone opens the
|
|
1682
|
+
* console, hit that path on its very first request — "restored from disk with no age check" in
|
|
1683
|
+
* practice, because the check that did exist only ever triggered a blocking recompute rather than an
|
|
1684
|
+
* honest stale-serve.
|
|
1685
|
+
*/
|
|
1686
|
+
async function gatherTrust() {
|
|
1687
|
+
// COLD ONLY: no successful release read has ever landed, so there is nothing to withhold or serve
|
|
1688
|
+
// stale — the one exception serveCached() itself carves out for its own caches.
|
|
1689
|
+
if (!TRUST_CACHE) {
|
|
1690
|
+
let release;
|
|
1691
|
+
try { release = await fetchReleaseDigest(); }
|
|
1692
|
+
catch (e) { release = { ok: false, error: String((e && e.message) || e) }; }
|
|
1693
|
+
// TASK 3: stamped AFTER the network call above resolves, not before it — the observation instant.
|
|
1694
|
+
const generatedAt = new Date().toISOString();
|
|
1695
|
+
const data = { generatedAt, release };
|
|
1696
|
+
if (release.ok) { TRUST_CACHE = { at: Date.parse(generatedAt), data }; saveConsoleCache(); }
|
|
1697
|
+
return { ...data, channel: readInstallChannel(), sbom: readSbom(), ...freshnessOf(generatedAt) };
|
|
1698
|
+
}
|
|
1699
|
+
|
|
1700
|
+
// WARM — including over-ceiling. Never await the network here; hand back what we measured, say
|
|
1701
|
+
// when, and let a debounced background refresh (never THIS request) produce the next one.
|
|
1702
|
+
const fresh = freshnessOf(TRUST_CACHE.data.generatedAt);
|
|
1703
|
+
if (fresh.stale) kickTrustRefresh();
|
|
1704
|
+
// Disk facts stay live even when the release read is served from cache — the SBOM file and install
|
|
1705
|
+
// channel can change (a fresh `npm run sbom`, a plugin update) between two calls inside the ceiling.
|
|
1706
|
+
return { ...TRUST_CACHE.data, channel: readInstallChannel(), sbom: readSbom(), ...fresh };
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
// ── Assemble the read-models ─────────────────────────────────────────────────────────────────────
|
|
1710
|
+
/**
|
|
1711
|
+
* PAID SUBSCRIPTIONS, detected at USER level — not project level, not from environment variables.
|
|
1712
|
+
*
|
|
1713
|
+
* WHY THIS EXISTS. A user with BOTH a ChatGPT Max plan and a Claude Max plan showed up as "auto",
|
|
1714
|
+
* because the only thing "auto" ever looked at was `detect_env` — API keys in environment
|
|
1715
|
+
* variables. Verified on a real machine 2026-07-20: `~/.codex/auth.json` reads
|
|
1716
|
+
* `auth_mode: "chatgpt"`, `OPENAI_API_KEY: null`, with live OAuth tokens. A genuine, paid,
|
|
1717
|
+
* authenticated ChatGPT subscription with no API key anywhere — completely invisible to the old
|
|
1718
|
+
* detector. Claude's own Max session is worse: on macOS it lives in the LOGIN KEYCHAIN, so there is
|
|
1719
|
+
* no file to find at all.
|
|
1720
|
+
*
|
|
1721
|
+
* WHY IT MATTERS BEYOND A WRONG LABEL. A subscription is already paid for at a flat rate; an API
|
|
1722
|
+
* key bills per token. Routing to a key while an authenticated seat sits idle spends money the user
|
|
1723
|
+
* has already spent. So a subscription always outranks a key — the key is the LAST resort, never
|
|
1724
|
+
* the default. (Same principle as the meta-proxy's Passthrough plane: use the subscription you are
|
|
1725
|
+
* already paying for, and treat metered capacity as the fallback.)
|
|
1726
|
+
*
|
|
1727
|
+
* SECRETS ARE NEVER READ. For the keychain we ask only whether the ITEM EXISTS — never `-w`, which
|
|
1728
|
+
* would print the secret. For token files we check for the presence of a field, never its value.
|
|
1729
|
+
* Nothing here is logged, transmitted, or written anywhere.
|
|
1730
|
+
*
|
|
1731
|
+
* @returns {Record<string, {subscription: boolean, apiKey: boolean, how: string}>}
|
|
1732
|
+
*/
|
|
1733
|
+
export function detectSubscriptions() {
|
|
1734
|
+
const home = os.homedir();
|
|
1735
|
+
const out = {};
|
|
1736
|
+
const seat = (provider, subscription, apiKey, how) => { out[provider] = { subscription, apiKey, how }; };
|
|
1737
|
+
|
|
1738
|
+
// ── Anthropic (Claude Pro/Max) ────────────────────────────────────────────────────────────────
|
|
1739
|
+
// macOS keeps the Claude Code OAuth session in the login keychain; Linux/Windows use a file.
|
|
1740
|
+
// Existence only — `security find-generic-password` WITHOUT -w prints metadata, never the secret.
|
|
1741
|
+
let claudeSub = false; let claudeHow = 'not found';
|
|
1742
|
+
const credFile = path.join(home, '.claude', '.credentials.json');
|
|
1743
|
+
if (fs.existsSync(credFile)) { claudeSub = true; claudeHow = '~/.claude/.credentials.json'; }
|
|
1744
|
+
else if (process.platform === 'darwin') {
|
|
1745
|
+
try {
|
|
1746
|
+
const r = spawnSync('security', ['find-generic-password', '-s', 'Claude Code-credentials'], { encoding: 'utf8', timeout: 5000 });
|
|
1747
|
+
if (r.status === 0) { claudeSub = true; claudeHow = 'macOS login keychain'; }
|
|
1748
|
+
} catch { /* absent or locked — treated as not found, never as an error */ }
|
|
1749
|
+
}
|
|
1750
|
+
seat('anthropic', claudeSub, !!process.env.ANTHROPIC_API_KEY, claudeHow);
|
|
1751
|
+
|
|
1752
|
+
// ── OpenAI / ChatGPT (via the Codex CLI) ──────────────────────────────────────────────────────
|
|
1753
|
+
// auth_mode === 'chatgpt' means a ChatGPT plan is signed in; 'apikey' means metered billing.
|
|
1754
|
+
let oaSub = false; let oaKey = !!process.env.OPENAI_API_KEY; let oaHow = 'not found';
|
|
1755
|
+
const codexAuth = path.join(home, '.codex', 'auth.json');
|
|
1756
|
+
if (fs.existsSync(codexAuth)) {
|
|
1757
|
+
try {
|
|
1758
|
+
const j = JSON.parse(fs.readFileSync(codexAuth, 'utf8'));
|
|
1759
|
+
if (j.auth_mode === 'chatgpt' || (j.tokens && j.tokens.access_token)) { oaSub = true; oaHow = '~/.codex/auth.json (ChatGPT plan)'; }
|
|
1760
|
+
if (j.OPENAI_API_KEY) oaKey = true;
|
|
1761
|
+
} catch { /* unreadable/corrupt — report nothing rather than guess */ }
|
|
1762
|
+
}
|
|
1763
|
+
seat('openai', oaSub, oaKey, oaHow);
|
|
1764
|
+
// Codex is the same seat as the ChatGPT plan above, surfaced separately because the UI lists it
|
|
1765
|
+
// as its own "house" — one subscription, two labels, so never counted as two entitlements.
|
|
1766
|
+
seat('codex', oaSub, oaKey, oaHow === 'not found' ? 'not found' : `${oaHow} — same seat as OpenAI`);
|
|
1767
|
+
|
|
1768
|
+
// ── Google (Gemini) ───────────────────────────────────────────────────────────────────────────
|
|
1769
|
+
// gcloud ADC is a real authenticated credential; a bare ~/.gemini directory is NOT — it holds
|
|
1770
|
+
// settings and skills and exists on machines that were never signed in. Claiming a subscription
|
|
1771
|
+
// from a config folder would be exactly the fabricated-status this project forbids.
|
|
1772
|
+
const adc = path.join(home, '.config', 'gcloud', 'application_default_credentials.json');
|
|
1773
|
+
const gSub = fs.existsSync(adc);
|
|
1774
|
+
seat('google', gSub, !!(process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY), gSub ? '~/.config/gcloud (ADC)' : 'not found');
|
|
1775
|
+
|
|
1776
|
+
// ── xAI (Grok) ────────────────────────────────────────────────────────────────────────────────
|
|
1777
|
+
// No CLI writes a discoverable subscription credential today. Say so honestly rather than
|
|
1778
|
+
// inventing a detector that always returns false and looks like a real check.
|
|
1779
|
+
seat('xai', false, !!process.env.XAI_API_KEY, 'no detectable subscription credential');
|
|
1780
|
+
|
|
1781
|
+
return out;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* What to actually USE, given what was found. Subscription first, always.
|
|
1786
|
+
* @returns {{provider: string|null, basis: 'subscription'|'api-key'|'none', detail: string}}
|
|
1787
|
+
*/
|
|
1788
|
+
export function preferredSeat(subs) {
|
|
1789
|
+
const order = ['anthropic', 'openai', 'codex', 'google', 'xai'];
|
|
1790
|
+
for (const p of order) if (subs[p]?.subscription) return { provider: p, basis: 'subscription', detail: subs[p].how };
|
|
1791
|
+
for (const p of order) if (subs[p]?.apiKey) return { provider: p, basis: 'api-key', detail: 'environment variable' };
|
|
1792
|
+
return { provider: null, basis: 'none', detail: 'nothing detected' };
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
function gatherState(cwd, { fleet = true } = {}) {
|
|
1796
|
+
const wiring = wiringSurvey();
|
|
1797
|
+
const memory = gatherMemory(cwd, { fleet });
|
|
1798
|
+
try { memory.learnings = learnings(); } catch { memory.learnings = null; }
|
|
1799
|
+
const savings = gatherSavings();
|
|
1800
|
+
const cfgNow = readJSON(CONFIG_PATH) || {};
|
|
1801
|
+
// issue #20: the Savings card's "Turn on smart routing" CTA must reflect what was actually saved —
|
|
1802
|
+
// same tri-state rule gatherConfig() uses below, so this and the Settings tab never disagree. null
|
|
1803
|
+
// means never chosen, and the card must say that rather than paint a green ON chip over a default.
|
|
1804
|
+
savings.routing = cfgNow.routing === 'off' ? 'off' : cfgNow.routing === 'auto' ? 'auto' : null;
|
|
1805
|
+
// A PREFERENCE IS NOT A CAPABILITY. Saving routing:'auto' records an intention; it does not install
|
|
1806
|
+
// agentic-flow, and the Savings card claiming "Smart routing: ON" while the Capabilities card said
|
|
1807
|
+
// "not installed" — in the same render — was the contradiction that made the whole page untrustworthy.
|
|
1808
|
+
// The card now carries the same measurement the capability row uses, so the two cannot disagree.
|
|
1809
|
+
savings.routingInstalled = fs.existsSync(path.join(HOME, '.npm-global/bin/agentic-flow'));
|
|
1810
|
+
try { savings.routerEngine = gatherRouterEngine(); } catch { savings.routerEngine = null; }
|
|
1811
|
+
try {
|
|
1812
|
+
const cat = loadCatalog();
|
|
1813
|
+
const det = detectProvider(cat, { provider: cfgNow.provider });
|
|
1814
|
+
savings.utilization = utilization({ frontier: frontierFor(cat, det.provider) });
|
|
1815
|
+
} catch { try { savings.utilization = utilization({}); } catch { savings.utilization = null; } }
|
|
1816
|
+
const config = gatherConfig();
|
|
1817
|
+
const userSettings = gatherAdvocacy();
|
|
1818
|
+
// ADR-054: its own section, never folded into userSettings — see gatherBrainPower()'s header for
|
|
1819
|
+
// the three reasons. A failure here must not blank the page: the switch's own surface degrading is
|
|
1820
|
+
// no reason to lose the rest of the machine's state.
|
|
1821
|
+
let brainPower = null;
|
|
1822
|
+
try { brainPower = gatherBrainPower(); } catch { brainPower = null; }
|
|
1823
|
+
let gates = null;
|
|
1824
|
+
try { gates = gatesSurvey({ repo: REPO }); } catch { gates = null; }
|
|
1825
|
+
const recommendations = buildWiringRecommendations({ sites: wiring.sites });
|
|
1826
|
+
// The capability ⇄ recommendation bridge (see computeCapabilities()'s recId stamp, same idea here):
|
|
1827
|
+
// this is what makes `#rec-enable:memory-distillation` actually exist in the DOM for jumpToRec to
|
|
1828
|
+
// scroll to. Advisory-only, so a bug here must degrade to "no capability recs offered", never break
|
|
1829
|
+
// the rest of /api/state.
|
|
1830
|
+
try {
|
|
1831
|
+
const capRows = capabilityAuditAll({ project: cwd });
|
|
1832
|
+
recommendations.push(...buildCapabilityRecommendations({ capabilities: capRows }));
|
|
1833
|
+
} catch { /* an advisory surface must never break state */ }
|
|
1834
|
+
// Relevance order (never alphabetical/walk-order): machine-wide first, then projects by when
|
|
1835
|
+
// the user last actually worked in them — read from each project's own memory store.
|
|
1836
|
+
{
|
|
1837
|
+
const touched = {};
|
|
1838
|
+
for (const p of (ACTIVITY_MACHINE_CACHE && ACTIVITY_MACHINE_CACHE.projects) || []) {
|
|
1839
|
+
if (p.rel) touched[p.rel] = p.lastTouched || 0;
|
|
1840
|
+
touched[p.name] = Math.max(touched[p.name] || 0, p.lastTouched || 0);
|
|
1841
|
+
}
|
|
1842
|
+
const rank = (r) => r.id.startsWith('reconcile:') ? (touched[r.id.slice('reconcile:'.length)] || 0) : Number.MAX_SAFE_INTEGER;
|
|
1843
|
+
recommendations.sort((a, b) => rank(b) - rank(a));
|
|
1844
|
+
}
|
|
1845
|
+
// A cheap fingerprint of the state the page is about to render. The page echoes it back on apply;
|
|
1846
|
+
// apply's authoritative guard is still per-recommendation re-verification (currentValidIds), but
|
|
1847
|
+
// this lets the UI reason about staleness too.
|
|
1848
|
+
const preStateHash = crypto.createHash('sha1')
|
|
1849
|
+
.update(JSON.stringify({ recs: recommendations.map((r) => r.id).sort(), wiring: wiring.summary }))
|
|
1850
|
+
.digest('hex').slice(0, 16);
|
|
1851
|
+
const result = {
|
|
1852
|
+
token: TOKEN,
|
|
1853
|
+
generatedAt: new Date().toISOString(),
|
|
1854
|
+
preStateHash,
|
|
1855
|
+
host: { user: os.userInfo().username, platform: process.platform, node: process.version, npmPrefix: NPM_PREFIX.replace(HOME, '~'), brainVersion: brainVersionOnDisk() },
|
|
1856
|
+
sections: { wiring, memory, savings, config, userSettings, brainPower, gates, recommendations },
|
|
1857
|
+
};
|
|
1858
|
+
// Cache the last good state so repeat page-loads paint instantly, same as the stack audit does.
|
|
1859
|
+
// TOKEN is per-server-run and must never touch disk — ?fast=1 splices the live one back in.
|
|
1860
|
+
//
|
|
1861
|
+
// TASK 4: routed through the shared atomic writeCache(), not a private writeFileSync. This used to
|
|
1862
|
+
// be a SECOND, non-atomic writer to the exact same STATE_CACHE path that serveCached()'s own
|
|
1863
|
+
// writeCache() call (and the --refresh-cache CLI branch) also write, right after calling this very
|
|
1864
|
+
// function — a bare writeFileSync racing an atomic rename on one file defeats the atomicity of the
|
|
1865
|
+
// other writer, because a reader can still land mid-truncate from THIS one. writeCache() is already
|
|
1866
|
+
// best-effort internally (never throws), so no extra try/catch is needed here.
|
|
1867
|
+
const { token, ...safe } = result;
|
|
1868
|
+
writeCache(STATE_CACHE, result.generatedAt, safe, cwd); // project-scoped stamp
|
|
1869
|
+
return result;
|
|
1870
|
+
}
|
|
1871
|
+
function gatherStack() {
|
|
1872
|
+
const a = auditModel();
|
|
1873
|
+
// ISSUE #22 — carry `source` ('npm-global' | 'plugin') + marketplace through so the console can show
|
|
1874
|
+
// (and count) tools installed via the Claude Code plugin marketplace, not just `npm install -g` ones.
|
|
1875
|
+
const rows = a.rows.map((r) => ({ name: r.name, installed: r.installed, target: r.target, tag: r.tag, state: r.state, source: r.source ?? 'npm-global', marketplace: r.marketplace ?? null }));
|
|
1876
|
+
const shadows = a.shadows.map((s) => ({ name: s.name, version: s.version, global: s.global, dir: String(s.dir).replace(HOME, '~'), stale: !!(s.global && s.version !== s.global) }));
|
|
1877
|
+
const by = (st) => rows.filter((r) => r.state === st).length;
|
|
1878
|
+
const summary = { total: rows.length, behind: by('BEHIND'), broken: by('BROKEN'), ahead: by('AHEAD'), current: by('CURRENT'), unresolved: by('UNRESOLVED'), shadows: shadows.length, stale: a.stale.length };
|
|
1879
|
+
const recommendations = buildStackRecommendations({ rows: a.rows, stale: a.stale });
|
|
1880
|
+
const result = { error: a.error, packages: rows, shadows, summary, recommendations };
|
|
1881
|
+
// Cache the last good audit so repeat page-loads render instantly ("as of HH:MM — re-checking").
|
|
1882
|
+
//
|
|
1883
|
+
// TASK 4: routed through the shared atomic writeCache(), same reasoning as gatherState() above —
|
|
1884
|
+
// this used to be a private writeFileSync straight to STACK_CACHE's own path (a SECOND, non-atomic
|
|
1885
|
+
// writer racing the serveCached()/--refresh-cache callers that also write this exact file right
|
|
1886
|
+
// after calling this function). The timestamp is taken HERE, after `result` above is already fully
|
|
1887
|
+
// built from `auditModel()`'s completed scan (Task 3) — never before it, unlike the two callers this
|
|
1888
|
+
// fix also corrects.
|
|
1889
|
+
if (!a.error) writeCache(STACK_CACHE, new Date().toISOString(), result);
|
|
1890
|
+
return result;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
// ── The ONLY writer: apply / save / undo ─────────────────────────────────────────────────────────
|
|
1894
|
+
function journalUndo(entry) {
|
|
1895
|
+
fs.mkdirSync(path.dirname(UNDO_JOURNAL), { recursive: true });
|
|
1896
|
+
const token = crypto.randomBytes(9).toString('hex');
|
|
1897
|
+
fs.appendFileSync(UNDO_JOURNAL, JSON.stringify({ token, at: new Date().toISOString(), ...entry }) + '\n');
|
|
1898
|
+
return token;
|
|
1899
|
+
}
|
|
1900
|
+
function runNode(scriptRelPath, args) {
|
|
1901
|
+
const r = spawnSync(process.execPath, [path.join(REPO, scriptRelPath), ...args], { encoding: 'utf8', timeout: 16 * 60 * 1000, cwd: REPO });
|
|
1902
|
+
return { ok: r.status === 0, code: r.status, log: `${r.stdout || ''}${r.stderr || ''}`.trim().slice(-4000) };
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// A wiring recommendation's `project` id is relative to whichever candidate root it was found under
|
|
1906
|
+
// (issue #19) — reconstruct the absolute path by checking each root, so reconcile/undo can act on a
|
|
1907
|
+
// project under ~/source just as well as one under ~/Code.
|
|
1908
|
+
function resolveProjectDir(project) {
|
|
1909
|
+
for (const root of candidateRoots()) {
|
|
1910
|
+
const p = path.join(root, project);
|
|
1911
|
+
if (fs.existsSync(p)) return p;
|
|
1912
|
+
}
|
|
1913
|
+
return path.join(HOME, 'Code', project); // last-resort fallback: the previous fixed behavior
|
|
1914
|
+
}
|
|
1915
|
+
// Re-derive the currently-valid recommendation set, so apply can only ever act on something STILL true.
|
|
1916
|
+
/**
|
|
1917
|
+
* Observe the learner's REAL state, for the health recommendations.
|
|
1918
|
+
*
|
|
1919
|
+
* Deliberately reads the GLOBAL learner (`cwd: HOME`), because that is the store the capture flush
|
|
1920
|
+
* actually writes to. Reading the project-local `.claude-flow/neural` instead is exactly the mistake
|
|
1921
|
+
* that made the console display a dead learner (5 trajectories, last trained 6 days earlier) while
|
|
1922
|
+
* the live one held 412 — rUv documents this fragmentation as issue #2245, "four contradictory
|
|
1923
|
+
* sources". Until it is unified upstream we read the store that learning writes, never the corpse.
|
|
1924
|
+
*/
|
|
1925
|
+
function observeLearning() {
|
|
1926
|
+
const queueDir = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'learn');
|
|
1927
|
+
let queueDepth = 0;
|
|
1928
|
+
try {
|
|
1929
|
+
for (const f of fs.readdirSync(queueDir)) {
|
|
1930
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
1931
|
+
queueDepth += fs.readFileSync(path.join(queueDir, f), 'utf8').split('\n').filter(Boolean).length;
|
|
1932
|
+
}
|
|
1933
|
+
} catch { /* no queue dir yet — depth stays 0, which is honest */ }
|
|
1934
|
+
|
|
1935
|
+
let lastTrainSeconds = null; let trajectories = 0;
|
|
1936
|
+
try {
|
|
1937
|
+
const r = spawnSync(path.join(os.homedir(), '.npm-global/bin/ruflo'),
|
|
1938
|
+
['hooks', 'intelligence', '--status'],
|
|
1939
|
+
{
|
|
1940
|
+
cwd: os.homedir(),
|
|
1941
|
+
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
|
|
1942
|
+
encoding: 'utf8',
|
|
1943
|
+
timeout: 20_000,
|
|
1944
|
+
});
|
|
1945
|
+
const out = `${r.stdout || ''}`;
|
|
1946
|
+
const t = out.match(/Last Training:\s*(\d+)s ago/);
|
|
1947
|
+
const j = out.match(/Trajectories\s*\|\s*(\d+)/);
|
|
1948
|
+
if (t) lastTrainSeconds = Number(t[1]);
|
|
1949
|
+
if (j) trajectories = Number(j[1]);
|
|
1950
|
+
} catch { /* ruflo absent or slow — leave null, and null NEVER produces a recommendation */ }
|
|
1951
|
+
|
|
1952
|
+
// The fleet is what makes ADR-027's North Star recommendation constructible at all — without it,
|
|
1953
|
+
// `learning:distill-fleet` can never be built, so it can never be offered, so clicking it would be
|
|
1954
|
+
// rejected as "your machine changed". It was missing here, which is exactly how a recommendation
|
|
1955
|
+
// ends up existing in code and nowhere else.
|
|
1956
|
+
//
|
|
1957
|
+
// Read from the cache the /api/memory scan already writes: a live scan opens 100+ SQLite stores at
|
|
1958
|
+
// ~90ms each, which is far too slow to sit on this path. A cold cache honestly yields [] — and []
|
|
1959
|
+
// produces no recommendation, which is the correct answer when we have not looked.
|
|
1960
|
+
const fleet = readJSON(MEMORY_CACHE)?.data?.fleet ?? [];
|
|
1961
|
+
|
|
1962
|
+
return { queueDepth, lastTrainSeconds, trajectories, fleet };
|
|
1963
|
+
}
|
|
1964
|
+
|
|
1965
|
+
function currentValidIds(onlyId = null) {
|
|
1966
|
+
const ids = new Set();
|
|
1967
|
+
const wiringOnly = typeof onlyId === 'string' && onlyId.startsWith('reconcile:');
|
|
1968
|
+
const stackOnly = typeof onlyId === 'string'
|
|
1969
|
+
&& (onlyId === 'purge:shadows'
|
|
1970
|
+
|| onlyId.startsWith('sync:')
|
|
1971
|
+
|| (onlyId.startsWith('repair:') && onlyId !== 'repair:memory-index'));
|
|
1972
|
+
const healthOnly = onlyId === 'repair:memory-index'
|
|
1973
|
+
|| (typeof onlyId === 'string' && onlyId.startsWith('learning:'));
|
|
1974
|
+
const capabilityOnly = typeof onlyId === 'string' && onlyId.startsWith('enable:');
|
|
1975
|
+
const validateAll = !wiringOnly && !stackOnly && !healthOnly && !capabilityOnly;
|
|
1976
|
+
|
|
1977
|
+
if (validateAll || wiringOnly) {
|
|
1978
|
+
for (const r of buildWiringRecommendations({ sites: wiringSurvey().sites })) ids.add(r.id);
|
|
1979
|
+
}
|
|
1980
|
+
let auditRows = [];
|
|
1981
|
+
if (validateAll || stackOnly) {
|
|
1982
|
+
const a = auditModel();
|
|
1983
|
+
auditRows = a.rows;
|
|
1984
|
+
for (const r of buildStackRecommendations({ rows: a.rows, stale: a.stale })) ids.add(r.id);
|
|
1985
|
+
}
|
|
1986
|
+
// Health + learning. Previously the console could SEE a corrupt store and score it 49/100 while
|
|
1987
|
+
// offering nothing to do about it — detection without a remedy, which ADR-027 prohibits.
|
|
1988
|
+
if (validateAll || healthOnly) {
|
|
1989
|
+
try {
|
|
1990
|
+
const project = process.cwd();
|
|
1991
|
+
const health = scoreMemoryHealth({ project: path.basename(project), probes: probeMemory(project) });
|
|
1992
|
+
for (const r of buildHealthRecommendations({ memory: health, learning: observeLearning() })) ids.add(r.id);
|
|
1993
|
+
} catch { /* an advisory surface must never break the apply path */ }
|
|
1994
|
+
}
|
|
1995
|
+
// Capability recs (e.g. `enable:memory-distillation`) — without this, clicking the one recommended
|
|
1996
|
+
// capability checkbox would always report "already resolved / your machine changed", because apply()
|
|
1997
|
+
// only ever accepts ids this function has vouched for. Separate try from the health block above so a
|
|
1998
|
+
// failure in one surface never silently hides the other's ids too.
|
|
1999
|
+
if (validateAll || capabilityOnly) {
|
|
2000
|
+
try {
|
|
2001
|
+
for (const r of buildCapabilityRecommendations({ capabilities: capabilityAuditAll({ project: process.cwd() }) })) ids.add(r.id);
|
|
2002
|
+
} catch { /* an advisory surface must never break the apply path */ }
|
|
2003
|
+
}
|
|
2004
|
+
return { ids, auditRows };
|
|
2005
|
+
}
|
|
2006
|
+
function apply(ids) {
|
|
2007
|
+
const results = [];
|
|
2008
|
+
for (const id of ids) {
|
|
2009
|
+
// Re-read immediately before EACH fix. A batch can change the validity of the next item; one
|
|
2010
|
+
// pre-flight snapshot for the whole list would let item 2 run against the world item 1 changed.
|
|
2011
|
+
const { ids: validNow } = currentValidIds(id);
|
|
2012
|
+
if (!validNow.has(id)) { results.push({ id, ok: false, skipped: true, error: 'worldMoved', log: 'Skipped — this is already resolved, or your machine changed since the page loaded. Nothing was done. Reload to see the current state.' }); continue; }
|
|
2013
|
+
|
|
2014
|
+
// ONE dispatch, through the registry (scripts/remedy-registry.mjs). This used to be a chain of
|
|
2015
|
+
// `if (id.startsWith(...))` whose handled-id set no code could inspect — so it drifted from the
|
|
2016
|
+
// builders and nothing noticed: `learning:enable-fleet` was offered with NO executor and fell
|
|
2017
|
+
// through to "Unknown recommendation id", and one reordering silently routed a database repair
|
|
2018
|
+
// into a global npm sync. Now the id→executor→inverse binding is a value, an ambiguous id
|
|
2019
|
+
// THROWS instead of picking a winner, and remedy-registry.test.mjs proves every offerable id
|
|
2020
|
+
// resolves to exactly one runnable remedy with a real undo behind it.
|
|
2021
|
+
let plan;
|
|
2022
|
+
try { plan = planFor(id); }
|
|
2023
|
+
catch (e) { results.push({ id, ok: false, log: e.message }); continue; } // ambiguous — a bug, said out loud
|
|
2024
|
+
if (!plan) { results.push({ id, ok: false, log: `Unknown recommendation id: ${id}` }); continue; }
|
|
2025
|
+
|
|
2026
|
+
// Record the inverse BEFORE the change, and fill in the parts only this moment knows.
|
|
2027
|
+
const undoSpec = { ...plan.undo, id };
|
|
2028
|
+
if (undoSpec.kind === 'reinstall-version') {
|
|
2029
|
+
const prev = installedVersion(undoSpec.pkg);
|
|
2030
|
+
// No readable previous version ⇒ there is nothing to reinstall. Say that, rather than
|
|
2031
|
+
// journalling an inverse that would fail later while looking recorded.
|
|
2032
|
+
if (prev) undoSpec.prevVersion = prev; else { undoSpec.kind = 'auto-rebuild'; undoSpec.human = `no previous version of ${undoSpec.pkg} was readable, so there is nothing to roll back to`; }
|
|
2033
|
+
}
|
|
2034
|
+
if (undoSpec.kind === 'restore-memory-backup') undoSpec.db = path.join(process.cwd(), '.swarm/memory.db');
|
|
2035
|
+
// The console is scoped to ONE project (process.cwd()) for its whole life — same fact
|
|
2036
|
+
// `restore-memory-backup` just used above, recorded here too so undo() can hand it straight back
|
|
2037
|
+
// to distill-project.mjs's own `--restore`.
|
|
2038
|
+
if (undoSpec.kind === 'restore-project-distill') undoSpec.project = process.cwd();
|
|
2039
|
+
|
|
2040
|
+
let args = [...plan.exec.args];
|
|
2041
|
+
if (plan.exec.resolveProject) {
|
|
2042
|
+
const i = args.indexOf('--project');
|
|
2043
|
+
if (i >= 0) args[i + 1] = resolveProjectDir(args[i + 1]);
|
|
2044
|
+
}
|
|
2045
|
+
// `usesServerProject`: this remedy's script must run against the ACTUAL project the console is
|
|
2046
|
+
// serving, never REPO — runNode() spawns every script with `cwd: REPO` (see its own comment),
|
|
2047
|
+
// so a script that fell back to its own `process.cwd()` default would silently describe THIS
|
|
2048
|
+
// package's checkout instead of the user's project. That exact REPO-vs-project confusion is
|
|
2049
|
+
// capability-registry.mjs's own header's "single most damaging bug this file has shipped"; this
|
|
2050
|
+
// flag exists so it cannot recur here.
|
|
2051
|
+
if (plan.exec.usesServerProject) args = [...args, '--project', process.cwd()];
|
|
2052
|
+
if (plan.exec.needsReceipt) {
|
|
2053
|
+
const receipt = path.join(HOME, '.cache', 'ruvnet-brain', 'undo', `${plan.key}-${stamp()}.json`);
|
|
2054
|
+
undoSpec.receipt = receipt;
|
|
2055
|
+
args = [...args, '--receipt', receipt];
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
const undoToken = journalUndo(undoSpec);
|
|
2059
|
+
const res = runNode(plan.exec.script, args);
|
|
2060
|
+
results.push({ id, ...res, undoToken });
|
|
2061
|
+
}
|
|
2062
|
+
return { results };
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
function autoEligibleIds(recommendations = []) {
|
|
2066
|
+
return recommendations
|
|
2067
|
+
.filter((rec) => rec?.scope === 'project')
|
|
2068
|
+
.filter((rec) => {
|
|
2069
|
+
const plan = planFor(rec.id);
|
|
2070
|
+
return plan?.autoEligible === true && plan.undo?.kind !== 'none';
|
|
2071
|
+
})
|
|
2072
|
+
.map((rec) => rec.id);
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* VALIDATE AGAINST THE SCHEMA THAT IS ALREADY DECLARED. Without this, `/api/save-config` wrote
|
|
2076
|
+
* whatever arrived: MEASURED, `{routing:'banana', nightly:'yes-please', provider:{evil:1}}` landed in
|
|
2077
|
+
* the config file verbatim, and gatherConfig then read `routing:'banana'` as "not off" and rendered
|
|
2078
|
+
* it as ON. Every one of these keys has its type and its allowed values stated ten lines up in
|
|
2079
|
+
* CONFIG_SCHEMA; nothing was consulting them.
|
|
2080
|
+
*
|
|
2081
|
+
* Rejected values are REPORTED, not silently dropped and not silently coerced — a bad value must not
|
|
2082
|
+
* quietly become a different setting than the one the user believes they chose.
|
|
2083
|
+
*/
|
|
2084
|
+
function validateConfigPatch(values) {
|
|
2085
|
+
const clean = {};
|
|
2086
|
+
const rejected = [];
|
|
2087
|
+
for (const s of CONFIG_SCHEMA) {
|
|
2088
|
+
const v = values?.[s.key];
|
|
2089
|
+
if (v === undefined || v === null) continue;
|
|
2090
|
+
if (s.secret) {
|
|
2091
|
+
// Only overwrite a secret when a real new value is typed — '••••' is the masked placeholder
|
|
2092
|
+
// the form echoes back, and treating it as a new key would destroy the stored one.
|
|
2093
|
+
if (typeof v === 'string' && v.trim() && v !== '••••') clean[s.key] = v.trim();
|
|
2094
|
+
else if (typeof v !== 'string') rejected.push({ key: s.key, reason: 'expected a string' });
|
|
2095
|
+
continue;
|
|
2096
|
+
}
|
|
2097
|
+
if (s.type === 'bool') {
|
|
2098
|
+
if (typeof v === 'boolean') clean[s.key] = v;
|
|
2099
|
+
else rejected.push({ key: s.key, reason: `expected true or false, got ${JSON.stringify(v)}` });
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
if (s.type === 'enum') {
|
|
2103
|
+
if (typeof v === 'string' && s.options.includes(v)) clean[s.key] = v;
|
|
2104
|
+
else rejected.push({ key: s.key, reason: `expected one of ${s.options.join(', ')}, got ${JSON.stringify(v)}` });
|
|
2105
|
+
continue;
|
|
2106
|
+
}
|
|
2107
|
+
if (typeof v === 'string') clean[s.key] = v;
|
|
2108
|
+
else rejected.push({ key: s.key, reason: 'expected a string' });
|
|
2109
|
+
}
|
|
2110
|
+
// Keys not in the schema never reach disk. The old writer only ever copied schema keys either, but
|
|
2111
|
+
// it did so while trusting their values, which is the half of the job that mattered.
|
|
2112
|
+
return { clean, rejected };
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
/**
|
|
2116
|
+
* SAVE — the writer users actually reach, and now the one that is actually safe.
|
|
2117
|
+
*
|
|
2118
|
+
* This function used to be the counter-example to the entire user-settings.mjs module sitting beside
|
|
2119
|
+
* it: that file has a lock, an atomic rename, an exclusive-create backup and a from-the-future
|
|
2120
|
+
* refusal, all tested — and ZERO non-test callers, while this truncating, unlocked, unvalidated
|
|
2121
|
+
* writeFileSync served every click on the page. The hardening was real and unreachable.
|
|
2122
|
+
*
|
|
2123
|
+
* Now it borrows those primitives directly rather than growing a second, weaker copy of them:
|
|
2124
|
+
* withLock two Claude Code sessions on one machine is the normal case, not the exotic one, and
|
|
2125
|
+
* read-modify-write without a lock loses whichever key the loser wrote.
|
|
2126
|
+
* read-inside `prev` is re-read INSIDE the lock; reading before acquiring reintroduces the race.
|
|
2127
|
+
* writeAtomic writeFileSync truncates first, so a crash mid-write leaves an EMPTY config and the
|
|
2128
|
+
* user's answers are gone having passed the backup step successfully.
|
|
2129
|
+
* backup 'wx' exclusive creation, so a racing writer cannot overwrite the backup this save's undo
|
|
2130
|
+
* token points at.
|
|
2131
|
+
*/
|
|
2132
|
+
function saveConfig(values) {
|
|
2133
|
+
try { fs.mkdirSync(CONFIG_DIR, { recursive: true }); }
|
|
2134
|
+
catch (e) { return { ok: false, log: `could not create ${CONFIG_DIR.replace(HOME, '~')}: ${e.message}` }; }
|
|
2135
|
+
|
|
2136
|
+
const { clean, rejected } = validateConfigPatch(values);
|
|
2137
|
+
// Nothing valid to write is not a save. Saying "Saved." here would be the dead-button failure with
|
|
2138
|
+
// a receipt attached.
|
|
2139
|
+
if (!Object.keys(clean).length) {
|
|
2140
|
+
return {
|
|
2141
|
+
ok: false,
|
|
2142
|
+
rejected,
|
|
2143
|
+
log: rejected.length
|
|
2144
|
+
? `nothing was saved — ${rejected.map((r) => `${r.key}: ${r.reason}`).join('; ')}`
|
|
2145
|
+
: 'nothing was saved — no recognised settings were supplied',
|
|
2146
|
+
};
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// Credential and scheduler changes are real effects, not JSON preferences. Execute their existing
|
|
2150
|
+
// owners, remember their inverses, then commit the ordinary config. If the final config write
|
|
2151
|
+
// fails, both effects are rolled back before the error is returned.
|
|
2152
|
+
const requestedSecret = clean.openrouterKey;
|
|
2153
|
+
delete clean.openrouterKey;
|
|
2154
|
+
const requestedNightly = clean.nightly;
|
|
2155
|
+
let credentialChange = null;
|
|
2156
|
+
let nightlyChange = null;
|
|
2157
|
+
const rollbackCredential = () => {
|
|
2158
|
+
if (!credentialChange?.ok) return;
|
|
2159
|
+
try {
|
|
2160
|
+
if (credentialChange.backup) {
|
|
2161
|
+
fs.copyFileSync(credentialChange.backup, credentialChange.path);
|
|
2162
|
+
} else if (!credentialChange.existed) {
|
|
2163
|
+
fs.rmSync(credentialChange.path, { force: true });
|
|
2164
|
+
}
|
|
2165
|
+
} catch { /* reported by the caller as a partial rollback below */ }
|
|
2166
|
+
};
|
|
2167
|
+
if (requestedSecret !== undefined) {
|
|
2168
|
+
credentialChange = saveOpenRouterCredential(requestedSecret, { cwd: process.cwd() });
|
|
2169
|
+
if (!credentialChange.ok) return { ok: false, rejected, log: credentialChange.log };
|
|
2170
|
+
}
|
|
2171
|
+
if (requestedNightly !== undefined) {
|
|
2172
|
+
nightlyChange = applyNightlyChoice(requestedNightly);
|
|
2173
|
+
if (!nightlyChange.ok) {
|
|
2174
|
+
rollbackCredential();
|
|
2175
|
+
return { ok: false, rejected, log: nightlyChange.log };
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
const held = withLock(CONFIG_PATH, () => {
|
|
2180
|
+
const existed = fs.existsSync(CONFIG_PATH);
|
|
2181
|
+
const prev = readJSON(CONFIG_PATH) || {};
|
|
2182
|
+
|
|
2183
|
+
let backup = null;
|
|
2184
|
+
if (existed) {
|
|
2185
|
+
const base = `${CONFIG_PATH}.bak-${stamp()}`;
|
|
2186
|
+
backup = base;
|
|
2187
|
+
for (let n = 2; fs.existsSync(backup); n++) backup = `${base}-${String(n).padStart(2, '0')}`;
|
|
2188
|
+
try { fs.writeFileSync(backup, fs.readFileSync(CONFIG_PATH), { flag: 'wx', mode: 0o600 }); }
|
|
2189
|
+
catch (e) { return { ok: false, log: `refusing to write — backup failed: ${e.message}` }; }
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
const next = { ...prev, ...clean };
|
|
2193
|
+
// A successfully encrypted credential retires the legacy plaintext field on this same commit.
|
|
2194
|
+
if (credentialChange?.ok) delete next.openrouterKey;
|
|
2195
|
+
try { writeAtomic(CONFIG_PATH, JSON.stringify(next, null, 2) + '\n'); }
|
|
2196
|
+
catch (e) { return { ok: false, backup, log: `write failed: ${e.message}${backup ? `; your previous settings are at ${backup.replace(HOME, '~')}` : ''}` }; }
|
|
2197
|
+
try { fs.chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort on non-posix */ }
|
|
2198
|
+
|
|
2199
|
+
// The undo token is journalled only AFTER the write succeeded. Recording an undo for a save that
|
|
2200
|
+
// never happened hands the user a button that would revert a change they never made.
|
|
2201
|
+
const undoToken = journalUndo({
|
|
2202
|
+
kind: 'restore-config',
|
|
2203
|
+
backup,
|
|
2204
|
+
existed,
|
|
2205
|
+
nightlyBefore: nightlyChange?.before?.state ?? null,
|
|
2206
|
+
secretBackup: credentialChange?.backup ?? null,
|
|
2207
|
+
secretPath: credentialChange?.path ?? null,
|
|
2208
|
+
secretExisted: credentialChange?.existed ?? null,
|
|
2209
|
+
});
|
|
2210
|
+
return { ok: true, backup: backup ? backup.replace(HOME, '~') : null, undoToken, rejected };
|
|
2211
|
+
});
|
|
2212
|
+
|
|
2213
|
+
if (held.timedOut) {
|
|
2214
|
+
if (nightlyChange?.ok && ['on', 'off'].includes(nightlyChange.before?.state)) {
|
|
2215
|
+
applyNightlyChoice(nightlyChange.before.state === 'on');
|
|
2216
|
+
}
|
|
2217
|
+
rollbackCredential();
|
|
2218
|
+
return {
|
|
2219
|
+
ok: false,
|
|
2220
|
+
rejected,
|
|
2221
|
+
log: `another process is writing your settings and did not finish within ${LOCK_WAIT_MS}ms — nothing was written; try again`,
|
|
2222
|
+
};
|
|
2223
|
+
}
|
|
2224
|
+
if (!held.value?.ok) {
|
|
2225
|
+
if (nightlyChange?.ok && ['on', 'off'].includes(nightlyChange.before?.state)) {
|
|
2226
|
+
applyNightlyChoice(nightlyChange.before.state === 'on');
|
|
2227
|
+
}
|
|
2228
|
+
rollbackCredential();
|
|
2229
|
+
}
|
|
2230
|
+
if (held.value?.ok) publishSettingsToCache();
|
|
2231
|
+
return held.value;
|
|
2232
|
+
}
|
|
2233
|
+
/** Read the append-only undo journal. Malformed lines are skipped; they are not entries. */
|
|
2234
|
+
function readUndoJournal() {
|
|
2235
|
+
if (!fs.existsSync(UNDO_JOURNAL)) return [];
|
|
2236
|
+
try {
|
|
2237
|
+
return fs.readFileSync(UNDO_JOURNAL, 'utf8').split('\n').filter(Boolean)
|
|
2238
|
+
.map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
|
|
2239
|
+
} catch { return []; }
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
/**
|
|
2243
|
+
* An undo is spent once it is used. The journal is append-only, so "spent" is itself an appended
|
|
2244
|
+
* record rather than a mutation — same reason the project-state checkpoint is append-only.
|
|
2245
|
+
*/
|
|
2246
|
+
function markUndoConsumed(token) {
|
|
2247
|
+
try { fs.appendFileSync(UNDO_JOURNAL, JSON.stringify({ consumed: token, at: new Date().toISOString() }) + '\n'); }
|
|
2248
|
+
catch { /* the restore already happened; failing to record it must not un-happen it */ }
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function restoreConfigEffects(entry) {
|
|
2252
|
+
const failures = [];
|
|
2253
|
+
if (entry.secretPath) {
|
|
2254
|
+
try {
|
|
2255
|
+
if (entry.secretBackup && fs.existsSync(entry.secretBackup)) fs.copyFileSync(entry.secretBackup, entry.secretPath);
|
|
2256
|
+
else if (entry.secretExisted === false) fs.rmSync(entry.secretPath, { force: true });
|
|
2257
|
+
} catch (error) {
|
|
2258
|
+
failures.push(`encrypted credential restore failed: ${error.message}`);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
if (entry.nightlyBefore === 'on' || entry.nightlyBefore === 'off') {
|
|
2262
|
+
const restored = applyNightlyChoice(entry.nightlyBefore === 'on');
|
|
2263
|
+
if (!restored.ok) failures.push(restored.log);
|
|
2264
|
+
}
|
|
2265
|
+
return failures;
|
|
2266
|
+
}
|
|
2267
|
+
|
|
2268
|
+
function undo(undoToken) {
|
|
2269
|
+
if (!fs.existsSync(UNDO_JOURNAL)) return { ok: false, log: 'no undo history' };
|
|
2270
|
+
const journal = readUndoJournal();
|
|
2271
|
+
const entry = journal.find((e) => e.token === undoToken);
|
|
2272
|
+
if (!entry) return { ok: false, log: 'that undo token was not found' };
|
|
2273
|
+
|
|
2274
|
+
// ONE UNDO, ONCE. The token was never consumed, so the same button replayed forever: clicking it
|
|
2275
|
+
// twice re-restored a backup over whatever the user had done in between, and reported success both
|
|
2276
|
+
// times. An undo you can accidentally apply to a state it was not computed against is a data-loss
|
|
2277
|
+
// button wearing a safety label.
|
|
2278
|
+
if (journal.some((e) => e.consumed === undoToken)) {
|
|
2279
|
+
return { ok: false, log: 'that undo has already been used — it cannot be applied twice, because what it would restore is no longer what came before' };
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
if (entry.kind === 'restore-config') {
|
|
2283
|
+
// A LATER SAVE MAKES THIS UNDO WRONG, and this was the worst defect on the page. MEASURED: save A,
|
|
2284
|
+
// save B, then click A's undo — the console reported "restored your previous settings" and B's
|
|
2285
|
+
// choices were silently gone, because A's backup predates B entirely. Reachable in a single
|
|
2286
|
+
// screen: saving Settings shows an undo button, clicking "Turn on smart routing" is a second save
|
|
2287
|
+
// through the same endpoint, and A's undo then reverts routing while the CTA still reads ON.
|
|
2288
|
+
//
|
|
2289
|
+
// An undo can only speak for the last write. If something was written after it, the honest answer
|
|
2290
|
+
// is to refuse and say so — restoring anyway would be destroying newer data while claiming to
|
|
2291
|
+
// protect older data.
|
|
2292
|
+
const laterSave = journal.some((e) => e.kind === 'restore-config' && e.at > entry.at && e.token !== undoToken);
|
|
2293
|
+
if (laterSave) {
|
|
2294
|
+
return { ok: false, log: 'your settings were saved again after this point, so this undo would wipe out that newer save — nothing was changed. Use the undo from the most recent save, or restore a backup by hand.' };
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
if (entry.backup && fs.existsSync(entry.backup)) {
|
|
2298
|
+
// Locked and atomic, matching the save path. A half-written config during an UNDO leaves the
|
|
2299
|
+
// user with neither their old settings nor their new ones.
|
|
2300
|
+
let held;
|
|
2301
|
+
try {
|
|
2302
|
+
const bytes = fs.readFileSync(entry.backup);
|
|
2303
|
+
held = withLock(CONFIG_PATH, () => writeAtomic(CONFIG_PATH, bytes));
|
|
2304
|
+
} catch (e) { return { ok: false, log: `restore failed: ${e.message} — your backup at ${entry.backup.replace(HOME, '~')} is intact` }; }
|
|
2305
|
+
if (held.timedOut) return { ok: false, log: `another process is writing your settings and did not finish within ${LOCK_WAIT_MS}ms — NOTHING was restored and your backup is intact; try again` };
|
|
2306
|
+
try { fs.chmodSync(CONFIG_PATH, 0o600); } catch { /* best effort on non-posix */ }
|
|
2307
|
+
const effectFailures = restoreConfigEffects(entry);
|
|
2308
|
+
if (effectFailures.length) {
|
|
2309
|
+
return { ok: false, log: `the settings file was restored, but ${effectFailures.join('; ')}. The undo remains available.` };
|
|
2310
|
+
}
|
|
2311
|
+
markUndoConsumed(undoToken);
|
|
2312
|
+
return { ok: true, log: 'restored your previous settings' };
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
if (!entry.existed && fs.existsSync(CONFIG_PATH)) {
|
|
2316
|
+
// The first-ever-save case: undo means removing the file. Guarded by the same later-save check
|
|
2317
|
+
// above — without it, this branch DELETED THE WHOLE CONFIG including every choice made after,
|
|
2318
|
+
// and reported "removed the settings file (there was none before)" as if that were harmless.
|
|
2319
|
+
let held;
|
|
2320
|
+
try { held = withLock(CONFIG_PATH, () => { fs.rmSync(CONFIG_PATH); return true; }); }
|
|
2321
|
+
catch (e) { return { ok: false, log: `could not remove the settings file: ${e.message}` }; }
|
|
2322
|
+
if (held.timedOut) return { ok: false, log: `another process is writing your settings and did not finish within ${LOCK_WAIT_MS}ms — nothing was removed; try again` };
|
|
2323
|
+
const effectFailures = restoreConfigEffects(entry);
|
|
2324
|
+
if (effectFailures.length) {
|
|
2325
|
+
return { ok: false, log: `the settings file was removed, but ${effectFailures.join('; ')}. The undo remains available.` };
|
|
2326
|
+
}
|
|
2327
|
+
markUndoConsumed(undoToken);
|
|
2328
|
+
return { ok: true, log: 'removed the settings file (there was none before this save)' };
|
|
2329
|
+
}
|
|
2330
|
+
return { ok: false, log: 'no backup available to restore' };
|
|
2331
|
+
}
|
|
2332
|
+
// EVERY branch below marks its token consumed on success, for the reason spelled out on the
|
|
2333
|
+
// restore-config branch above: these all copy a saved snapshot over a live file, so replaying one
|
|
2334
|
+
// re-applies an old state over whatever the user has done since. The replay guard at the top of
|
|
2335
|
+
// this function covers all kinds; these calls are what arm it.
|
|
2336
|
+
if (entry.kind === 'reinstall-version' && entry.pkg && entry.prevVersion) {
|
|
2337
|
+
const r = spawnSync('npm', ['install', '-g', '--prefix', NPM_PREFIX, `${entry.pkg}@${entry.prevVersion}`], { encoding: 'utf8', timeout: 15 * 60 * 1000 });
|
|
2338
|
+
if (r.status === 0) markUndoConsumed(undoToken);
|
|
2339
|
+
return { ok: r.status === 0, log: r.status === 0 ? `reinstalled ${entry.pkg}@${entry.prevVersion}` : (r.stderr || '').slice(-800) };
|
|
2340
|
+
}
|
|
2341
|
+
if (entry.kind === 'restore-backup' && entry.project) {
|
|
2342
|
+
const dir = resolveProjectDir(entry.project);
|
|
2343
|
+
let restored = 0;
|
|
2344
|
+
for (const f of ['.claude/settings.json', '.claude/settings.local.json', '.mcp.json']) {
|
|
2345
|
+
const target = path.join(dir, f);
|
|
2346
|
+
const baks = (() => { try { return fs.readdirSync(path.dirname(target)).filter((n) => n.startsWith(path.basename(target) + '.bak-reconcile-')); } catch { return []; } })();
|
|
2347
|
+
if (!baks.length) continue;
|
|
2348
|
+
baks.sort();
|
|
2349
|
+
fs.copyFileSync(path.join(path.dirname(target), baks[baks.length - 1]), target); restored++;
|
|
2350
|
+
}
|
|
2351
|
+
if (restored > 0) markUndoConsumed(undoToken);
|
|
2352
|
+
return { ok: restored > 0, log: restored ? `restored ${restored} settings file(s) from backup` : 'no reconcile backups found to restore' };
|
|
2353
|
+
}
|
|
2354
|
+
// THE BRANCH THAT DID NOT EXIST. `repair:memory-index` journalled kind 'restore-memory-backup'
|
|
2355
|
+
// and nothing here handled it, so it fell to the default arm below and answered "nothing to undo
|
|
2356
|
+
// (the change reverses itself automatically)" — while the recommendation had promised to restore
|
|
2357
|
+
// the pre-repair backup. health-repair.mjs writes that backup as `<db>.rescue-<iso>`; this finds
|
|
2358
|
+
// the newest one and puts it back.
|
|
2359
|
+
if (entry.kind === 'restore-memory-backup' && entry.db) {
|
|
2360
|
+
const dir = path.dirname(entry.db);
|
|
2361
|
+
const base = `${path.basename(entry.db)}.rescue-`;
|
|
2362
|
+
let baks = [];
|
|
2363
|
+
try { baks = fs.readdirSync(dir).filter((n) => n.startsWith(base)).sort(); } catch { /* dir gone */ }
|
|
2364
|
+
if (!baks.length) return { ok: false, log: `no pre-repair backup found next to ${entry.db.replace(HOME, '~')} — nothing was restored` };
|
|
2365
|
+
const from = path.join(dir, baks[baks.length - 1]);
|
|
2366
|
+
try { fs.copyFileSync(from, entry.db); }
|
|
2367
|
+
catch (e) { return { ok: false, log: `could not restore ${from.replace(HOME, '~')}: ${e.message}` }; }
|
|
2368
|
+
markUndoConsumed(undoToken);
|
|
2369
|
+
return { ok: true, log: `restored your memory store from the snapshot taken before the repair (${baks[baks.length - 1]})` };
|
|
2370
|
+
}
|
|
2371
|
+
// `enable:memory-distillation`'s inverse. Deliberately handed BACK to distill-project.mjs's own
|
|
2372
|
+
// `--restore` rather than re-derived here: it already knows where its snapshots live (that
|
|
2373
|
+
// project's `.swarm/backups`) and its restore path is the one proven end to end (see the script's
|
|
2374
|
+
// header: 644 → 648 → 644 → 648, 2026-07-24). Re-implementing "find the newest backup" a second time
|
|
2375
|
+
// in this file is exactly the duplicate-inverse pattern ADR-047 was rejected for.
|
|
2376
|
+
if (entry.kind === 'restore-project-distill' && entry.project) {
|
|
2377
|
+
const r = spawnSync(process.execPath,
|
|
2378
|
+
[path.join(REPO, 'scripts/distill-project.mjs'), '--project', entry.project, '--restore'],
|
|
2379
|
+
{ encoding: 'utf8', timeout: 5 * 60 * 1000 });
|
|
2380
|
+
if (r.status === 0) markUndoConsumed(undoToken);
|
|
2381
|
+
const out = `${r.stdout || ''}${r.stderr || ''}`.trim();
|
|
2382
|
+
return { ok: r.status === 0, log: out.slice(-2000) || (r.status === 0 ? 'restored the pre-distill snapshot' : 'restore failed') };
|
|
2383
|
+
}
|
|
2384
|
+
// Fleet distillation touches a set of stores discovered at run time, so its executor writes a
|
|
2385
|
+
// receipt naming each store it snapshotted. No receipt ⇒ we do not know what was touched, and we
|
|
2386
|
+
// say so instead of guessing — restoring the wrong snapshot over a live store is worse than
|
|
2387
|
+
// restoring nothing.
|
|
2388
|
+
if (entry.kind === 'restore-store-backups') {
|
|
2389
|
+
const rec = entry.receipt && fs.existsSync(entry.receipt) ? readJSON(entry.receipt) : null;
|
|
2390
|
+
const stores = Array.isArray(rec?.stores) ? rec.stores : [];
|
|
2391
|
+
if (!stores.length) return { ok: false, log: 'no receipt of which stores were distilled — nothing was restored. Each store\'s own snapshot is still in its .swarm/backups folder.' };
|
|
2392
|
+
let restored = 0; const failures = [];
|
|
2393
|
+
for (const s of stores) {
|
|
2394
|
+
let snaps = [];
|
|
2395
|
+
try { snaps = fs.readdirSync(s.backupDir).filter((n) => n.endsWith('.db') || n.includes('memory')).sort(); } catch { /* dir gone */ }
|
|
2396
|
+
if (!snaps.length) { failures.push(`${s.name}: no snapshot found`); continue; }
|
|
2397
|
+
try { fs.copyFileSync(path.join(s.backupDir, snaps[snaps.length - 1]), s.db); restored++; }
|
|
2398
|
+
catch (e) { failures.push(`${s.name}: ${e.message}`); }
|
|
2399
|
+
}
|
|
2400
|
+
return {
|
|
2401
|
+
ok: restored > 0,
|
|
2402
|
+
log: `${restored} of ${stores.length} store(s) restored from their pre-distill snapshots`
|
|
2403
|
+
+ (failures.length ? ` — could not restore: ${failures.join('; ')}` : ''),
|
|
2404
|
+
};
|
|
2405
|
+
}
|
|
2406
|
+
// Only kinds that genuinely reverse themselves reach here. Anything else arriving at this arm is
|
|
2407
|
+
// a registry/undo drift, and remedy-registry.test.mjs fails the build before it can reach a user.
|
|
2408
|
+
if (entry.kind === 'none' || entry.kind === 'auto-rebuild') {
|
|
2409
|
+
return { ok: true, log: entry.human || 'nothing to undo (the change reverses itself automatically)' };
|
|
2410
|
+
}
|
|
2411
|
+
return { ok: false, log: `no undo is implemented for "${entry.kind}" — nothing was changed back. Please report this.` };
|
|
2412
|
+
}
|
|
2413
|
+
// The undo kinds this function actually implements. Exported so the closure test can check the
|
|
2414
|
+
// registry against the REAL handler set rather than a hand-copied list that would drift from it.
|
|
2415
|
+
export const HANDLED_UNDO_KINDS = Object.freeze([
|
|
2416
|
+
'restore-config', 'reinstall-version', 'restore-backup',
|
|
2417
|
+
'restore-memory-backup', 'restore-store-backups', 'restore-project-distill', 'auto-rebuild', 'none',
|
|
2418
|
+
]);
|
|
2419
|
+
|
|
2420
|
+
// ── HTTP ─────────────────────────────────────────────────────────────────────────────────────────
|
|
2421
|
+
const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.webp': 'image/webp', '.jpg': 'image/jpeg', '.json': 'application/json', '.woff2': 'font/woff2' };
|
|
2422
|
+
function serveStatic(req, res) {
|
|
2423
|
+
const rel = decodeURIComponent(req.url.split('?')[0]).replace(/^\/+/, '') || 'index.html';
|
|
2424
|
+
const file = path.join(CONSOLE_DIR, rel);
|
|
2425
|
+
if (!file.startsWith(CONSOLE_DIR) || !fs.existsSync(file) || fs.statSync(file).isDirectory()) return send(res, 404, 'text/plain', 'not found');
|
|
2426
|
+
let body = fs.readFileSync(file);
|
|
2427
|
+
const ext = path.extname(file);
|
|
2428
|
+
if (ext === '.html') body = Buffer.from(String(body).replace('</head>', `<script>window.__CONSOLE_TOKEN__=${JSON.stringify(TOKEN)}</script></head>`));
|
|
2429
|
+
res.writeHead(200, { 'content-type': MIME[ext] || 'application/octet-stream', 'cache-control': 'no-store' });
|
|
2430
|
+
res.end(body);
|
|
2431
|
+
}
|
|
2432
|
+
function send(res, code, type, body) { res.writeHead(code, { 'content-type': type, 'cache-control': 'no-store' }); res.end(body); }
|
|
2433
|
+
function sendJSON(res, code, obj) { send(res, code, 'application/json', JSON.stringify(obj)); }
|
|
2434
|
+
function readBody(req) { return new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; if (b.length > 1e6) req.destroy(); }); req.on('end', () => { try { resolve(JSON.parse(b || '{}')); } catch { resolve({}); } }); }); }
|
|
2435
|
+
|
|
2436
|
+
/**
|
|
2437
|
+
* Open the console AND PUT IT IN FRONT OF THE USER.
|
|
2438
|
+
*
|
|
2439
|
+
* `open <url>` creates the tab but does NOT raise the browser window. Observed live 2026-07-21:
|
|
2440
|
+
* the console had been opened twice and was sitting in two Chrome tabs the whole time, behind
|
|
2441
|
+
* VS Code, while the user stared at their editor and reasonably concluded it was broken — and I
|
|
2442
|
+
* kept reporting "opened" because the command exited 0. Exit code 0 meant "a tab exists
|
|
2443
|
+
* somewhere", never "you can see it".
|
|
2444
|
+
*
|
|
2445
|
+
* So on macOS we also `activate` the browser. Raising a window the user asked for is not a
|
|
2446
|
+
* surprise; leaving them looking at the wrong app while claiming success is.
|
|
2447
|
+
*/
|
|
2448
|
+
/* ASYNCHRONOUS, ALWAYS (RVBC-INSTANT-SPEC #5). This runs inside the `server.listen()` callback, so
|
|
2449
|
+
every synchronous millisecond here is a millisecond the freshly-opened tab spends waiting for its
|
|
2450
|
+
own first byte. `spawnSync(open)` costs a launch-services round-trip and `spawnSync(osascript)`
|
|
2451
|
+
was capped at EIGHT SECONDS — which is to say the browser could have the tab while the server that
|
|
2452
|
+
is supposed to answer it was blocked, by the very act of opening it. Detached + unref'd spawns
|
|
2453
|
+
start the same processes without ever holding the loop. */
|
|
2454
|
+
function openBrowser(url) {
|
|
2455
|
+
const opener = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
2456
|
+
const bg = (cmd, args, opts = {}) => {
|
|
2457
|
+
try { const c = spawn(cmd, args, { stdio: 'ignore', detached: true, ...opts }); c.on('error', () => {}); c.unref(); }
|
|
2458
|
+
catch { /* headless is fine */ }
|
|
2459
|
+
};
|
|
2460
|
+
bg(opener, [url]);
|
|
2461
|
+
if (process.platform !== 'darwin') return;
|
|
2462
|
+
// Bring whichever browser now holds the tab to the front. Best-effort and silent: a failure here
|
|
2463
|
+
// must never break serving the page.
|
|
2464
|
+
{
|
|
2465
|
+
bg('osascript', ['-e', `
|
|
2466
|
+
tell application "System Events"
|
|
2467
|
+
set brs to name of every application process whose bundle identifier is in ¬
|
|
2468
|
+
{"com.google.Chrome","com.apple.Safari","company.thebrowser.Browser","org.mozilla.firefox","com.brave.Browser"}
|
|
2469
|
+
end tell
|
|
2470
|
+
repeat with b in brs
|
|
2471
|
+
try
|
|
2472
|
+
tell application (b as text) to activate
|
|
2473
|
+
exit repeat
|
|
2474
|
+
end try
|
|
2475
|
+
end repeat
|
|
2476
|
+
`], { timeout: 8000 }); // spawn's own timeout kills a wedged osascript; it never blocks us
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = false, cwd = process.cwd() } = {}) {
|
|
2480
|
+
const server = http.createServer(async (req, res) => {
|
|
2481
|
+
// DNS-rebinding guard: this server binds 127.0.0.1 only. Reject any request whose Host header
|
|
2482
|
+
// isn't loopback, so a malicious web page can't rebind a hostname to 127.0.0.1 and read local state.
|
|
2483
|
+
const reqHost = String(req.headers.host || '').split(':')[0].toLowerCase();
|
|
2484
|
+
if (reqHost !== '127.0.0.1' && reqHost !== 'localhost' && reqHost !== '::1' && reqHost !== '[::1]') {
|
|
2485
|
+
res.writeHead(403, { 'content-type': 'text/plain' }); res.end('forbidden host'); return;
|
|
2486
|
+
}
|
|
2487
|
+
try {
|
|
2488
|
+
const url = req.url.split('?')[0];
|
|
2489
|
+
// Heavy read-models: ALWAYS cache-first (fast=1 or not — both land here now). The handler
|
|
2490
|
+
// never blocks the event loop; kickRefresh() recomputes in a detached child. See writeCache/
|
|
2491
|
+
// serveCached above and the --refresh-cache CLI mode. TOKEN is injected at serve time so it
|
|
2492
|
+
// never has to live in the on-disk cache.
|
|
2493
|
+
if (req.method === 'GET' && url === '/api/state') {
|
|
2494
|
+
// project-scoped: never serve another project's cached state. The measuring lives in the
|
|
2495
|
+
// --refresh-cache child; this handler only ever reads a file and stamps the token on it.
|
|
2496
|
+
return serveCached(res, STATE_CACHE, (d) => ({ ...d, token: TOKEN }), cwd);
|
|
2497
|
+
}
|
|
2498
|
+
// ── /api/capabilities — "what do I own, and is it on?" ──────────────────────────────────────
|
|
2499
|
+
//
|
|
2500
|
+
// THE MISSING WIRE. capability-registry.mjs and capability-audit.mjs were both written, both
|
|
2501
|
+
// tested, and had ZERO call sites — a parallel reviewer found it with one grep. The client
|
|
2502
|
+
// referenced them only in COMMENTS. So the console could compute the single thing the owner
|
|
2503
|
+
// has asked for all night ("a ton of people don't know what is or isn't turned on because
|
|
2504
|
+
// it's very much a black box") and served it to nobody.
|
|
2505
|
+
//
|
|
2506
|
+
// That is this project's signature failure in its purest form: built, tested, unwired. It is
|
|
2507
|
+
// the same shape as the recommendation with no executor, and the advocacy engine that
|
|
2508
|
+
// rendered nowhere. Detection without delivery is a nicer way of doing nothing.
|
|
2509
|
+
//
|
|
2510
|
+
// Cached like the other heavy read-models — auditAll() shells out to real commands to derive
|
|
2511
|
+
// each state, which is far too slow for a first paint but is exactly why the answers are
|
|
2512
|
+
// trustworthy: every row is DERIVED on this machine, never asserted.
|
|
2513
|
+
if (req.method === 'GET' && url === '/api/capabilities') {
|
|
2514
|
+
return serveCached(res, CAPABILITY_CACHE, (d) => d, cwd);
|
|
2515
|
+
}
|
|
2516
|
+
if (req.method === 'GET' && url === '/api/memory') {
|
|
2517
|
+
// THE THESIS, FINALLY CONNECTED (ADR-027, 2026-07-22).
|
|
2518
|
+
//
|
|
2519
|
+
// buildHealthRecommendations() has existed since the ADR was written and was reachable ONLY
|
|
2520
|
+
// from apply() — i.e. only once a user clicked something that was never displayed. The brain
|
|
2521
|
+
// could see a corrupt store, a starving learner, and a fleet of memory stores that teach it
|
|
2522
|
+
// nothing, and it said none of it out loud. Every word in ADR-027 about the brain advocating
|
|
2523
|
+
// was true of the code and invisible to the person in front of it.
|
|
2524
|
+
//
|
|
2525
|
+
// It rides /api/memory rather than /api/state because it needs the fleet scan (100+ SQLite
|
|
2526
|
+
// stores at ~90ms each) and a `ruflo hooks intelligence --status` round-trip. That is far too
|
|
2527
|
+
// slow for first paint — so it is measured ONLY in the --refresh-cache child, which builds
|
|
2528
|
+
// the fleet and hands it straight to the recommendation builder so the advice is derived
|
|
2529
|
+
// from the same scan the user is looking at.
|
|
2530
|
+
return serveCached(res, MEMORY_CACHE, (d) => d, cwd); // project-scoped: health + recs are about THIS project
|
|
2531
|
+
}
|
|
2532
|
+
if (req.method === 'GET' && url === '/api/stack') {
|
|
2533
|
+
// Machine-level (no scopeKey): the installed stack is the same whichever project you opened
|
|
2534
|
+
// from. Measured only in the child — this is the endpoint that answered cold in 23,640 ms on
|
|
2535
|
+
// the request path before the instant-open fix.
|
|
2536
|
+
return serveCached(res, STACK_CACHE);
|
|
2537
|
+
}
|
|
2538
|
+
if (req.method === 'GET' && url === '/api/activity') return sendJSON(res, 200, gatherActivity(cwd));
|
|
2539
|
+
if (req.method === 'GET' && url === '/api/lessons') return sendJSON(res, 200, gatherLessons());
|
|
2540
|
+
if (req.method === 'GET' && url === '/api/trust') return sendJSON(res, 200, await gatherTrust());
|
|
2541
|
+
if (req.method === 'GET' && url === '/tips') { req.url = '/tips.html'; return serveStatic(req, res); }
|
|
2542
|
+
if (req.method === 'POST') {
|
|
2543
|
+
const body = await readBody(req);
|
|
2544
|
+
if (body.token !== TOKEN) return sendJSON(res, 403, { error: 'bad or missing token' });
|
|
2545
|
+
if (url === '/api/apply') return sendJSON(res, 200, apply(Array.isArray(body.ids) ? body.ids : []));
|
|
2546
|
+
if (url === '/api/save-config') return sendJSON(res, 200, saveConfig(body.values || {}));
|
|
2547
|
+
if (url === '/api/save-advocacy') return sendJSON(res, 200, saveAdvocacy(body.values || {}));
|
|
2548
|
+
// ADR-054 — a distinct endpoint because it writes a distinct thing (the sentinel + the
|
|
2549
|
+
// mirror), never routed through save-advocacy/save-config.
|
|
2550
|
+
if (url === '/api/save-brain-power') return sendJSON(res, 200, saveBrainPower(body.values || {}));
|
|
2551
|
+
if (url === '/api/save-brain-profile') return sendJSON(res, 200, saveBrainProfile(body.values || {}));
|
|
2552
|
+
if (url === '/api/refresh') {
|
|
2553
|
+
// THE ONE REFRESH STORY (owner directive 2026-07-26; RVBC-INSTANT-SPEC #8). The page opens
|
|
2554
|
+
// instantly on the last measurement, SAYS how old it is, and this is the button that takes
|
|
2555
|
+
// a new one. Three things it must get right, each of which was wrong in the first draft:
|
|
2556
|
+
//
|
|
2557
|
+
// • ALL FOUR caches, not just state. The header pill speaks for the whole page; going
|
|
2558
|
+
// green after state alone — while stack, capabilities and the fleet were still a
|
|
2559
|
+
// minute behind — is a page-wide "measured just now" that is false for three of its
|
|
2560
|
+
// four cards. (Live cache stamps from the incident: state 03:36:58, stack 03:37:23,
|
|
2561
|
+
// memory/capabilities 03:38:02.)
|
|
2562
|
+
// • EXPIRE, NEVER DELETE, AND NEVER DE-SCOPE — see expireCachesEmbedding: the data
|
|
2563
|
+
// survives, marked withdrawn, so the page keeps showing the last honest picture with
|
|
2564
|
+
// an honest age while the new one is taken.
|
|
2565
|
+
// • FORCE the kick. kickRefresh's 15s debounce would silently swallow a click made
|
|
2566
|
+
// within 15s of any background kick — and the old code still answered `ok: true`. A
|
|
2567
|
+
// refresh that did not start must not report that it did, so `started` is the child's
|
|
2568
|
+
// real answer, not a constant.
|
|
2569
|
+
expireCachesEmbedding([STATE_CACHE, STACK_CACHE, MEMORY_CACHE, CAPABILITY_CACHE]);
|
|
2570
|
+
const started = kickRefresh({ force: true });
|
|
2571
|
+
return sendJSON(res, 200, { ok: true, refreshing: true, started });
|
|
2572
|
+
}
|
|
2573
|
+
if (url === '/api/undo') return sendJSON(res, 200, undo(body.undoToken));
|
|
2574
|
+
if (url === '/api/set-lesson') return sendJSON(res, 200, setLesson(body));
|
|
2575
|
+
return sendJSON(res, 404, { error: 'unknown endpoint' });
|
|
2576
|
+
}
|
|
2577
|
+
if (req.method === 'GET') return serveStatic(req, res);
|
|
2578
|
+
return send(res, 405, 'text/plain', 'method not allowed');
|
|
2579
|
+
} catch (e) { return sendJSON(res, 500, { error: String(e && e.message || e) }); }
|
|
2580
|
+
});
|
|
2581
|
+
server.on('error', (e) => {
|
|
2582
|
+
if (e.code === 'EADDRINUSE' && port !== 0) { console.error(` port ${port} busy — trying a free one…`); startServer({ port: 0, open, cwd }); }
|
|
2583
|
+
else { console.error(` server error: ${e.message}`); process.exit(1); }
|
|
2584
|
+
});
|
|
2585
|
+
server.listen(port, '127.0.0.1', () => {
|
|
2586
|
+
const actual = server.address().port;
|
|
2587
|
+
const url = `http://127.0.0.1:${actual}/`;
|
|
2588
|
+
console.log(`\n 🧠 RuvNet Brain — Onboarding Console`);
|
|
2589
|
+
console.log(` ${url}`);
|
|
2590
|
+
console.log(` read-only until you click · token-gated · ^C to stop\n`);
|
|
2591
|
+
// Cold-start fix (2026-07-17): hydrate last run's fleet/trust caches from disk FIRST — the
|
|
2592
|
+
// first page load paints real, honestly-stamped data in ~2s instead of a 25–50s scan — then
|
|
2593
|
+
// warm a fresh scan off the request path.
|
|
2594
|
+
// Tell a FIRST-RUN user what to expect. With a warm cache the page paints immediately; with no
|
|
2595
|
+
// cache at all it is genuinely empty until the detached scan lands, and an empty page with no
|
|
2596
|
+
// explanation reads as broken. Measured 2026-07-20: URL is printed in ~0.3s either way, so the
|
|
2597
|
+
// wait a user perceives is the page filling in, not the server starting.
|
|
2598
|
+
//
|
|
2599
|
+
// COLD-VS-WARM DEFINITION: loadConsoleCache() returns true only when a disk cache was successfully
|
|
2600
|
+
// restored at boot (meaning a prior run exists and has persisted data). First-ever run → no cache
|
|
2601
|
+
// file exists → loadConsoleCache returns false → message prints. Warm re-opens → cache file exists
|
|
2602
|
+
// and loads → message does not print. This is the same definition serveCached() uses.
|
|
2603
|
+
const hadCache = loadConsoleCache();
|
|
2604
|
+
if (!hadCache) {
|
|
2605
|
+
console.log(` ${'first run — the page opens now and narrates its own scan'}`);
|
|
2606
|
+
console.log(` ${"(next time you open this, it's already measured)"}\n`);
|
|
2607
|
+
announceWhenLive(url); // print "it's live — take a look at your page" when the scan lands
|
|
2608
|
+
}
|
|
2609
|
+
// ORDER MATTERS AND IS THE WHOLE POINT (RVBC-INSTANT-SPEC #5). Browser FIRST — the tab is what
|
|
2610
|
+
// the user is waiting for and openBrowser is now fully asynchronous — then the scan, in a
|
|
2611
|
+
// detached child, off this thread entirely.
|
|
2612
|
+
//
|
|
2613
|
+
// WHAT WAS HERE BEFORE, AND WHY IT COST THE OWNER HIS THREE MINUTES: `setTimeout(gatherActivity,
|
|
2614
|
+
// 50)`. Fifty milliseconds after the URL printed — which is to say exactly as the browser was
|
|
2615
|
+
// opening — the server ran the machine-wide fleet scan ON ITS OWN EVENT LOOP: 100+ SQLite stores,
|
|
2616
|
+
// 40s+, during which the brand-new tab could not be answered at all. A blank white page, at the
|
|
2617
|
+
// precise moment a first-time user is deciding whether this thing works. The child does that
|
|
2618
|
+
// scan now (see --refresh-cache), and gatherActivity reports `warming` until it lands.
|
|
2619
|
+
if (open) openBrowser(url);
|
|
2620
|
+
// Browser acceptance pre-warms a disposable HOME and disables only this redundant second scan.
|
|
2621
|
+
// The production default is unchanged: every ordinary console start refreshes in the background.
|
|
2622
|
+
if (process.env.RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH !== '1') kickRefresh({ force: true });
|
|
2623
|
+
});
|
|
2624
|
+
return server;
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
// ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
|
|
2628
|
+
if (process.argv[1] && path.resolve(process.argv[1]).endsWith('onboarding-console.mjs')) {
|
|
2629
|
+
const args = process.argv.slice(2);
|
|
2630
|
+
if (args.includes('--print-state')) { console.log(JSON.stringify(gatherState(process.cwd()), null, 2)); }
|
|
2631
|
+
else if (args.includes('--print-stack')) { console.log(JSON.stringify(gatherStack(), null, 2)); }
|
|
2632
|
+
else if (args.includes('--refresh-cache')) {
|
|
2633
|
+
// Runs as a DETACHED CHILD of the server (kickRefresh) — or standalone to pre-warm. Computes the
|
|
2634
|
+
// heavy read-models HERE, in a separate process, so the server's event loop is never blocked, and
|
|
2635
|
+
// writes each cache the moment it is ready (state first — it is what the page paints first).
|
|
2636
|
+
try {
|
|
2637
|
+
let st = gatherState(process.cwd(), { fleet: false });
|
|
2638
|
+
const autoApplyOn = loadSettings().values.autoApply === true;
|
|
2639
|
+
const eligible = autoApplyOn ? autoEligibleIds(st.sections.recommendations) : [];
|
|
2640
|
+
if (eligible.length) {
|
|
2641
|
+
const receipt = apply(eligible);
|
|
2642
|
+
// Re-measure after the mutations. A pre-apply read model must never be stamped as current.
|
|
2643
|
+
st = gatherState(process.cwd(), { fleet: false });
|
|
2644
|
+
st.sections.autoApply = {
|
|
2645
|
+
at: new Date().toISOString(),
|
|
2646
|
+
requested: eligible,
|
|
2647
|
+
results: receipt.results,
|
|
2648
|
+
};
|
|
2649
|
+
}
|
|
2650
|
+
const { token, ...safe } = st;
|
|
2651
|
+
writeCache(STATE_CACHE, st.generatedAt, safe, process.cwd());
|
|
2652
|
+
} catch { /* leave the old cache in place */ }
|
|
2653
|
+
// TASK 3: function-call arguments are evaluated left-to-right, so the previous
|
|
2654
|
+
// `writeCache(STACK_CACHE, new Date().toISOString(), gatherStack())` evaluated the timestamp
|
|
2655
|
+
// BEFORE gatherStack()'s ~22s scan ran — the same bug as the /api/stack handler above, duplicated
|
|
2656
|
+
// here. gatherStack() as its own statement first fixes it the same way.
|
|
2657
|
+
try { const stackData = gatherStack(); writeCache(STACK_CACHE, new Date().toISOString(), stackData); } catch { /* keep prior */ }
|
|
2658
|
+
// Must compute the SAME shape the /api/memory handler does — fleet AND recommendations.
|
|
2659
|
+
//
|
|
2660
|
+
// This wrote fleet-only, so the background refresh silently ERASED the advocacy the handler had
|
|
2661
|
+
// just produced: the first request returned 2 recommendations, the refresh landed, and every
|
|
2662
|
+
// request after it returned 0. The page would have shown the thesis once and then quietly
|
|
2663
|
+
// stopped, which is indistinguishable from "your machine is fine" — the precise failure ADR-027
|
|
2664
|
+
// exists to end, reintroduced by a cache writer that knew about half the payload. Caught by
|
|
2665
|
+
// polling the live endpoint twice instead of once.
|
|
2666
|
+
try {
|
|
2667
|
+
const fleet = scanFleet();
|
|
2668
|
+
let recommendations = [];
|
|
2669
|
+
try {
|
|
2670
|
+
const health = scoreMemoryHealth({ project: path.basename(process.cwd()), probes: probeMemory(process.cwd()) });
|
|
2671
|
+
recommendations = buildHealthRecommendations({ memory: health, learning: { ...observeLearning(), fleet } });
|
|
2672
|
+
} catch { /* advisory only */ }
|
|
2673
|
+
writeCache(MEMORY_CACHE, new Date().toISOString(), { fleet, recommendations }, process.cwd());
|
|
2674
|
+
} catch { /* keep prior */ }
|
|
2675
|
+
|
|
2676
|
+
// CAPABILITY_CACHE WAS NOT IN THIS LIST — the single most consequential omission in the file.
|
|
2677
|
+
//
|
|
2678
|
+
// Measured 2026-07-24: after the freshness ceiling landed, a capability cache past the ceiling was
|
|
2679
|
+
// correctly marked stale and `kickRefresh()` was fired — and this child, the only thing that ever
|
|
2680
|
+
// refreshes anything in the background, did not know CAPABILITY_CACHE existed. Polled every 5s for
|
|
2681
|
+
// a minute: it never came back fresh. It could not. The only other writer is serveCached's COLD
|
|
2682
|
+
// path, which requires the file to be absent, and it never is.
|
|
2683
|
+
//
|
|
2684
|
+
// So capabilities had NO refresher whatsoever. Under the old code that was invisible, because the
|
|
2685
|
+
// cache was served forever while *looking* current — the two-day-old lie was not a stale-cache bug
|
|
2686
|
+
// with an unlucky timestamp, it was this: a read-model nothing was ever going to recompute. The
|
|
2687
|
+
// ceiling did not cause the problem, it EXPOSED it, by turning a silent lie into a visible refusal.
|
|
2688
|
+
//
|
|
2689
|
+
// Found only because a test's PRECONDITION failed: waiting for the cache to become fresh so the
|
|
2690
|
+
// real assertion could run. Had the precondition been assumed rather than checked, the run would
|
|
2691
|
+
// have passed and reported a guarantee that does not exist.
|
|
2692
|
+
try {
|
|
2693
|
+
const { at, data } = computeCapabilities(); // the SAME computer the handler uses
|
|
2694
|
+
writeCache(CAPABILITY_CACHE, at, data, process.cwd());
|
|
2695
|
+
} catch { /* keep prior — a failed audit must never blank the card */ }
|
|
2696
|
+
|
|
2697
|
+
// THE MACHINE-WIDE FLEET SCAN LIVES HERE NOW (RVBC-INSTANT-SPEC #5), and this is the last thing
|
|
2698
|
+
// the child does because it is the longest (100+ SQLite stores, 40s+) and everything above it is
|
|
2699
|
+
// what the page paints first. It used to run on the SERVER's thread — inline on a first-ever
|
|
2700
|
+
// /api/activity, and via a setTimeout 50ms after boot, which is to say while the browser was
|
|
2701
|
+
// opening. loadConsoleCache() first so this process holds the previous trust measurement and
|
|
2702
|
+
// saveConsoleCache's merge has something to preserve.
|
|
2703
|
+
try { loadConsoleCache(); refreshFleetCache(); } catch { /* keep prior — a failed walk must never blank the fleet */ }
|
|
2704
|
+
|
|
2705
|
+
process.exit(0);
|
|
2706
|
+
}
|
|
2707
|
+
else if (args.includes('--serve') || args.length === 0) {
|
|
2708
|
+
// Hitting the command again should land on the console you already have, not spawn a second
|
|
2709
|
+
// server on a random port and a second tab. If one is already up, just point the browser at it.
|
|
2710
|
+
const port = Number(process.env.CONSOLE_PORT) || 7411;
|
|
2711
|
+
const open = args.includes('--open');
|
|
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); });
|
|
2721
|
+
});
|
|
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
|
+
}
|
|
2727
|
+
else { console.log(`\n onboarding-console — the RuvNet Brain configure page\n\n --serve [--open] start the local server (and open your browser)\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
|
+
}
|
|
2729
|
+
|
|
2730
|
+
export {
|
|
2731
|
+
gatherState,
|
|
2732
|
+
gatherStack,
|
|
2733
|
+
gatherTrust,
|
|
2734
|
+
wiringSurvey,
|
|
2735
|
+
probeMemory,
|
|
2736
|
+
apply,
|
|
2737
|
+
saveConfig,
|
|
2738
|
+
undo,
|
|
2739
|
+
gatherAdvocacy,
|
|
2740
|
+
saveAdvocacy,
|
|
2741
|
+
gatherBrainPower,
|
|
2742
|
+
saveBrainPower,
|
|
2743
|
+
gatherBrainProfile,
|
|
2744
|
+
saveBrainProfile,
|
|
2745
|
+
autoEligibleIds,
|
|
2746
|
+
};
|
|
2747
|
+
// Exported for the cross-project cache-isolation test (console-cache-scope.test.mjs). serveCached's
|
|
2748
|
+
// scopeKey is the guard that stops one project's cached state being served for another.
|
|
2749
|
+
export { serveCached, writeCache, kickRefresh };
|