ruvnet-brain 4.5.16 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -19
- package/bin/developer-update.mjs +3 -0
- package/bin/install.mjs +15 -3
- package/bin/nightly-refresh.mjs +17 -1
- package/config/practical-rule-catalog.json +111 -0
- package/console/CONTRACT.md +47 -3
- package/console/app.js +112 -20
- package/package.json +5 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +13 -1
- package/plugin/commands/configure.md +2 -2
- package/plugin/commands/rnb.md +13 -1
- package/plugin/commands/rnbc.md +13 -1
- package/plugin/commands/rvbc.md +13 -1
- package/plugin/commands/rvcb.md +13 -1
- package/plugin/hooks/codex-hooks.json +13 -1
- package/plugin/hooks/hook-contracts.json +20 -4
- package/plugin/scripts/adr-currency-gate.mjs +75 -31
- package/plugin/scripts/advocacy-outcomes.mjs +18 -4
- package/plugin/scripts/agentdb-recall.mjs +161 -26
- package/plugin/scripts/automatic-update.mjs +11 -2
- package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
- package/plugin/scripts/capability-registry.mjs +55 -114
- package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
- package/plugin/scripts/codex-hook-adapter.mjs +33 -6
- package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
- package/plugin/scripts/continuation-gate.mjs +2 -1
- package/plugin/scripts/continuity-brief.mjs +55 -22
- package/plugin/scripts/continuity-events.mjs +93 -17
- package/plugin/scripts/continuity-hook-policy.mjs +1 -1
- package/plugin/scripts/continuity-journal.mjs +77 -29
- package/plugin/scripts/decision-gate.mjs +62 -37
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/developer-update-cleanup.mjs +38 -0
- package/plugin/scripts/developer-update-lock.mjs +30 -0
- package/plugin/scripts/developer-update-maintenance.mjs +178 -0
- package/plugin/scripts/developer-update-owner.mjs +24 -0
- package/plugin/scripts/developer-update-policy.mjs +48 -0
- package/plugin/scripts/developer-update.mjs +435 -0
- package/plugin/scripts/doc-currency.mjs +993 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -3
- package/plugin/scripts/grounding-answer.mjs +18 -16
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
- package/plugin/scripts/grounding-turn-gate.mjs +34 -29
- package/plugin/scripts/grounding-turn-mark.mjs +101 -17
- package/plugin/scripts/hook-context-budget.mjs +90 -0
- package/plugin/scripts/hook-registry.mjs +129 -1
- package/plugin/scripts/hook-shim.mjs +20 -2
- package/plugin/scripts/host-update.mjs +6 -3
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/nightly-scheduler.mjs +55 -16
- package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
- package/plugin/scripts/project-capture-queue.mjs +22 -15
- package/plugin/scripts/project-progression-producer.mjs +15 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -4
- package/plugin/scripts/project-progression-session-start.mjs +29 -17
- package/plugin/scripts/project-progression-sources.mjs +24 -10
- package/plugin/scripts/project-progression-store.mjs +7 -2
- package/plugin/scripts/project-store-resolver.mjs +10 -7
- package/plugin/scripts/project-transition-hook.mjs +20 -12
- package/plugin/scripts/session-snapshot-hook.mjs +20 -9
- package/plugin/scripts/session-start-budget.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +32 -6
- package/plugin/scripts/session-start-trace.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -5
- package/plugin/scripts/unprompted-runtime.mjs +36 -10
- package/plugin/scripts/user-settings.mjs +7 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/codex-managed-terminal.mjs +1 -1
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/console-suite-update.mjs +117 -0
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +30 -1
- package/scripts/model-managed-prompt.mjs +1 -1
- package/scripts/model-managed-workflow-service.mjs +113 -14
- package/scripts/model-routing-controller.mjs +112 -21
- package/scripts/onboarding-console.mjs +41 -12
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/release-qualification-contract.mjs +14 -0
- package/scripts/remedy-registry.mjs +1 -0
- package/scripts/source-scope-receipt.mjs +9 -4
- package/scripts/stack-sync.mjs +19 -132
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
// • Bind 127.0.0.1 only; mint a random per-launch token; every mutating POST must echo it (else 403).
|
|
20
20
|
|
|
21
21
|
import http from 'node:http';
|
|
22
|
+
import { createSuiteUpdater } from './console-suite-update.mjs';
|
|
22
23
|
import fs from 'node:fs';
|
|
23
24
|
import os from 'node:os';
|
|
24
25
|
import path from 'node:path';
|
|
@@ -27,6 +28,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
27
28
|
import { spawnSync, execFileSync, spawn } from 'node:child_process';
|
|
28
29
|
|
|
29
30
|
import { auditModel, installedVersion } from './stack-sync.mjs';
|
|
31
|
+
import { acquireDeveloperLock } from '../plugin/scripts/developer-update-lock.mjs';
|
|
30
32
|
import { candidateRoots, findStores, findProjects, diagnose } from './memory-doctor.mjs';
|
|
31
33
|
import { buildStackRecommendations, buildWiringRecommendations, summarizeWiring, scoreMemoryHealth, buildHealthRecommendations, buildCapabilityRecommendations } from './console-engine.mjs';
|
|
32
34
|
import { planFor } from './remedy-registry.mjs';
|
|
@@ -659,7 +661,7 @@ function gatherSavings({ repo = REPO } = {}) {
|
|
|
659
661
|
const CONFIG_SCHEMA = [
|
|
660
662
|
{ 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.' },
|
|
661
663
|
{ 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.' },
|
|
662
|
-
{ key: 'nightly', label: 'Nightly
|
|
664
|
+
{ key: 'nightly', label: 'Nightly coordinated updates', type: 'bool', help: 'Run the same update coordinator overnight using your saved release policy and tool scope.' },
|
|
663
665
|
{ key: 'routing', label: 'Token-smart routing', type: 'enum', options: ['auto', 'off'], help: 'Send cheap, mechanical tasks to smaller, cheaper models automatically.' },
|
|
664
666
|
{ 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.' },
|
|
665
667
|
];
|
|
@@ -805,7 +807,7 @@ function gatherConfig() {
|
|
|
805
807
|
if (!schedule.artifact.supported) {
|
|
806
808
|
unavailable.push({
|
|
807
809
|
key: 'nightly',
|
|
808
|
-
label: 'Nightly
|
|
810
|
+
label: 'Nightly coordinated updates',
|
|
809
811
|
reason: schedule.evidence,
|
|
810
812
|
});
|
|
811
813
|
}
|
|
@@ -1596,15 +1598,17 @@ export function settleRefreshState({ currentRunId, runId, code = 0, signal = nul
|
|
|
1596
1598
|
if (code !== 0 || signal) return classifyRefreshState({ failed: signal ? `signal ${signal}` : `exit ${code}`, runId });
|
|
1597
1599
|
return Object.freeze({ status: 'settled', runId, error: null });
|
|
1598
1600
|
}
|
|
1599
|
-
|
|
1601
|
+
let FOLLOW_UP_REFRESH = null;
|
|
1602
|
+
export function kickRefreshDetailed({ force = false, followUp = false, cwd = process.cwd(), spawnRefresh = spawn } = {}) {
|
|
1600
1603
|
if (process.env.RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH === '1') {
|
|
1601
1604
|
REFRESH_STATE = classifyRefreshState({ disabled: true });
|
|
1602
1605
|
return REFRESH_STATE;
|
|
1603
1606
|
}
|
|
1604
1607
|
const now = Date.now();
|
|
1605
1608
|
if (REFRESH_CHILD && now - LAST_REFRESH_KICK < REFRESH_WEDGED_MS) {
|
|
1609
|
+
if (followUp) FOLLOW_UP_REFRESH = { cwd, spawnRefresh };
|
|
1606
1610
|
REFRESH_STATE = classifyRefreshState({ running: true, runId: REFRESH_STATE.runId });
|
|
1607
|
-
return REFRESH_STATE;
|
|
1611
|
+
return FOLLOW_UP_REFRESH ? { ...REFRESH_STATE, queued: true } : REFRESH_STATE;
|
|
1608
1612
|
}
|
|
1609
1613
|
if (!force && now - LAST_REFRESH_KICK < 15000) {
|
|
1610
1614
|
REFRESH_STATE = classifyRefreshState({ debounced: true, runId: REFRESH_STATE.runId });
|
|
@@ -1622,10 +1626,19 @@ function kickRefreshDetailed({ force = false } = {}) {
|
|
|
1622
1626
|
// inherit it. Machine-level caches (stack/activity/trust) don't depend on cwd, so this is safe
|
|
1623
1627
|
// for them; it only fixes the project-scoped ones. NOT a change to the withhold-vs-recompute
|
|
1624
1628
|
// contract (the 2026-07-17 outage) — only to which project the background compute is about.
|
|
1625
|
-
const child =
|
|
1629
|
+
const child = spawnRefresh(process.execPath, [SELF, '--refresh-cache'], { detached: true, stdio: 'ignore', cwd });
|
|
1626
1630
|
REFRESH_CHILD = child;
|
|
1627
1631
|
const runId = `${process.pid}-${now}`;
|
|
1628
1632
|
REFRESH_STATE = classifyRefreshState({ runId });
|
|
1633
|
+
const drainFollowUp = () => {
|
|
1634
|
+
const queued = FOLLOW_UP_REFRESH;
|
|
1635
|
+
FOLLOW_UP_REFRESH = null; // Any number of forced mutations coalesce into one scan.
|
|
1636
|
+
if (!queued) return;
|
|
1637
|
+
// The old child may have published after the mutation withdrew its earlier snapshot.
|
|
1638
|
+
// Withdraw again only after that writer has exited, before the post-mutation child starts.
|
|
1639
|
+
expireCachesEmbedding([STATE_CACHE, STACK_CACHE, MEMORY_CACHE, CAPABILITY_CACHE]);
|
|
1640
|
+
kickRefreshDetailed({ ...queued, force: true });
|
|
1641
|
+
};
|
|
1629
1642
|
// unref() only releases the event-loop hold; these listeners still fire while the server lives.
|
|
1630
1643
|
child.on('exit', (code, signal) => {
|
|
1631
1644
|
// An older child can exit after a newer run has started. It may only settle its own run.
|
|
@@ -1633,12 +1646,14 @@ function kickRefreshDetailed({ force = false } = {}) {
|
|
|
1633
1646
|
if (!settled) return;
|
|
1634
1647
|
REFRESH_CHILD = null;
|
|
1635
1648
|
REFRESH_STATE = settled;
|
|
1649
|
+
drainFollowUp();
|
|
1636
1650
|
});
|
|
1637
1651
|
child.on('error', (error) => {
|
|
1638
1652
|
const settled = settleRefreshState({ currentRunId: REFRESH_STATE.runId, runId, code: 1, signal: error?.message || error });
|
|
1639
1653
|
if (!settled) return;
|
|
1640
1654
|
REFRESH_CHILD = null;
|
|
1641
1655
|
REFRESH_STATE = settled;
|
|
1656
|
+
drainFollowUp();
|
|
1642
1657
|
});
|
|
1643
1658
|
child.unref(); // let it outlive this request; it writes the caches and exits on its own
|
|
1644
1659
|
return REFRESH_STATE;
|
|
@@ -1813,7 +1828,7 @@ function serveCached(res, file, decorate = (d) => d, scopeKey = null) {
|
|
|
1813
1828
|
|
|
1814
1829
|
// WARM — including over-ceiling. Never compute inline here; hand back what we measured, say when,
|
|
1815
1830
|
// and let the detached child produce the next one.
|
|
1816
|
-
const fresh = freshnessOf(c.at);
|
|
1831
|
+
const fresh = { ...freshnessOf(c.at), ...(FOLLOW_UP_REFRESH ? { stale: true, refreshQueued: true } : {}) };
|
|
1817
1832
|
kickRefresh();
|
|
1818
1833
|
return sendJSON(res, 200, {
|
|
1819
1834
|
...decorate(c.data),
|
|
@@ -2922,9 +2937,15 @@ function undo(undoToken) {
|
|
|
2922
2937
|
// re-applies an old state over whatever the user has done since. The replay guard at the top of
|
|
2923
2938
|
// this function covers all kinds; these calls are what arm it.
|
|
2924
2939
|
if (entry.kind === 'reinstall-version' && entry.pkg && entry.prevVersion) {
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2940
|
+
let lock;
|
|
2941
|
+
try {
|
|
2942
|
+
lock = acquireDeveloperLock();
|
|
2943
|
+
const r = spawnSync('npm', ['install', '-g', '--prefix', NPM_PREFIX, `${entry.pkg}@${entry.prevVersion}`], { encoding: 'utf8', timeout: 15 * 60 * 1000,
|
|
2944
|
+
env: { ...process.env, RUVNET_DEVELOPER_UPDATE_TOKEN: lock.token } });
|
|
2945
|
+
if (r.status === 0) markUndoConsumed(undoToken);
|
|
2946
|
+
return { ok: r.status === 0, log: r.status === 0 ? `reinstalled ${entry.pkg}@${entry.prevVersion}` : (r.stderr || '').slice(-800) };
|
|
2947
|
+
} catch (error) { return { ok: false, log: error.message }; }
|
|
2948
|
+
finally { lock?.release(); }
|
|
2928
2949
|
}
|
|
2929
2950
|
if (entry.kind === 'restore-backup' && entry.project) {
|
|
2930
2951
|
const dir = resolveProjectDir(entry.project);
|
|
@@ -3068,6 +3089,7 @@ function openBrowser(url) {
|
|
|
3068
3089
|
}
|
|
3069
3090
|
}
|
|
3070
3091
|
function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = false, cwd = process.cwd() } = {}) {
|
|
3092
|
+
const suiteUpdater = createSuiteUpdater({ home: CONSOLE_ROOT, brainHome: process.env.RUVNET_BRAIN_HOME || path.join(CONSOLE_ROOT, '.cache/ruvnet-brain'), markNightly: () => saveConfig({ nightly: true }) });
|
|
3071
3093
|
const controlToken = crypto.randomBytes(24).toString('hex');
|
|
3072
3094
|
let activeRuntime = null;
|
|
3073
3095
|
let receiptFile = null;
|
|
@@ -3100,7 +3122,7 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
|
|
|
3100
3122
|
if (req.method === 'GET' && url === '/api/state') {
|
|
3101
3123
|
// project-scoped: never serve another project's cached state. The measuring lives in the
|
|
3102
3124
|
// --refresh-cache child; this handler only ever reads a file and stamps the token on it.
|
|
3103
|
-
return serveCached(res, STATE_CACHE, (d) => ({ ...d, token: TOKEN }), cwd);
|
|
3125
|
+
return serveCached(res, STATE_CACHE, (d) => ({ ...d, token: TOKEN, suiteUpdate: suiteUpdater.state() }), cwd);
|
|
3104
3126
|
}
|
|
3105
3127
|
// ── /api/capabilities — "what do I own, and is it on?" ──────────────────────────────────────
|
|
3106
3128
|
//
|
|
@@ -3142,7 +3164,8 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
|
|
|
3142
3164
|
// the request path before the instant-open fix.
|
|
3143
3165
|
return serveCached(res, STACK_CACHE);
|
|
3144
3166
|
}
|
|
3145
|
-
if (req.method === 'GET' && url === '/api/
|
|
3167
|
+
if (req.method === 'GET' && url === '/api/suite-update') return sendJSON(res, 200, suiteUpdater.state());
|
|
3168
|
+
if (req.method === 'GET' && url === '/api/activity') return sendJSON(res, 200, { ...gatherActivity(cwd), suiteUpdate: suiteUpdater.state() });
|
|
3146
3169
|
if (req.method === 'GET' && url === '/api/lessons') return sendJSON(res, 200, gatherLessons());
|
|
3147
3170
|
if (req.method === 'GET' && url === '/api/trust') return sendJSON(res, 200, await gatherTrust());
|
|
3148
3171
|
if (req.method === 'GET' && url === '/tips') { req.url = '/tips.html'; return serveStatic(req, res); }
|
|
@@ -3153,6 +3176,12 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
|
|
|
3153
3176
|
if (req.method === 'POST') {
|
|
3154
3177
|
const body = await readBody(req);
|
|
3155
3178
|
if (body.token !== TOKEN) return sendJSON(res, 403, { error: 'bad or missing token' });
|
|
3179
|
+
if (url === '/api/suite-update') {
|
|
3180
|
+
const origin = req.headers.origin;
|
|
3181
|
+
if (origin && origin !== `http://${req.headers.host}`) return sendJSON(res, 403, { error: 'forbidden origin' });
|
|
3182
|
+
const result = suiteUpdater.start(body.channel);
|
|
3183
|
+
return sendJSON(res, result.status || (result.ok ? 202 : 500), result);
|
|
3184
|
+
}
|
|
3156
3185
|
if (url === '/api/apply') return sendJSON(res, 200, apply(Array.isArray(body.ids) ? body.ids : []));
|
|
3157
3186
|
if (url === '/api/save-config') return sendJSON(res, 200, saveConfig(body.values || {}));
|
|
3158
3187
|
if (url === '/api/save-advocacy') return sendJSON(res, 200, saveAdvocacy(body.values || {}));
|
|
@@ -3178,7 +3207,7 @@ function startServer({ port = Number(process.env.CONSOLE_PORT) || 7411, open = f
|
|
|
3178
3207
|
// refresh that did not start must not report that it did, so `started` is the child's
|
|
3179
3208
|
// real answer, not a constant.
|
|
3180
3209
|
expireCachesEmbedding([STATE_CACHE, STACK_CACHE, MEMORY_CACHE, CAPABILITY_CACHE]);
|
|
3181
|
-
const refresh = kickRefreshDetailed({ force: true });
|
|
3210
|
+
const refresh = kickRefreshDetailed({ force: true, followUp: true, cwd });
|
|
3182
3211
|
return sendJSON(res, 200, { ok: refresh.status !== 'failed', started: refresh.status === 'started', refreshing: refresh.status === 'started' || refresh.status === 'already-running', refresh });
|
|
3183
3212
|
}
|
|
3184
3213
|
if (url === '/api/undo') return sendJSON(res, 200, undo(body.undoToken));
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// One deterministic advisory selector. Existing safety gates remain independently authoritative.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
const catalogFile = fileURLToPath(new URL('../config/practical-rule-catalog.json', import.meta.url));
|
|
6
|
+
export const RULE_PHASES = Object.freeze(['planning','mutation','execution','checks','review','completion','recovery','compaction','release','memory','learning','evaluation','grounding','maintenance']);
|
|
7
|
+
const ACTIONS = new Set(['read','write','delete','implementation','source-inspection','architecture','source-change','memory-recall','memory-opt-out','checkpoint','recover','learn','capture','evaluate','rollback','model-call','dispatch','parallel-dispatch','privacy','ownership','config-change','unfinished-work','stalled','blocked','publish','version','build','knowledge','search','update','doctor','disk','check']);
|
|
8
|
+
let loaded;
|
|
9
|
+
function freeze(value) { if (value && typeof value === 'object') { Object.values(value).forEach(freeze); Object.freeze(value); } return value; }
|
|
10
|
+
export function loadPracticalCatalog() {
|
|
11
|
+
if (loaded) return loaded;
|
|
12
|
+
const bytes = fs.readFileSync(catalogFile); if (bytes.length > 512 * 1024) throw Error('Practical catalog exceeds bounded configuration size');
|
|
13
|
+
const data = JSON.parse(bytes), ids = new Set();
|
|
14
|
+
if (data.kind !== 'approved-practical-action-catalog' || data.rules?.length !== 100 || !/^[a-f0-9]{64}$/.test(data.auditSha256)) throw Error('Invalid source-bound practical catalog');
|
|
15
|
+
for (const r of data.rules) {
|
|
16
|
+
if (!/^P\d{3}$/.test(r.id) || r.cohort !== 'approved-practical' || ids.has(r.id) || !r.sourceRuleIds?.length || typeof r.actionRule !== 'string'
|
|
17
|
+
|| r.applicability.phases.some(p => !RULE_PHASES.includes(p)) || r.applicability.actions.some(a => !ACTIONS.has(a))) throw Error('Invalid approved practical rule');
|
|
18
|
+
ids.add(r.id);
|
|
19
|
+
}
|
|
20
|
+
loaded = freeze({ data, projectionSha256: crypto.createHash('sha256').update(bytes).digest('hex') }); return loaded;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function selectPracticalRules({ phase, actions = [], maxContextBytes = 2048, maxRules = 6 } = {}, catalog = loadPracticalCatalog()) {
|
|
24
|
+
const base = { schemaVersion: 1, delivery: 'ADVISORY_ONLY', enforcement: 'NOT_ASSERTED_BY_SELECTOR',
|
|
25
|
+
catalogAuditSha256: catalog.data.auditSha256, projectionSha256: catalog.projectionSha256,
|
|
26
|
+
phase: RULE_PHASES.includes(phase) ? phase : null, actions: [], selectedIds: [], deferredIds: [], context: '' };
|
|
27
|
+
if (!RULE_PHASES.includes(phase)) return { ...base, status: 'UNKNOWN_PHASE', reason: 'No broad fallback or all-rules injection' };
|
|
28
|
+
if (!Array.isArray(actions) || actions.length > 16 || actions.some(a => !ACTIONS.has(a))) return { ...base, actions: [], status: 'UNKNOWN_ACTION', reason: 'Explicit supported action scope required' };
|
|
29
|
+
if (!Number.isInteger(maxContextBytes) || maxContextBytes < 128 || maxContextBytes > 8192 || !Number.isInteger(maxRules) || maxRules < 1 || maxRules > 12) throw Error('Invalid practical context budget');
|
|
30
|
+
const applicable = catalog.data.rules.filter(r => r.cohort === 'approved-practical' && /^P\d{3}$/.test(r.id)
|
|
31
|
+
&& r.sourceRuleIds?.length && r.applicability.phases.includes(phase)
|
|
32
|
+
&& (!r.applicability.actions.length || r.applicability.actions.some(a => actions.includes(a))))
|
|
33
|
+
.sort((a,b) => a.applicability.priority - b.applicability.priority || a.id.localeCompare(b.id));
|
|
34
|
+
const header = 'Applicable action guidance only; selecting a rule does not enforce it or replace existing gates.\n';
|
|
35
|
+
let context = header; const selected = [], deferred = [];
|
|
36
|
+
for (const rule of applicable) {
|
|
37
|
+
const line = `[${rule.id}; declared ${rule.declaredEnforcement.kind}; advisory] ${rule.actionRule}\n`;
|
|
38
|
+
if (selected.length >= maxRules || Buffer.byteLength(context + line) > maxContextBytes) { deferred.push(rule.id); continue; }
|
|
39
|
+
selected.push(rule); context += line;
|
|
40
|
+
}
|
|
41
|
+
return { ...base, actions: [...new Set(actions)].sort(), status: selected.length ? 'SELECTED' : applicable.length ? 'BUDGET_DEFERRED' : 'NO_APPLICABLE_RULES',
|
|
42
|
+
selectedIds: selected.map(r => r.id), deferredIds: deferred, context: selected.length ? context : '',
|
|
43
|
+
contextBytes: selected.length ? Buffer.byteLength(context) : 0 };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function practicalSelectionReceipt(selection) {
|
|
47
|
+
return { schemaVersion: 1, phase: selection.phase, actions: selection.actions, status: selection.status,
|
|
48
|
+
selectedIds: selection.selectedIds, deferredIds: selection.deferredIds, catalogAuditSha256: selection.catalogAuditSha256,
|
|
49
|
+
projectionSha256: selection.projectionSha256, contextBytes: selection.contextBytes ?? 0,
|
|
50
|
+
delivery: 'ADVISORY_ONLY', enforcement: 'NOT_ASSERTED_BY_SELECTOR' };
|
|
51
|
+
}
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
export const PRODUCT_INTEGRITY_SCHEMA_VERSION = 1;
|
|
13
13
|
export const PRODUCT_INTEGRITY_TEST_CLASSES = Object.freeze(['essential', 'supporting', 'obsolete']);
|
|
14
14
|
export const PRODUCT_INTEGRITY_PROOF_STRENGTH = Object.freeze(['unit', 'integration', 'packed-artifact', 'candidate-host', 'public-byte']);
|
|
15
|
+
const TRACE_SCOPE = 'contract-and-source-byte-inventory';
|
|
16
|
+
const TRACE_UNTESTED = Object.freeze(['semantic-review', 'behavior-execution']);
|
|
15
17
|
export const PRODUCT_INTEGRITY_PROCESSES = Object.freeze([
|
|
16
18
|
{ id: 'SourceCoverage', upstream: [] }, { id: 'CorpusGeneration', upstream: ['SourceCoverage'] },
|
|
17
19
|
{ id: 'ReleaseProjection', upstream: ['CorpusGeneration'] }, { id: 'RefreshLifecycle', upstream: ['ReleaseProjection'] },
|
|
@@ -119,19 +121,23 @@ export function productIntegrityGovernedPaths(contract = validateProductIntegrit
|
|
|
119
121
|
export function buildProductIntegrityTrace({ root = '.', sourceSha, contract = validateProductIntegrityContract(), inventory } = {}) {
|
|
120
122
|
if (!/^[a-f0-9]{40}$/.test(String(sourceSha || ''))) throw new Error('product integrity trace requires an exact source SHA');
|
|
121
123
|
const validated = validateProductIntegrityContract(contract);
|
|
122
|
-
const unsigned = { schemaVersion:
|
|
124
|
+
const unsigned = { schemaVersion: 2, kind: 'ruvnet-brain-product-integrity-trace', sourceSha, contractSha256: digest(validated), sourceScope: buildSourceScopeReceipt({ root, sourceSha, governedPaths: productIntegrityGovernedPaths(validated), inventory }), contract: validated, verdict: 'PASS',
|
|
125
|
+
evidenceScope: TRACE_SCOPE, semanticReviewVerified: false, behaviorVerified: false, untested: [...TRACE_UNTESTED] };
|
|
123
126
|
return { ...unsigned, traceSha256: digest(unsigned) };
|
|
124
127
|
}
|
|
125
128
|
export function validateProductIntegrityTrace(trace, { root = '.', sourceSha = trace?.sourceSha, inventory } = {}) {
|
|
126
129
|
const { traceSha256, ...unsigned } = trace || {};
|
|
127
|
-
if (trace?.schemaVersion !==
|
|
130
|
+
if (trace?.schemaVersion !== 2 || trace?.kind !== 'ruvnet-brain-product-integrity-trace' || trace.sourceSha !== sourceSha || trace.verdict !== 'PASS'
|
|
131
|
+
|| trace.evidenceScope !== TRACE_SCOPE || trace.semanticReviewVerified !== false || trace.behaviorVerified !== false
|
|
132
|
+
|| canonicalJson(trace.untested) !== canonicalJson(TRACE_UNTESTED) || trace.contractSha256 !== digest(trace.contract) || traceSha256 !== digest(unsigned)) throw new Error('product integrity trace identity is invalid or legacy');
|
|
128
133
|
validateSourceScopeReceipt(trace.sourceScope, { root, inventory });
|
|
129
134
|
const rebuilt = buildProductIntegrityTrace({ root, sourceSha, contract: trace.contract, inventory });
|
|
130
135
|
if (canonicalJson(rebuilt) !== canonicalJson(trace)) throw new Error('product integrity trace differs from the exact source');
|
|
131
136
|
return trace;
|
|
132
137
|
}
|
|
133
138
|
export function renderProductIntegrityTraceMarkdown(contract = validateProductIntegrityContract()) {
|
|
134
|
-
const lines = ['# ADR-072 generated traceability', '', '> Generated from `scripts/product-integrity-contract.mjs`; do not hand-edit.', '',
|
|
139
|
+
const lines = ['# ADR-072 generated traceability', '', '> Generated from `scripts/product-integrity-contract.mjs`; do not hand-edit.', '',
|
|
140
|
+
'> This lists declared proof boundaries. A generated trace PASS verifies contract structure and exact source bytes; semantic review and behavior execution remain unverified.', '', '## Processes', '', '| Process | Upstream | Owns | Contributes |', '|---|---|---|---|'];
|
|
135
141
|
for (const row of contract.processes) lines.push(`| ${row.id} | ${row.upstream.join(', ') || '—'} | ${row.owns.join(', ') || '—'} | ${row.contributes.join(', ') || '—'} |`);
|
|
136
142
|
lines.push('', '## Obligations', '', '| ID | Statement | Owner | Contributors |', '|---|---|---|---|');
|
|
137
143
|
for (const row of contract.obligations) lines.push(`| ${row.id} | ${row.statement} | ${row.owner} | ${row.contributors.join(', ') || '—'} |`);
|
|
@@ -170,7 +176,8 @@ export function runProductIntegrityCli(argv = process.argv.slice(2), io = { stdo
|
|
|
170
176
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const sourceSha = valueAfter(argv, '--source-sha'); const out = valueAfter(argv, '--out');
|
|
171
177
|
if (argv.includes('--trace') && sourceSha && out) { fs.writeFileSync(path.resolve(out), `${canonicalJson(buildProductIntegrityTrace({ root, sourceSha }))}\n`, { flag: 'wx', mode: 0o600 }); return 0; }
|
|
172
178
|
const verify = valueAfter(argv, '--verify-trace');
|
|
173
|
-
if (verify && sourceSha) { const trace = JSON.parse(fs.readFileSync(path.resolve(verify), 'utf8')); validateProductIntegrityTrace(trace, { root, sourceSha }); io.stdout.write(`${canonicalJson({ verdict: 'PASS',
|
|
179
|
+
if (verify && sourceSha) { const trace = JSON.parse(fs.readFileSync(path.resolve(verify), 'utf8')); validateProductIntegrityTrace(trace, { root, sourceSha }); io.stdout.write(`${canonicalJson({ verdict: 'PASS', evidenceScope: TRACE_SCOPE,
|
|
180
|
+
semanticReviewVerified: false, behaviorVerified: false, traceSha256: trace.traceSha256 })}\n`); return 0; }
|
|
174
181
|
throw new Error('invalid product integrity argument combination');
|
|
175
182
|
}
|
|
176
183
|
if (((() => { try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })())) { try { process.exitCode = runProductIntegrityCli(); } catch (error) { console.error(`[product-integrity-contract] ${error.message}`); process.exitCode = 1; } }
|
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
// Reviewed stabilization release boundaries. Legacy tests remain developer diagnostics.
|
|
2
2
|
export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
3
3
|
"source": [
|
|
4
|
+
{
|
|
5
|
+
"id": "source-bound-installed-update-coordinator",
|
|
6
|
+
"reason": "Allowed channels and immutable Claude artifact paths reject downgrades, ambiguous registry records, changed bytes, extra active resources and escape paths; Console success requires the exact launched source closure. POSIX owner fixtures additionally enforce original-prefix installs, existing-tool-only policy, shared locks and source-bound scheduler receipts",
|
|
7
|
+
"files": ["tests/unit/developer-update-policy-portable.test.mjs", "tests/unit/plugin-artifact-proof.test.mjs", "tests/unit/console-suite-update.test.mjs"],
|
|
8
|
+
"platformFiles": {
|
|
9
|
+
"linux": ["tests/unit/developer-update.test.mjs"],
|
|
10
|
+
"macos": ["tests/unit/developer-update.test.mjs"]
|
|
11
|
+
}
|
|
12
|
+
},
|
|
4
13
|
{
|
|
5
14
|
"id": "installer-release-argument-safety",
|
|
6
15
|
"reason": "Incomplete named releases refuse before network or settings writes; valid named releases and installer help retain their contracts",
|
|
@@ -282,6 +291,11 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
|
|
|
282
291
|
}
|
|
283
292
|
],
|
|
284
293
|
"integration": [
|
|
294
|
+
{
|
|
295
|
+
"id": "authenticated-update-coordinator-interface",
|
|
296
|
+
"reason": "The actual Console server reports unrun status without policy writes, rejects absent tokens, cross-origin requests and invalid policies, and refuses a second updater while the shared lock is owned",
|
|
297
|
+
"files": ["tests/integration/console-suite-update-endpoint.test.mjs"]
|
|
298
|
+
},
|
|
285
299
|
{
|
|
286
300
|
"id": "managed-checker-kernel-boundary",
|
|
287
301
|
"reason": "Native read-only sandbox denies acceptance-script writes outside the authorized project",
|
|
@@ -236,6 +236,7 @@ export function sampleIdFor(remedy) {
|
|
|
236
236
|
switch (remedy.key) {
|
|
237
237
|
case 'memory-index': return 'repair:memory-index';
|
|
238
238
|
case 'learning-flush': return 'learning:flush';
|
|
239
|
+
case 'learning-legacy-user-flush': return 'learning:flush-legacy-user';
|
|
239
240
|
case 'learning-train': return 'learning:train';
|
|
240
241
|
case 'distill-fleet': return 'learning:distill-fleet';
|
|
241
242
|
case 'enable-memory-distillation': return 'enable:memory-distillation';
|
|
@@ -58,10 +58,13 @@ export function buildSourceScopeReceipt({ root, governedPaths, sourceSha = null,
|
|
|
58
58
|
const missing = governed.filter((relative) => !inventorySet.has(relative));
|
|
59
59
|
if (missing.length) throw new Error(`governed source is outside repository inventory: ${missing.join(', ')}`);
|
|
60
60
|
const byPath = new Map(files.map((row) => [row.path, row]));
|
|
61
|
-
|
|
61
|
+
// Hashing reads bytes; it does not inspect meaning or execute reviewed behavior.
|
|
62
|
+
const governedFiles = governed.map((relative) => ({ ...byPath.get(relative), bytesReadComplete: true }));
|
|
62
63
|
const body = {
|
|
63
|
-
schemaVersion:
|
|
64
|
+
schemaVersion: 2,
|
|
64
65
|
kind: 'ruvnet-brain-source-scope-receipt',
|
|
66
|
+
evidenceScope: 'complete-repository-byte-inventory',
|
|
67
|
+
semanticReview: { status: 'UNKNOWN', performed: false },
|
|
65
68
|
sourceSha,
|
|
66
69
|
repository: { fileCount: files.length, inventorySha256: digest(files) },
|
|
67
70
|
governed: { fileCount: governedFiles.length, files: governedFiles },
|
|
@@ -70,9 +73,11 @@ export function buildSourceScopeReceipt({ root, governedPaths, sourceSha = null,
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
export function validateSourceScopeReceipt(receipt, { root, inventory = gitInventory } = {}) {
|
|
73
|
-
if (receipt?.schemaVersion !==
|
|
76
|
+
if (receipt?.schemaVersion !== 2 || receipt?.kind !== 'ruvnet-brain-source-scope-receipt'
|
|
77
|
+
|| receipt.evidenceScope !== 'complete-repository-byte-inventory'
|
|
78
|
+
|| canonicalJson(receipt.semanticReview) !== canonicalJson({ status: 'UNKNOWN', performed: false })
|
|
74
79
|
|| !Number.isInteger(receipt.repository?.fileCount) || !Array.isArray(receipt.governed?.files)
|
|
75
|
-
|| receipt.governed.files.some((row) => row.
|
|
80
|
+
|| receipt.governed.files.some((row) => row.bytesReadComplete !== true || Object.hasOwn(row, 'readComplete'))) throw new Error('source scope receipt is malformed or legacy; rebuild byte inventory evidence');
|
|
76
81
|
if (digest(payload(receipt)) !== receipt.receiptSha256) throw new Error('source scope receipt digest mismatch');
|
|
77
82
|
const rebuilt = buildSourceScopeReceipt({ root, sourceSha: receipt.sourceSha,
|
|
78
83
|
governedPaths: receipt.governed.files.map(({ path: relative }) => relative), inventory });
|
package/scripts/stack-sync.mjs
CHANGED
|
@@ -36,6 +36,9 @@ import { spawnSync } from 'node:child_process';
|
|
|
36
36
|
import fs from 'node:fs';
|
|
37
37
|
import os from 'node:os';
|
|
38
38
|
import path from 'node:path';
|
|
39
|
+
import { runDeveloperUpdate, readDeveloperUpdateConfig } from '../plugin/scripts/developer-update.mjs';
|
|
40
|
+
import { FAMILY, PLUGIN_MARKETPLACES, cmpVersion, pickTargetTag, normalizeNpmDistTags } from '../plugin/scripts/developer-update-policy.mjs';
|
|
41
|
+
export { FAMILY, PLUGIN_MARKETPLACES, cmpVersion, pickTargetTag, normalizeNpmDistTags } from '../plugin/scripts/developer-update-policy.mjs';
|
|
39
42
|
|
|
40
43
|
const HOME = os.homedir();
|
|
41
44
|
|
|
@@ -78,9 +81,9 @@ const RECEIPT = path.join(HOME, '.cache/ruvnet-brain/stack-sync-receipt.json');
|
|
|
78
81
|
const PLUGINS_DIR = path.join(HOME, '.claude', 'plugins');
|
|
79
82
|
|
|
80
83
|
// The tag policy. ONE table — the single source of truth for "what SHOULD be installed".
|
|
81
|
-
// The
|
|
82
|
-
//
|
|
83
|
-
export const TAG_POLICY = { ruflo: '
|
|
84
|
+
// The recommended default is latest. The canonical owner config can explicitly select alpha.
|
|
85
|
+
// Unlisted packages still get DEFAULT_TAG; installed Kit retains next/latest ordering.
|
|
86
|
+
export const TAG_POLICY = { ruflo: 'latest', '@claude-flow/cli': 'latest' };
|
|
84
87
|
export const DEFAULT_TAG = 'latest';
|
|
85
88
|
|
|
86
89
|
// Resolve a package's TARGET version to the NEWEST of its candidate tags — the policy tag AND latest —
|
|
@@ -91,19 +94,10 @@ export const DEFAULT_TAG = 'latest';
|
|
|
91
94
|
// 3.32.0 @alpha target, so it correctly refused to downgrade and never chased 3.32.7). Considering both
|
|
92
95
|
// tags and taking the higher version fixes that in EITHER direction: alpha-leads → track alpha;
|
|
93
96
|
// 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
97
|
|
|
104
98
|
// What counts as "the stack": an explicit allow-list pattern, not a loose scope match, so a stray
|
|
105
99
|
// package can never be swept into a global install by accident.
|
|
106
|
-
|
|
100
|
+
|
|
107
101
|
|
|
108
102
|
// The plugin side of the same allow-list intent (ISSUE #22). Plugin identifiers are marketplace
|
|
109
103
|
// names (`ruflo-core`, `ruvnet-brain`, `cog-beehive-monitor`), NOT npm package names, so FAMILY
|
|
@@ -114,7 +108,7 @@ export const FAMILY = /^(ruflo|ruvector|ruvector-extensions|ruvi|ruvbot|qudag|fl
|
|
|
114
108
|
// stray third-party plugin can never be swept into the RuvNet stack by accident. FAMILY is still
|
|
115
109
|
// applied as a secondary matcher, so a future rUv plugin shipped through a different marketplace is
|
|
116
110
|
// still counted.
|
|
117
|
-
|
|
111
|
+
|
|
118
112
|
|
|
119
113
|
const log = (m) => console.log(m);
|
|
120
114
|
const die = (m) => { console.error(`\n FAILED: ${m}`); process.exit(1); };
|
|
@@ -123,30 +117,7 @@ const die = (m) => { console.error(`\n FAILED: ${m}`); process.exit(1); };
|
|
|
123
117
|
// Prerelease-aware per semver: a prerelease sorts BEFORE its release (3.28.0-alpha.1 < 3.28.0),
|
|
124
118
|
// and numeric identifiers compare numerically — alpha.9 < alpha.10, which a string compare gets
|
|
125
119
|
// backwards, and a string compare is precisely the bug this whole file exists to kill.
|
|
126
|
-
|
|
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
|
-
}
|
|
120
|
+
|
|
150
121
|
export const isBehind = (installed, target) => cmpVersion(installed, target) < 0;
|
|
151
122
|
|
|
152
123
|
export function installedVersion(pkg, lib = GLOBAL_LIB) {
|
|
@@ -219,7 +190,7 @@ function listInstalled({ lib = GLOBAL_LIB, pluginsDir = PLUGINS_DIR } = {}) {
|
|
|
219
190
|
function registryTags(pkg) {
|
|
220
191
|
const r = spawnSync('npm', ['view', pkg, 'dist-tags', '--json'], { encoding: 'utf8', timeout: 30000 });
|
|
221
192
|
if (r.status !== 0 || !r.stdout) return null;
|
|
222
|
-
try { return JSON.parse(r.stdout); } catch { return null; }
|
|
193
|
+
try { return normalizeNpmDistTags(JSON.parse(r.stdout)); } catch { return null; }
|
|
223
194
|
}
|
|
224
195
|
|
|
225
196
|
// A second copy of a stack package in the npx cache can only ever SHADOW the global one.
|
|
@@ -251,13 +222,13 @@ export function findShadows(npxCache = NPX_CACHE, lib = GLOBAL_LIB) {
|
|
|
251
222
|
// isBehind — this function assigns a state label, it does not compare versions itself except through
|
|
252
223
|
// the single comparator. Extracted so audit() (CLI, may exit) and auditModel() (embedders, never
|
|
253
224
|
// exits) share ONE classification, never two that can drift.
|
|
254
|
-
export function classify(pkgs) {
|
|
225
|
+
export function classify(pkgs, { channel = readDeveloperUpdateConfig().channel } = {}) {
|
|
255
226
|
return pkgs.map((p) => {
|
|
256
227
|
// Installed records prove presence, not marketplace currency.
|
|
257
228
|
if (p.source === 'plugin') {
|
|
258
229
|
return { ...p, tag: 'plugin', target: null, state: p.installed ? 'INSTALLED_UNVERIFIED' : 'BROKEN', evidence: 'local install record only; marketplace revision not checked' };
|
|
259
230
|
}
|
|
260
|
-
const want =
|
|
231
|
+
const want = p.name === '@pacphi/agentic-kit' ? 'next' : channel === 'alpha' && FAMILY.test(p.name) ? 'alpha' : DEFAULT_TAG;
|
|
261
232
|
const tags = registryTags(p.name);
|
|
262
233
|
// Newest of {policy tag, latest} — see pickTargetTag: "track @alpha" alone pinned the core behind
|
|
263
234
|
// @latest on 2026-07-18. A package with no alpha tag falls through to latest.
|
|
@@ -348,95 +319,11 @@ function report({ rows, shadows, stale }) {
|
|
|
348
319
|
return { behind: rows.filter((r) => r.state === 'BEHIND'), broken: rows.filter((r) => r.state === 'BROKEN'), stale };
|
|
349
320
|
}
|
|
350
321
|
|
|
351
|
-
function
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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(` No npm repair selected. Audit: ${JSON.stringify(summarizeAudit(a))}`);
|
|
395
|
-
if (!dryRun) writeReceipt(a, [], []);
|
|
396
|
-
process.exitCode = summarizeAudit(a).exitCode;
|
|
397
|
-
return;
|
|
398
|
-
}
|
|
399
|
-
if (dryRun) {
|
|
400
|
-
if (toInstall.length) log(` would install: ${toInstall.join(' ')}`);
|
|
401
|
-
if (stale.length) log(` would purge ${stale.length} stale npx shadow(s)`);
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// INSTALL FIRST, PURGE SECOND. (Adversarial review, 2026-07-14.) The first version purged the
|
|
406
|
-
// shadows before installing — so a failed install left the user with their shadows gone AND the
|
|
407
|
-
// global not yet fixed: strictly WORSE than when they started, with no way back. A repair step
|
|
408
|
-
// that can leave you worse off than not running it is not a repair.
|
|
409
|
-
if (toInstall.length) {
|
|
410
|
-
log(` installing: ${toInstall.join(' ')}`);
|
|
411
|
-
const r = spawnSync('npm', ['install', '-g', '--prefix', PREFIX, ...toInstall],
|
|
412
|
-
{ stdio: 'inherit', timeout: 15 * 60 * 1000 });
|
|
413
|
-
if (r.status !== 0) die(`npm install -g exited ${r.status}; the stack is NOT synced. Shadows left untouched.`);
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
const purged = [];
|
|
417
|
-
for (const s of stale) {
|
|
418
|
-
// Purge the whole npx dir: it is a disposable resolution cache (npm re-creates it on demand),
|
|
419
|
-
// and a mixed-version dir is exactly how a stale transitive copy survives a targeted delete.
|
|
420
|
-
try { fs.rmSync(s.dir, { recursive: true, force: true }); purged.push(`${s.name}@${s.version}`); } catch { /* best effort */ }
|
|
421
|
-
}
|
|
422
|
-
if (purged.length) log(` purged ${purged.length} stale shadow(s): ${purged.join(', ')}`);
|
|
423
|
-
|
|
424
|
-
// VERIFY AGAINST THE DISK. An installer that trusts its own exit code is a hope, not a guarantee:
|
|
425
|
-
// the old nightly printed "auto-update finished cleanly" from npm's status alone, which is exactly
|
|
426
|
-
// how a stack rots while every log line insists it is healthy.
|
|
427
|
-
const wrong = [];
|
|
428
|
-
for (const r of [...behind, ...broken]) {
|
|
429
|
-
if (!r.target) continue;
|
|
430
|
-
const now = installedVersion(r.name);
|
|
431
|
-
if (now !== r.target) wrong.push(`${r.name}: expected ${r.target}, disk says ${now ?? 'MISSING'}`);
|
|
432
|
-
}
|
|
433
|
-
if (wrong.length) die(`npm reported success but THE DISK DISAGREES:\n - ${wrong.join('\n - ')}`);
|
|
434
|
-
|
|
435
|
-
const after = audit();
|
|
436
|
-
writeReceipt(after, toInstall, purged);
|
|
437
|
-
process.exitCode = summarizeAudit(after).exitCode;
|
|
438
|
-
log(` Remaining audit: ${JSON.stringify(summarizeAudit(after))}`);
|
|
439
|
-
log(`\n synced ${toInstall.length} package(s); purged ${purged.length} shadow(s); verified against disk.`);
|
|
322
|
+
async function sync({ dryRun = false } = {}) {
|
|
323
|
+
const config = readDeveloperUpdateConfig();
|
|
324
|
+
const receipt = await runDeveloperUpdate({ mode: dryRun ? 'check' : 'apply', config });
|
|
325
|
+
log(`Coordinated update ${receipt.state}; ${receipt.steps.length} installed package owners checked.`);
|
|
326
|
+
if (receipt.coverage.notes.length) log(`Excluded or preserved: ${receipt.coverage.notes.join('; ')}`);
|
|
440
327
|
}
|
|
441
328
|
|
|
442
329
|
// Importable as a module by the tests; only acts when run as a CLI.
|
|
@@ -451,13 +338,13 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('stack-sync.mjs'))
|
|
|
451
338
|
if (summary.shadows) log(' Cached copies are present; inspect invocation paths before removing any cache. No copies removed by audit.');
|
|
452
339
|
process.exitCode = summary.exitCode;
|
|
453
340
|
} else if (args.includes('--sync')) {
|
|
454
|
-
sync({ dryRun: args.includes('--dry-run') });
|
|
341
|
+
sync({ dryRun: args.includes('--dry-run') }).catch(error => { console.error(error.message); process.exitCode = 1; });
|
|
455
342
|
} else {
|
|
456
343
|
log(`
|
|
457
344
|
stack-sync — one global copy of the RuvNet stack. Correct. Provable.
|
|
458
345
|
|
|
459
346
|
--audit report drift; exit 1 if anything is behind, broken, or shadowed
|
|
460
|
-
--sync
|
|
347
|
+
--sync update existing owners through the shared coordinator; cleanup is explicit
|
|
461
348
|
--dry-run with --sync: say what it would do, change nothing
|
|
462
349
|
|
|
463
350
|
Tag policy: ${JSON.stringify(TAG_POLICY)}; everything else @${DEFAULT_TAG}
|