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,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/signal-watch.mjs — the W2 POLLED half of the external-signal watch plane (ADR-058 §D3;
|
|
3
|
+
// DDD-0013 Context 2, aggregate SignalDebt). A maintainer-only dev tool, same class as
|
|
4
|
+
// scripts/ci-verdict.mjs and scripts/issue-watch.mjs — it is NOT part of the shipped plugin, so it
|
|
5
|
+
// only ever runs in this checkout (this repo's own session-start.sh looks for it by repo-relative
|
|
6
|
+
// path and simply finds nothing on a downloader's machine — see plugin/scripts/session-start.sh).
|
|
7
|
+
//
|
|
8
|
+
// THE FAILURE THIS CLOSES: 2026-07-27, GitHub CI was failing and the OWNER had to tell the model.
|
|
9
|
+
// plugin/scripts/signal-watch.mjs (the PostToolUse observer, the OTHER half — "W1 OBSERVED") opens a
|
|
10
|
+
// pending debt the moment a `git push` succeeds, keyed (repo, sha), appended to pending.jsonl. THIS
|
|
11
|
+
// file resolves that debt via `gh run list` — READ-ONLY VERBS ONLY, never a mutating `gh` command —
|
|
12
|
+
// and writes the result so plugin/scripts/session-start.sh can surface a transition with zero user
|
|
13
|
+
// input. This is 2026-07-27 replayed with the human removed (ADR-058 §D3, mutant M-W1).
|
|
14
|
+
//
|
|
15
|
+
// REUSE, NOT REIMPLEMENTATION (explicit ADR-058 instruction, enforced by this repo's own
|
|
16
|
+
// substitution:check gate): the success/refuse mapping is scripts/ci-verdict.mjs's assessCiGate —
|
|
17
|
+
// the SAME "unknown is red" law the remote-CI ship gate already uses. This file does not carry a
|
|
18
|
+
// second copy of that decision.
|
|
19
|
+
//
|
|
20
|
+
// SINGLE WRITER (DDD-0013 Context 2 invariant 3): this file is the ONLY writer of ci-status.json.
|
|
21
|
+
// plugin/scripts/signal-watch.mjs (the observer) only ever APPENDS to pending.jsonl and never
|
|
22
|
+
// touches ci-status.json — two files, two writers, on purpose (ADR-050's one-poisoned-predicate
|
|
23
|
+
// lesson: channels that share a write path fail together).
|
|
24
|
+
//
|
|
25
|
+
// DEGRADATION LADDER (DDD-0013 Context 2 invariant 1, "UNKNOWN STAYS OPEN"):
|
|
26
|
+
// no `gh` binary -> state 'unverifiable', reason names the fix. A typed CAPABILITY state,
|
|
27
|
+
// never an error.
|
|
28
|
+
// `gh` unauthenticated -> state 'unverifiable', reason names the fix. Same class as above.
|
|
29
|
+
// API error / rate-limit -> state stays 'pending' — a transient hiccup, retried next poll. NEVER
|
|
30
|
+
// invented as a green resolution (ci-verdict.mjs's law, reused, not
|
|
31
|
+
// reimplemented: `assessCiGate(null)` refuses).
|
|
32
|
+
// no run found yet -> state stays 'pending' (CI hasn't started; not a failure).
|
|
33
|
+
// Never fakes green, never silently disables (DDD invariant 6) — every non-resolved state is a
|
|
34
|
+
// distinct, named reason, not silence.
|
|
35
|
+
//
|
|
36
|
+
// FIXTURE INJECTION (ADR-058 §D3, mandatory): SIGNAL_WATCH_GH_FIXTURE points at a JSON file standing
|
|
37
|
+
// in for a real `gh run list` call, so CI needs no network and no auth. See
|
|
38
|
+
// tests/fixtures/signal-watch/gh-fixtures/*.json for the shapes:
|
|
39
|
+
// [ { "conclusion": "success" } ] — a real `gh run list --json ...` array (first element used)
|
|
40
|
+
// { "apiError": true } — simulates a rate-limit / network failure (state stays pending)
|
|
41
|
+
// { "unauthenticated": true } — simulates `gh auth login required`
|
|
42
|
+
// { "notInstalled": true } — simulates a missing `gh` binary
|
|
43
|
+
|
|
44
|
+
import fs from 'node:fs';
|
|
45
|
+
import path from 'node:path';
|
|
46
|
+
import os from 'node:os';
|
|
47
|
+
import { spawnSync } from 'node:child_process';
|
|
48
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
49
|
+
import { assessCiGate } from './ci-verdict.mjs';
|
|
50
|
+
|
|
51
|
+
const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
52
|
+
const SIGNAL_DIR = process.env.RUVNET_SIGNAL_DIR || path.join(BRAIN_HOME, 'external-signals');
|
|
53
|
+
const GH_BIN = process.env.GH_BIN || 'gh';
|
|
54
|
+
const GH_TIMEOUT_MS = 3000; // bounded — this poller may run synchronously inside session-start.sh's 5s hook budget
|
|
55
|
+
|
|
56
|
+
export function pendingPath() { return process.env.SIGNAL_WATCH_PENDING || path.join(SIGNAL_DIR, 'pending.jsonl'); }
|
|
57
|
+
export function statusPath() { return process.env.SIGNAL_WATCH_STATUS || path.join(SIGNAL_DIR, 'ci-status.json'); }
|
|
58
|
+
|
|
59
|
+
export function debtKey(source, repo, ref) { return `${source}:${repo}:${ref}`; }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse pending.jsonl (append-only event log, written ONLY by the PostToolUse observer) into the
|
|
63
|
+
* list of open git-push CI-verdict debts. Dedup keeps the LATEST event per (repo, ref) — a debt's
|
|
64
|
+
* resolution STATE lives entirely in ci-status.json, never here; this only answers "what exists to
|
|
65
|
+
* check". One malformed line is skipped, never crashes the poller (matches issue-watch.mjs's own
|
|
66
|
+
* defensiveness against a corrupt state file).
|
|
67
|
+
*/
|
|
68
|
+
export function readPushDebts(path_ = pendingPath()) {
|
|
69
|
+
let raw;
|
|
70
|
+
try { raw = fs.readFileSync(path_, 'utf8'); } catch { return []; }
|
|
71
|
+
const byKey = new Map();
|
|
72
|
+
for (const line of raw.split('\n')) {
|
|
73
|
+
const t = line.trim();
|
|
74
|
+
if (!t) continue;
|
|
75
|
+
let ev;
|
|
76
|
+
try { ev = JSON.parse(t); } catch { continue; }
|
|
77
|
+
if (ev && ev.kind === 'git-push' && ev.repo && ev.ref) {
|
|
78
|
+
byKey.set(debtKey('gh-ci', ev.repo, ev.ref), ev);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return [...byKey.entries()].map(([key, ev]) => ({ key, repo: ev.repo, ref: ev.ref, ts: ev.ts }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function loadJson(p) {
|
|
85
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return {}; }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function saveJson(p, obj) {
|
|
89
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
90
|
+
fs.writeFileSync(p, JSON.stringify(obj, null, 2));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Resolve ONE debt's CI verdict. READ-ONLY `gh` verbs only (`--version`, `run list`) — a
|
|
95
|
+
* state-changing verb here is the exact boundary violation DDD-0013's anti-corruption section names
|
|
96
|
+
* ("adapters carry a read-only verb whitelist; a state-changing verb ... is a boundary violation the
|
|
97
|
+
* mesh lint fails"). Returns { state: 'resolved'|'unverifiable'|'pending', conclusion?, reason?, workflowName? }.
|
|
98
|
+
*/
|
|
99
|
+
export function resolveVerdict({ repo, ref }, { ghBin = GH_BIN, fixturePath = process.env.SIGNAL_WATCH_GH_FIXTURE } = {}) {
|
|
100
|
+
let runs = null;
|
|
101
|
+
let capability = null;
|
|
102
|
+
|
|
103
|
+
if (fixturePath) {
|
|
104
|
+
let fixture;
|
|
105
|
+
try { fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8')); }
|
|
106
|
+
catch (e) { return { state: 'pending', reason: `unreadable SIGNAL_WATCH_GH_FIXTURE: ${e.message}` }; }
|
|
107
|
+
if (fixture && fixture.notInstalled) capability = 'not-installed';
|
|
108
|
+
else if (fixture && fixture.unauthenticated) capability = 'unauthenticated';
|
|
109
|
+
else if (fixture && fixture.apiError) runs = null;
|
|
110
|
+
else runs = Array.isArray(fixture) ? fixture : (fixture.runs || []);
|
|
111
|
+
} else {
|
|
112
|
+
let probe;
|
|
113
|
+
try { probe = spawnSync(ghBin, ['--version'], { encoding: 'utf8', timeout: GH_TIMEOUT_MS }); }
|
|
114
|
+
catch { probe = { error: { code: 'ENOENT' } }; }
|
|
115
|
+
if (probe.error && probe.error.code === 'ENOENT') {
|
|
116
|
+
capability = 'not-installed';
|
|
117
|
+
} else {
|
|
118
|
+
// READ-ONLY VERB: `gh run list`. Never anything that mutates (no `gh run rerun`, no `gh pr merge`, etc).
|
|
119
|
+
const r = spawnSync(ghBin, ['run', 'list', '--commit', ref, '--repo', repo, '--json', 'status,conclusion,workflowName'],
|
|
120
|
+
{ encoding: 'utf8', timeout: GH_TIMEOUT_MS });
|
|
121
|
+
if (r.status === 0) {
|
|
122
|
+
try { runs = JSON.parse(r.stdout || '[]'); } catch { runs = null; }
|
|
123
|
+
} else {
|
|
124
|
+
const err = String(r.stderr || r.stdout || '');
|
|
125
|
+
if (/auth login|not logged in|authentication|401|unauthorized/i.test(err)) capability = 'unauthenticated';
|
|
126
|
+
else runs = null; // API error, rate-limit, offline — stays UNKNOWN, never invented as green
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (capability === 'not-installed') return { state: 'unverifiable', reason: 'gh not installed — install: brew install gh (or see https://cli.github.com)' };
|
|
132
|
+
if (capability === 'unauthenticated') return { state: 'unverifiable', reason: 'gh auth login required' };
|
|
133
|
+
if (!Array.isArray(runs)) return { state: 'pending', reason: 'gh API error or unreachable — verdict UNKNOWN, debt stays open' };
|
|
134
|
+
if (!runs.length) return { state: 'pending', reason: 'no CI run found yet for this SHA' };
|
|
135
|
+
|
|
136
|
+
const run = runs[0];
|
|
137
|
+
// The SAME unknown-is-red law the remote-CI ship gate uses, reused rather than reimplemented.
|
|
138
|
+
const gate = assessCiGate(run.conclusion ?? null);
|
|
139
|
+
if (gate === 'ship') return { state: 'resolved', conclusion: 'success', workflowName: run.workflowName };
|
|
140
|
+
return { state: 'resolved', conclusion: run.conclusion || 'unknown', workflowName: run.workflowName };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Poll every open, not-yet-resolved debt once and persist the result. Append-only in spirit
|
|
145
|
+
* (DDD-0013 Context 1 invariant 3, applied here): a debt already 'resolved' is never re-resolved —
|
|
146
|
+
* closing state is a SurfacingLedger-layer transition (session-start.sh), not a poller re-check.
|
|
147
|
+
*/
|
|
148
|
+
export function pollOnce({ pendingFile = pendingPath(), statusFile = statusPath(), ghBin = GH_BIN, fixturePath = process.env.SIGNAL_WATCH_GH_FIXTURE } = {}) {
|
|
149
|
+
const debts = readPushDebts(pendingFile);
|
|
150
|
+
const status = loadJson(statusFile);
|
|
151
|
+
for (const debt of debts) {
|
|
152
|
+
const prior = status[debt.key];
|
|
153
|
+
if (prior && prior.state === 'resolved') continue;
|
|
154
|
+
const verdict = resolveVerdict({ repo: debt.repo, ref: debt.ref }, { ghBin, fixturePath });
|
|
155
|
+
status[debt.key] = { ...verdict, repo: debt.repo, ref: debt.ref, checkedAt: new Date().toISOString() };
|
|
156
|
+
}
|
|
157
|
+
saveJson(statusFile, status);
|
|
158
|
+
return status;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function main() {
|
|
162
|
+
const status = pollOnce({});
|
|
163
|
+
process.stdout.write(JSON.stringify(status, null, 2) + '\n');
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function isMain() {
|
|
167
|
+
try { return process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; }
|
|
168
|
+
catch { return false; }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (isMain()) main();
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// stack-sync.mjs — THE one rule for the RuvNet stack. One global copy. Correct. Provable.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS (found live 2026-07-14, because Stuart noticed an update nag pointing BACKWARDS):
|
|
5
|
+
//
|
|
6
|
+
// TWO installers were fighting on this machine:
|
|
7
|
+
// com.stuartkerr.ruflo-autoupdate nightly 03:30 npm install -g <38 pkgs>, core on @alpha
|
|
8
|
+
// io.ruv.auto-subscribe HOURLY npm install -g <4 pkgs> on @latest
|
|
9
|
+
// The moment rUv publishes an alpha ahead of latest — the entire point of an alpha track — the
|
|
10
|
+
// nightly puts you on alpha and the hourly drags you back down. Every hour. Forever.
|
|
11
|
+
//
|
|
12
|
+
// Meanwhile ~190 hook invocations across 16 projects call `npx <pkg>@latest` on every Edit, Bash
|
|
13
|
+
// and prompt. npx runs its OWN private copy from ~/.npm/_npx, which can be badly stale (rvf sat
|
|
14
|
+
// at 0.1.9 in that cache while the global was 0.2.3). npx does not merely fail to catch drift —
|
|
15
|
+
// it MANUFACTURES THE ILLUSION OF CURRENCY: every command works, every --version prints the new
|
|
16
|
+
// number, and the binary the MCP server actually executes quietly rots.
|
|
17
|
+
//
|
|
18
|
+
// THE ROOT CAUSE, stated once: currency is an ORDERING question, not an EQUALITY one.
|
|
19
|
+
// Three separate files compared installed vs latest with `!=`, which fires in EITHER direction:
|
|
20
|
+
// plugin/scripts/ground-ruvnet.sh -> advised a DOWNGRADE every prompt (fixed 2.7.2)
|
|
21
|
+
// ~/.claude/hooks/ruflo-upgrade-awareness.sh -> same bug, same bad advice
|
|
22
|
+
// ~/.claude/scripts/ruvnet-auto-subscribe.sh -> same bug, but it EXECUTES npm install -g
|
|
23
|
+
// `!=` only ever LOOKS right because installed is normally <= latest. Nothing asserted direction.
|
|
24
|
+
//
|
|
25
|
+
// So this file has exactly ONE comparator, and it is the only place ordering is decided.
|
|
26
|
+
// A downgrade is not "policy-forbidden" here — it is STRUCTURALLY UNREACHABLE: the only install
|
|
27
|
+
// gate is isBehind(), and AHEAD is an explicitly allowed, untouched state.
|
|
28
|
+
//
|
|
29
|
+
// GROUNDING (search_ruvnet across 37 repos): rUv ships no machine-wide package updater. His docs
|
|
30
|
+
// present `npm install -g` and `npx` as equally valid with no ruling on which wins — which is
|
|
31
|
+
// exactly where the drift lives. Closest precedent: ruflo/plugins/ruflo-ruvector ADR-0001
|
|
32
|
+
// (ACCEPTED) — pin, verify against a smoke test, bump deliberately. This is OUR operator script
|
|
33
|
+
// honoring that discipline. It is not an rUv product and does not wear rUv's name.
|
|
34
|
+
|
|
35
|
+
import { spawnSync } from 'node:child_process';
|
|
36
|
+
import fs from 'node:fs';
|
|
37
|
+
import os from 'node:os';
|
|
38
|
+
import path from 'node:path';
|
|
39
|
+
|
|
40
|
+
const HOME = os.homedir();
|
|
41
|
+
|
|
42
|
+
// ISSUE #18 (Henrik Pettersen): PREFIX used to be hardcoded to ~/.npm-global. That path is Stuart's
|
|
43
|
+
// own convention, not an npm default — on any machine managing Node via mise/nvm/volta/fnm, npm's
|
|
44
|
+
// real global prefix lives somewhere else entirely (e.g. ~/.local/share/mise/installs/node/24.14.1),
|
|
45
|
+
// so the hardcoded path never existed there and this tool reported "0 packages on your global
|
|
46
|
+
// stack" with the full stack actually installed under the real prefix — reporting health while
|
|
47
|
+
// blind, the exact failure ADR-0013 (Onboarding Console) exists to kill.
|
|
48
|
+
// Fix: ask npm itself where its prefix is, at runtime, and resolve it ONCE at module load (not per
|
|
49
|
+
// call — `npm config get prefix` is a subprocess spawn, not free). Fall back to ~/.npm-global only
|
|
50
|
+
// if npm can't tell us anything usable.
|
|
51
|
+
function resolveNpmPrefix() {
|
|
52
|
+
const r = spawnSync('npm', ['config', 'get', 'prefix'], { encoding: 'utf8', timeout: 10000 });
|
|
53
|
+
const out = r.status === 0 && r.stdout ? r.stdout.trim() : '';
|
|
54
|
+
return out || path.join(HOME, '.npm-global');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// npm's on-disk layout for global packages differs by platform: macOS/Linux nest an extra `lib/`
|
|
58
|
+
// segment (<prefix>/lib/node_modules), Windows does not (<prefix>/node_modules). Rather than assume
|
|
59
|
+
// either, prefer whichever actually exists on disk so one code path works on both.
|
|
60
|
+
function resolveGlobalLib(prefix) {
|
|
61
|
+
const withLib = path.join(prefix, 'lib', 'node_modules');
|
|
62
|
+
const withoutLib = path.join(prefix, 'node_modules');
|
|
63
|
+
if (fs.existsSync(withLib)) return withLib;
|
|
64
|
+
if (fs.existsSync(withoutLib)) return withoutLib;
|
|
65
|
+
return withLib; // neither exists yet (e.g. a fresh/empty prefix) — keep the more common shape
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const PREFIX = resolveNpmPrefix();
|
|
69
|
+
const GLOBAL_LIB = resolveGlobalLib(PREFIX);
|
|
70
|
+
const NPX_CACHE = path.join(HOME, '.npm/_npx');
|
|
71
|
+
const RECEIPT = path.join(HOME, '.cache/ruvnet-brain/stack-sync-receipt.json');
|
|
72
|
+
// ISSUE #22: rUv tools installed through the Claude Code plugin MARKETPLACE (not `npm install -g`)
|
|
73
|
+
// live here, and were invisible to this auditor — so plugin-only users saw a permanently undercounted
|
|
74
|
+
// stack, and any plugin they DID have could never be reported "installed". Claude Code writes the
|
|
75
|
+
// authoritative install list (which plugin, which marketplace, which cached version dir is active) to
|
|
76
|
+
// installed_plugins.json under this dir; we read THAT rather than guessing among the dozens of stale
|
|
77
|
+
// version folders the cache keeps around.
|
|
78
|
+
const PLUGINS_DIR = path.join(HOME, '.claude', 'plugins');
|
|
79
|
+
|
|
80
|
+
// The tag policy. ONE table — the single source of truth for "what SHOULD be installed".
|
|
81
|
+
// The orchestration core tracks alpha (rUv ships fast: 3.26 -> 3.28 inside one 18-hour window).
|
|
82
|
+
// Everything else tracks latest. Unlisted packages get DEFAULT_TAG.
|
|
83
|
+
export const TAG_POLICY = { ruflo: 'alpha', '@claude-flow/cli': 'alpha' };
|
|
84
|
+
export const DEFAULT_TAG = 'latest';
|
|
85
|
+
|
|
86
|
+
// Resolve a package's TARGET version to the NEWEST of its candidate tags — the policy tag AND latest —
|
|
87
|
+
// not blindly the policy tag. TAG_POLICY names the tag the orchestration core USUALLY leads on (@alpha,
|
|
88
|
+
// because rUv ships fast). But the reverse happens: on 2026-07-18 the core's @latest was 3.32.7 while
|
|
89
|
+
// its @alpha sat at 3.32.0, so "track @alpha" alone pinned the install 7 releases behind the newest
|
|
90
|
+
// published build — and the nightly reported SUCCESS doing it (installed 3.32.2 read as AHEAD of the
|
|
91
|
+
// 3.32.0 @alpha target, so it correctly refused to downgrade and never chased 3.32.7). Considering both
|
|
92
|
+
// tags and taking the higher version fixes that in EITHER direction: alpha-leads → track alpha;
|
|
93
|
+
// latest-leads → track latest. You are never left behind the newest thing rUv actually published.
|
|
94
|
+
export function pickTargetTag(tags, want, defaultTag = DEFAULT_TAG) {
|
|
95
|
+
if (!tags) return { tag: null, target: null };
|
|
96
|
+
const candidates = [...new Set([want, defaultTag])].filter((t) => tags[t]);
|
|
97
|
+
let tag = null, target = null;
|
|
98
|
+
for (const t of candidates) {
|
|
99
|
+
if (target === null || cmpVersion(tags[t], target) > 0) { tag = t; target = tags[t]; }
|
|
100
|
+
}
|
|
101
|
+
return { tag, target };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// What counts as "the stack": an explicit allow-list pattern, not a loose scope match, so a stray
|
|
105
|
+
// package can never be swept into a global install by accident.
|
|
106
|
+
export const FAMILY = /^(ruflo|ruvector|ruvector-extensions|ruvi|ruvbot|qudag|flow-nexus|agent-browser|agent-browser-mcp|agentic-flow|agentic-qe|agentic-robotics|agentic-payments|ruv-swarm|@ruvector\/|@claude-flow\/|@metaharness\/|@agentic-robotics\/)/;
|
|
107
|
+
|
|
108
|
+
// The plugin side of the same allow-list intent (ISSUE #22). Plugin identifiers are marketplace
|
|
109
|
+
// names (`ruflo-core`, `ruvnet-brain`, `cog-beehive-monitor`), NOT npm package names, so FAMILY
|
|
110
|
+
// alone cannot classify them — `ruvnet-brain` and cognitum's plugins never match it. The reliable
|
|
111
|
+
// signal is the MARKETPLACE a plugin came from: these four are rUv/ruvnet marketplaces (verified in
|
|
112
|
+
// ~/.claude/plugins/known_marketplaces.json → ruvnet/ruflo, ruvnet/RuView, stuinfla/ruvnet-brain,
|
|
113
|
+
// cognitum.one). An explicit set, mirroring FAMILY's "allow-list, never a loose scope match" so a
|
|
114
|
+
// stray third-party plugin can never be swept into the RuvNet stack by accident. FAMILY is still
|
|
115
|
+
// applied as a secondary matcher, so a future rUv plugin shipped through a different marketplace is
|
|
116
|
+
// still counted.
|
|
117
|
+
export const PLUGIN_MARKETPLACES = new Set(['ruflo', 'ruview', 'ruvnet-brain', 'cognitum']);
|
|
118
|
+
|
|
119
|
+
const log = (m) => console.log(m);
|
|
120
|
+
const die = (m) => { console.error(`\n FAILED: ${m}`); process.exit(1); };
|
|
121
|
+
|
|
122
|
+
// THE COMPARATOR. The only place ordering is decided anywhere in this system.
|
|
123
|
+
// Prerelease-aware per semver: a prerelease sorts BEFORE its release (3.28.0-alpha.1 < 3.28.0),
|
|
124
|
+
// and numeric identifiers compare numerically — alpha.9 < alpha.10, which a string compare gets
|
|
125
|
+
// backwards, and a string compare is precisely the bug this whole file exists to kill.
|
|
126
|
+
export function cmpVersion(a, b) {
|
|
127
|
+
const split = (v) => {
|
|
128
|
+
const [core, pre] = String(v).split('-');
|
|
129
|
+
return [core.split('.').map((n) => parseInt(n, 10) || 0), pre ? pre.split('.') : null];
|
|
130
|
+
};
|
|
131
|
+
const [ac, ap] = split(a);
|
|
132
|
+
const [bc, bp] = split(b);
|
|
133
|
+
for (let i = 0; i < 3; i++) {
|
|
134
|
+
const d = (ac[i] || 0) - (bc[i] || 0);
|
|
135
|
+
if (d !== 0) return d < 0 ? -1 : 1;
|
|
136
|
+
}
|
|
137
|
+
if (!ap && !bp) return 0;
|
|
138
|
+
if (ap && !bp) return -1;
|
|
139
|
+
if (!ap && bp) return 1;
|
|
140
|
+
for (let i = 0; i < Math.max(ap.length, bp.length); i++) {
|
|
141
|
+
const x = ap[i], y = bp[i];
|
|
142
|
+
if (x === undefined) return -1;
|
|
143
|
+
if (y === undefined) return 1;
|
|
144
|
+
const nx = /^\d+$/.test(x), ny = /^\d+$/.test(y);
|
|
145
|
+
if (nx && ny) { const d = parseInt(x, 10) - parseInt(y, 10); if (d) return d < 0 ? -1 : 1; }
|
|
146
|
+
else if (x !== y) return x < y ? -1 : 1;
|
|
147
|
+
}
|
|
148
|
+
return 0;
|
|
149
|
+
}
|
|
150
|
+
export const isBehind = (installed, target) => cmpVersion(installed, target) < 0;
|
|
151
|
+
|
|
152
|
+
export function installedVersion(pkg, lib = GLOBAL_LIB) {
|
|
153
|
+
const pj = path.join(lib, pkg, 'package.json');
|
|
154
|
+
if (!fs.existsSync(pj)) return null;
|
|
155
|
+
try { return JSON.parse(fs.readFileSync(pj, 'utf8')).version || null; } catch { return null; }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ISSUE #22 — the version of an installed Claude Code plugin is its plugin.json `version`, read from
|
|
159
|
+
// the exact cached version dir Claude Code marked active (installPath). Injectable exactly like
|
|
160
|
+
// installedVersion(pkg, lib) so it is unit-testable against a temp dir.
|
|
161
|
+
export function pluginVersion(installPath) {
|
|
162
|
+
const pj = path.join(installPath, '.claude-plugin', 'plugin.json');
|
|
163
|
+
try { return JSON.parse(fs.readFileSync(pj, 'utf8')).version || null; } catch { return null; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ISSUE #22 — scan the Claude Code plugin cache for rUv-family plugins. Source of truth is
|
|
167
|
+
// installed_plugins.json (Claude Code's own record of what is installed, its marketplace, and which
|
|
168
|
+
// cached version dir is active) — NOT a raw walk of the cache, which keeps dozens of stale version
|
|
169
|
+
// folders per plugin. `pluginsDir` is injectable, mirroring the lib=GLOBAL_LIB pattern, so tests can
|
|
170
|
+
// point it at a fixture. Returns the same {name, installed} shape as the npm scan, tagged source:'plugin'.
|
|
171
|
+
export function listInstalledPlugins(pluginsDir = PLUGINS_DIR) {
|
|
172
|
+
const manifest = path.join(pluginsDir, 'installed_plugins.json');
|
|
173
|
+
if (!fs.existsSync(manifest)) return [];
|
|
174
|
+
let data;
|
|
175
|
+
try { data = JSON.parse(fs.readFileSync(manifest, 'utf8')); } catch { return []; }
|
|
176
|
+
const plugins = data && typeof data.plugins === 'object' ? data.plugins : {};
|
|
177
|
+
const out = [];
|
|
178
|
+
for (const [key, records] of Object.entries(plugins)) {
|
|
179
|
+
// key = "<plugin>@<marketplace>"; the marketplace is the last @-segment.
|
|
180
|
+
const at = key.lastIndexOf('@');
|
|
181
|
+
const name = at > 0 ? key.slice(0, at) : key;
|
|
182
|
+
const marketplace = at > 0 ? key.slice(at + 1) : '';
|
|
183
|
+
if (!(PLUGIN_MARKETPLACES.has(marketplace) || FAMILY.test(name))) continue;
|
|
184
|
+
// A plugin can have several install records (user + per-project scope). Pick the highest readable
|
|
185
|
+
// version so a stale project-scoped copy can never mask a newer user-scoped one — same "ordering,
|
|
186
|
+
// not equality" discipline as the rest of this file. Fall back to the manifest's own version field
|
|
187
|
+
// if the on-disk plugin.json is unreadable, so a present plugin is NEVER reported "not installed".
|
|
188
|
+
let installed = null;
|
|
189
|
+
for (const rec of Array.isArray(records) ? records : []) {
|
|
190
|
+
const v = (rec && rec.installPath ? pluginVersion(rec.installPath) : null)
|
|
191
|
+
|| (rec && rec.version && rec.version !== 'unknown' ? rec.version : null);
|
|
192
|
+
if (!v) continue;
|
|
193
|
+
if (installed === null || cmpVersion(v, installed) > 0) installed = v;
|
|
194
|
+
}
|
|
195
|
+
out.push({ name, installed, source: 'plugin', marketplace });
|
|
196
|
+
}
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function listInstalled({ lib = GLOBAL_LIB, pluginsDir = PLUGINS_DIR } = {}) {
|
|
201
|
+
const out = [];
|
|
202
|
+
const scan = (dir, scope = '') => {
|
|
203
|
+
if (!fs.existsSync(dir)) return;
|
|
204
|
+
for (const e of fs.readdirSync(dir)) {
|
|
205
|
+
if (e.startsWith('.')) continue;
|
|
206
|
+
if (e.startsWith('@') && !scope) { scan(path.join(dir, e), e + '/'); continue; }
|
|
207
|
+
const name = scope + e;
|
|
208
|
+
if (FAMILY.test(name)) out.push({ name, installed: installedVersion(name, lib), source: 'npm-global' });
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
scan(lib);
|
|
212
|
+
// Merge in plugin-sourced tools (ISSUE #22). Dedup by name: a tool present BOTH globally and as a
|
|
213
|
+
// plugin appears once, and the global-npm copy wins — it is the one classify() can compare against
|
|
214
|
+
// npm dist-tags. A plugin-only tool is added, so it can never be reported "not installed".
|
|
215
|
+
const seen = new Set(out.map((r) => r.name));
|
|
216
|
+
for (const p of listInstalledPlugins(pluginsDir)) {
|
|
217
|
+
if (seen.has(p.name)) continue;
|
|
218
|
+
seen.add(p.name);
|
|
219
|
+
out.push(p);
|
|
220
|
+
}
|
|
221
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function registryTags(pkg) {
|
|
225
|
+
const r = spawnSync('npm', ['view', pkg, 'dist-tags', '--json'], { encoding: 'utf8', timeout: 30000 });
|
|
226
|
+
if (r.status !== 0 || !r.stdout) return null;
|
|
227
|
+
try { return JSON.parse(r.stdout); } catch { return null; }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// A second copy of a stack package in the npx cache can only ever SHADOW the global one.
|
|
231
|
+
// It is never useful. This is how "two ruflos" happens.
|
|
232
|
+
export function findShadows(npxCache = NPX_CACHE, lib = GLOBAL_LIB) {
|
|
233
|
+
const shadows = [];
|
|
234
|
+
if (!fs.existsSync(npxCache)) return shadows;
|
|
235
|
+
for (const d of fs.readdirSync(npxCache)) {
|
|
236
|
+
const nm = path.join(npxCache, d, 'node_modules');
|
|
237
|
+
if (!fs.existsSync(nm)) continue;
|
|
238
|
+
const scan = (dir, scope = '') => {
|
|
239
|
+
for (const e of fs.readdirSync(dir)) {
|
|
240
|
+
if (e.startsWith('.')) continue;
|
|
241
|
+
if (e.startsWith('@') && !scope) { scan(path.join(dir, e), e + '/'); continue; }
|
|
242
|
+
const name = scope + e;
|
|
243
|
+
if (!FAMILY.test(name)) continue;
|
|
244
|
+
try {
|
|
245
|
+
const v = JSON.parse(fs.readFileSync(path.join(dir, e, 'package.json'), 'utf8')).version;
|
|
246
|
+
shadows.push({ name, version: v, dir: path.join(npxCache, d), global: installedVersion(name, lib) });
|
|
247
|
+
} catch { /* unreadable copy: still a shadow, but we cannot name its version */ }
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
try { scan(nm); } catch { /* unreadable cache dir */ }
|
|
251
|
+
}
|
|
252
|
+
return shadows;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Classify installed packages against the registry. Ordering is STILL decided only in cmpVersion/
|
|
256
|
+
// isBehind — this function assigns a state label, it does not compare versions itself except through
|
|
257
|
+
// the single comparator. Extracted so audit() (CLI, may exit) and auditModel() (embedders, never
|
|
258
|
+
// exits) share ONE classification, never two that can drift.
|
|
259
|
+
export function classify(pkgs) {
|
|
260
|
+
return pkgs.map((p) => {
|
|
261
|
+
// ISSUE #22 — a Claude Code plugin tracks ITS MARKETPLACE's update cadence, not npm semver. There
|
|
262
|
+
// is no npm dist-tag oracle for it (querying `npm view <plugin>` would compare against an unrelated
|
|
263
|
+
// package or 404), so we do not manufacture a drift signal. It is present and installed ⇒ CURRENT;
|
|
264
|
+
// a plugin is only BROKEN if we could not read any version at all. Either way it is COUNTED and is
|
|
265
|
+
// never reported "not installed". This also keeps the npm-registry "blind tool" guard scoped to
|
|
266
|
+
// real npm rows (see audit()/auditModel()).
|
|
267
|
+
if (p.source === 'plugin') {
|
|
268
|
+
return { ...p, tag: 'plugin', target: p.installed, state: p.installed ? 'CURRENT' : 'BROKEN' };
|
|
269
|
+
}
|
|
270
|
+
const want = TAG_POLICY[p.name] || DEFAULT_TAG;
|
|
271
|
+
const tags = registryTags(p.name);
|
|
272
|
+
// Newest of {policy tag, latest} — see pickTargetTag: "track @alpha" alone pinned the core behind
|
|
273
|
+
// @latest on 2026-07-18. A package with no alpha tag falls through to latest.
|
|
274
|
+
const { tag, target } = pickTargetTag(tags, want);
|
|
275
|
+
let state;
|
|
276
|
+
if (!p.installed) state = 'BROKEN';
|
|
277
|
+
else if (!target) state = 'UNRESOLVED';
|
|
278
|
+
else if (isBehind(p.installed, target)) state = 'BEHIND';
|
|
279
|
+
else if (cmpVersion(p.installed, target) > 0) state = 'AHEAD';
|
|
280
|
+
else state = 'CURRENT';
|
|
281
|
+
return { ...p, tag, target, state };
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function audit() {
|
|
286
|
+
const pkgs = listInstalled();
|
|
287
|
+
if (!pkgs.length) die(`no stack packages under ${GLOBAL_LIB} — is the npm prefix right?`);
|
|
288
|
+
|
|
289
|
+
// A BLIND TOOL MUST NOT REPORT HEALTH. (Adversarial review, 2026-07-14 — a real bug in the first
|
|
290
|
+
// version of this file.) If the registry is unreachable, EVERY package resolves UNRESOLVED, the
|
|
291
|
+
// drift count is 0+0+0, and --audit used to print "all current" and exit 0 — a currency claim
|
|
292
|
+
// that was never measured. That is the exact failure this whole file exists to kill, reproduced
|
|
293
|
+
// inside the fix for it. Silence is not health.
|
|
294
|
+
const rows = classify(pkgs);
|
|
295
|
+
const unresolved = rows.filter((r) => r.state === 'UNRESOLVED');
|
|
296
|
+
// Guard scoped to npm rows (ISSUE #22): plugin rows are CURRENT by construction, so counting them
|
|
297
|
+
// here would let a fully-unreachable registry hide behind a couple of installed plugins — the exact
|
|
298
|
+
// "blind tool reports health" bug this guard exists to kill, reintroduced. Denominator = npm rows.
|
|
299
|
+
const npmRows = rows.filter((r) => r.source !== 'plugin');
|
|
300
|
+
if (npmRows.length && unresolved.length === npmRows.length) {
|
|
301
|
+
die(`could not reach the npm registry for ANY of ${npmRows.length} npm packages.\n` +
|
|
302
|
+
` This tool refuses to report on a stack it could not measure. Check the network and re-run.`);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const shadows = findShadows();
|
|
306
|
+
return { rows, unresolved, shadows, stale: shadows.filter((s) => s.global && s.version !== s.global) };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Non-exiting audit for embedders (the Onboarding Console). Same measurement as audit(), but returns
|
|
310
|
+
// a model with an `error` field instead of calling process.exit — a long-lived server must never be
|
|
311
|
+
// killed by a transient registry blip. Honours the SAME "a blind tool must not report health" rule:
|
|
312
|
+
// if the registry was unreachable for EVERY package, that is surfaced as an error, not as "all current".
|
|
313
|
+
export function auditModel() {
|
|
314
|
+
const pkgs = listInstalled();
|
|
315
|
+
if (!pkgs.length) return { error: `no stack packages under ${GLOBAL_LIB}`, rows: [], unresolved: [], shadows: [], stale: [] };
|
|
316
|
+
const rows = classify(pkgs);
|
|
317
|
+
const unresolved = rows.filter((r) => r.state === 'UNRESOLVED');
|
|
318
|
+
const shadows = findShadows();
|
|
319
|
+
const stale = shadows.filter((s) => s.global && s.version !== s.global);
|
|
320
|
+
// Same npm-scoped guard as audit() (ISSUE #22): plugin rows never count toward "registry unreachable".
|
|
321
|
+
const npmRows = rows.filter((r) => r.source !== 'plugin');
|
|
322
|
+
const error = npmRows.length && unresolved.length === npmRows.length
|
|
323
|
+
? `could not reach the npm registry for any of ${npmRows.length} npm packages` : null;
|
|
324
|
+
return { rows, unresolved, shadows, stale, error };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function report({ rows, shadows, stale }) {
|
|
328
|
+
const w = Math.max(...rows.map((r) => r.name.length));
|
|
329
|
+
const nPlugin = rows.filter((r) => r.source === 'plugin').length;
|
|
330
|
+
const nNpm = rows.length - nPlugin;
|
|
331
|
+
log(`\n RuvNet stack — ${rows.length} packages (${nNpm} npm-global, ${nPlugin} Claude Code plugin)\n`);
|
|
332
|
+
for (const r of rows) {
|
|
333
|
+
const mark = { CURRENT: ' ok ', BEHIND: 'BEHIND', AHEAD: ' ahead', BROKEN: 'BROKEN', UNRESOLVED: ' ?? ' }[r.state];
|
|
334
|
+
const detail = r.state === 'BEHIND' ? `${r.installed} -> ${r.target} (@${r.tag})`
|
|
335
|
+
: r.state === 'AHEAD' ? `${r.installed} (ahead of @${r.tag} ${r.target} — alpha track; left alone)`
|
|
336
|
+
: r.state === 'BROKEN' ? `no readable version on disk; registry has ${r.target ?? '?'}`
|
|
337
|
+
: r.source === 'plugin' ? `${r.installed} (plugin · ${r.marketplace} marketplace)`
|
|
338
|
+
: r.installed;
|
|
339
|
+
log(` [${mark}] ${r.name.padEnd(w)} ${detail}`);
|
|
340
|
+
}
|
|
341
|
+
if (shadows.length) {
|
|
342
|
+
log(`\n npx shadow copies:`);
|
|
343
|
+
for (const s of shadows) {
|
|
344
|
+
log(` ${s.name}@${s.version}${s.global && s.version !== s.global ? ` STALE — global is ${s.global}` : ''}`);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
log('');
|
|
348
|
+
return { behind: rows.filter((r) => r.state === 'BEHIND'), broken: rows.filter((r) => r.state === 'BROKEN'), stale };
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function writeReceipt(a, installed, purged) {
|
|
352
|
+
fs.mkdirSync(path.dirname(RECEIPT), { recursive: true });
|
|
353
|
+
fs.writeFileSync(RECEIPT, JSON.stringify({
|
|
354
|
+
at: new Date().toISOString(), installed, purged,
|
|
355
|
+
packages: a.rows.map((r) => ({ name: r.name, version: r.installed, state: r.state })),
|
|
356
|
+
}, null, 2));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// EXCLUSIVE LOCK. (Adversarial review, 2026-07-14.) Nothing stopped the nightly job and a manual
|
|
360
|
+
// run (or two Claude Code windows) from both running `npm install -g` on the same package at the
|
|
361
|
+
// same instant. Two concurrent installs interleaving in one node_modules dir is how you get a
|
|
362
|
+
// half-written package — which is almost certainly how @ruvector/edge-net ended up present-but-
|
|
363
|
+
// versionless with an orphaned `sharp` inside it. O_EXCL is atomic; a stale lock from a crashed
|
|
364
|
+
// run is reclaimed after 20 minutes (longer than the npm timeout).
|
|
365
|
+
const LOCK = path.join(HOME, '.cache/ruvnet-brain/stack-sync.lock');
|
|
366
|
+
function acquireLock() {
|
|
367
|
+
fs.mkdirSync(path.dirname(LOCK), { recursive: true });
|
|
368
|
+
try {
|
|
369
|
+
fs.writeFileSync(LOCK, JSON.stringify({ pid: process.pid, at: Date.now() }), { flag: 'wx' });
|
|
370
|
+
} catch (e) {
|
|
371
|
+
if (e.code !== 'EEXIST') throw e;
|
|
372
|
+
let held = {};
|
|
373
|
+
try { held = JSON.parse(fs.readFileSync(LOCK, 'utf8')); } catch { /* unparseable = stale */ }
|
|
374
|
+
const ageMin = (Date.now() - (held.at ?? 0)) / 60000;
|
|
375
|
+
if (ageMin < 20) {
|
|
376
|
+
die(`another stack-sync is running (pid ${held.pid}, started ${ageMin.toFixed(1)}m ago).\n` +
|
|
377
|
+
` Refusing to run two installers at once — that is how packages get half-written.`);
|
|
378
|
+
}
|
|
379
|
+
fs.writeFileSync(LOCK, JSON.stringify({ pid: process.pid, at: Date.now() }));
|
|
380
|
+
}
|
|
381
|
+
const release = () => { try { fs.unlinkSync(LOCK); } catch { /* already gone */ } };
|
|
382
|
+
process.on('exit', release);
|
|
383
|
+
process.on('SIGINT', () => { release(); process.exit(130); });
|
|
384
|
+
process.on('SIGTERM', () => { release(); process.exit(143); });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function sync({ dryRun = false } = {}) {
|
|
388
|
+
if (!dryRun) acquireLock();
|
|
389
|
+
const a = audit();
|
|
390
|
+
const { behind, broken, stale } = report(a);
|
|
391
|
+
const toInstall = [...behind, ...broken.filter((r) => r.target)].map((r) => `${r.name}@${r.target}`);
|
|
392
|
+
|
|
393
|
+
if (!toInstall.length && !stale.length) {
|
|
394
|
+
log(' one copy of everything, all current, no shadows. Nothing to do.');
|
|
395
|
+
writeReceipt(a, [], []);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
if (dryRun) {
|
|
399
|
+
if (toInstall.length) log(` would install: ${toInstall.join(' ')}`);
|
|
400
|
+
if (stale.length) log(` would purge ${stale.length} stale npx shadow(s)`);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// INSTALL FIRST, PURGE SECOND. (Adversarial review, 2026-07-14.) The first version purged the
|
|
405
|
+
// shadows before installing — so a failed install left the user with their shadows gone AND the
|
|
406
|
+
// global not yet fixed: strictly WORSE than when they started, with no way back. A repair step
|
|
407
|
+
// that can leave you worse off than not running it is not a repair.
|
|
408
|
+
if (toInstall.length) {
|
|
409
|
+
log(` installing: ${toInstall.join(' ')}`);
|
|
410
|
+
const r = spawnSync('npm', ['install', '-g', '--prefix', PREFIX, ...toInstall],
|
|
411
|
+
{ stdio: 'inherit', timeout: 15 * 60 * 1000 });
|
|
412
|
+
if (r.status !== 0) die(`npm install -g exited ${r.status}; the stack is NOT synced. Shadows left untouched.`);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const purged = [];
|
|
416
|
+
for (const s of stale) {
|
|
417
|
+
// Purge the whole npx dir: it is a disposable resolution cache (npm re-creates it on demand),
|
|
418
|
+
// and a mixed-version dir is exactly how a stale transitive copy survives a targeted delete.
|
|
419
|
+
try { fs.rmSync(s.dir, { recursive: true, force: true }); purged.push(`${s.name}@${s.version}`); } catch { /* best effort */ }
|
|
420
|
+
}
|
|
421
|
+
if (purged.length) log(` purged ${purged.length} stale shadow(s): ${purged.join(', ')}`);
|
|
422
|
+
|
|
423
|
+
// VERIFY AGAINST THE DISK. An installer that trusts its own exit code is a hope, not a guarantee:
|
|
424
|
+
// the old nightly printed "auto-update finished cleanly" from npm's status alone, which is exactly
|
|
425
|
+
// how a stack rots while every log line insists it is healthy.
|
|
426
|
+
const wrong = [];
|
|
427
|
+
for (const r of [...behind, ...broken]) {
|
|
428
|
+
if (!r.target) continue;
|
|
429
|
+
const now = installedVersion(r.name);
|
|
430
|
+
if (now !== r.target) wrong.push(`${r.name}: expected ${r.target}, disk says ${now ?? 'MISSING'}`);
|
|
431
|
+
}
|
|
432
|
+
if (wrong.length) die(`npm reported success but THE DISK DISAGREES:\n - ${wrong.join('\n - ')}`);
|
|
433
|
+
|
|
434
|
+
writeReceipt(audit(), toInstall, purged);
|
|
435
|
+
log(`\n synced ${toInstall.length} package(s); purged ${purged.length} shadow(s); verified against disk.`);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Importable as a module by the tests; only acts when run as a CLI.
|
|
439
|
+
if (process.argv[1] && path.resolve(process.argv[1]).endsWith('stack-sync.mjs')) {
|
|
440
|
+
const args = process.argv.slice(2);
|
|
441
|
+
if (args.includes('--audit')) {
|
|
442
|
+
const a = audit();
|
|
443
|
+
const { behind, broken, stale } = report(a);
|
|
444
|
+
// UNRESOLVED counts as drift, not as health: a package we could not measure is a package we
|
|
445
|
+
// cannot vouch for. Reporting "current" for something we never checked is the lie this tool exists to stop.
|
|
446
|
+
const unres = a.unresolved.length;
|
|
447
|
+
const bad = behind.length + broken.length + stale.length + unres;
|
|
448
|
+
if (bad) {
|
|
449
|
+
log(` DRIFT: ${behind.length} behind, ${broken.length} broken, ${stale.length} stale shadow(s)` +
|
|
450
|
+
(unres ? `, ${unres} UNMEASURED (registry unreachable)` : '') + '.');
|
|
451
|
+
log(` Fix: node scripts/stack-sync.mjs --sync\n`);
|
|
452
|
+
process.exit(1); // non-zero: a watchdog must never call a drifted stack "green"
|
|
453
|
+
}
|
|
454
|
+
log(' one copy of everything, all current, no shadows.\n');
|
|
455
|
+
} else if (args.includes('--sync')) {
|
|
456
|
+
sync({ dryRun: args.includes('--dry-run') });
|
|
457
|
+
} else {
|
|
458
|
+
log(`
|
|
459
|
+
stack-sync — one global copy of the RuvNet stack. Correct. Provable.
|
|
460
|
+
|
|
461
|
+
--audit report drift; exit 1 if anything is behind, broken, or shadowed
|
|
462
|
+
--sync install what is BEHIND (never downgrades), purge npx shadows, verify against disk
|
|
463
|
+
--dry-run with --sync: say what it would do, change nothing
|
|
464
|
+
|
|
465
|
+
Tag policy: ${JSON.stringify(TAG_POLICY)}; everything else @${DEFAULT_TAG}
|
|
466
|
+
Receipt: ${RECEIPT}
|
|
467
|
+
`);
|
|
468
|
+
}
|
|
469
|
+
}
|