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.
Files changed (86) hide show
  1. package/README.md +15 -19
  2. package/bin/developer-update.mjs +3 -0
  3. package/bin/install.mjs +15 -3
  4. package/bin/nightly-refresh.mjs +17 -1
  5. package/config/practical-rule-catalog.json +111 -0
  6. package/console/CONTRACT.md +47 -3
  7. package/console/app.js +112 -20
  8. package/package.json +5 -2
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/commands/brain-console.md +13 -1
  12. package/plugin/commands/configure.md +2 -2
  13. package/plugin/commands/rnb.md +13 -1
  14. package/plugin/commands/rnbc.md +13 -1
  15. package/plugin/commands/rvbc.md +13 -1
  16. package/plugin/commands/rvcb.md +13 -1
  17. package/plugin/hooks/codex-hooks.json +13 -1
  18. package/plugin/hooks/hook-contracts.json +20 -4
  19. package/plugin/scripts/adr-currency-gate.mjs +75 -31
  20. package/plugin/scripts/advocacy-outcomes.mjs +18 -4
  21. package/plugin/scripts/agentdb-recall.mjs +161 -26
  22. package/plugin/scripts/automatic-update.mjs +11 -2
  23. package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
  24. package/plugin/scripts/capability-registry.mjs +55 -114
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
  26. package/plugin/scripts/codex-hook-adapter.mjs +33 -6
  27. package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
  28. package/plugin/scripts/continuation-gate.mjs +2 -1
  29. package/plugin/scripts/continuity-brief.mjs +55 -22
  30. package/plugin/scripts/continuity-events.mjs +93 -17
  31. package/plugin/scripts/continuity-hook-policy.mjs +1 -1
  32. package/plugin/scripts/continuity-journal.mjs +77 -29
  33. package/plugin/scripts/decision-gate.mjs +62 -37
  34. package/plugin/scripts/decision-outcomes.mjs +192 -199
  35. package/plugin/scripts/detach.mjs +53 -13
  36. package/plugin/scripts/developer-update-cleanup.mjs +38 -0
  37. package/plugin/scripts/developer-update-lock.mjs +30 -0
  38. package/plugin/scripts/developer-update-maintenance.mjs +178 -0
  39. package/plugin/scripts/developer-update-owner.mjs +24 -0
  40. package/plugin/scripts/developer-update-policy.mjs +48 -0
  41. package/plugin/scripts/developer-update.mjs +435 -0
  42. package/plugin/scripts/doc-currency.mjs +993 -0
  43. package/plugin/scripts/ground-ruvnet.sh +16 -3
  44. package/plugin/scripts/grounding-answer.mjs +18 -16
  45. package/plugin/scripts/grounding-stamp.sh +3 -0
  46. package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
  47. package/plugin/scripts/grounding-turn-gate.mjs +34 -29
  48. package/plugin/scripts/grounding-turn-mark.mjs +101 -17
  49. package/plugin/scripts/hook-context-budget.mjs +90 -0
  50. package/plugin/scripts/hook-registry.mjs +129 -1
  51. package/plugin/scripts/hook-shim.mjs +20 -2
  52. package/plugin/scripts/host-update.mjs +6 -3
  53. package/plugin/scripts/lesson-gate.mjs +1 -1
  54. package/plugin/scripts/nightly-scheduler.mjs +55 -16
  55. package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
  56. package/plugin/scripts/project-capture-queue.mjs +22 -15
  57. package/plugin/scripts/project-progression-producer.mjs +15 -8
  58. package/plugin/scripts/project-progression-reader.mjs +14 -4
  59. package/plugin/scripts/project-progression-session-start.mjs +29 -17
  60. package/plugin/scripts/project-progression-sources.mjs +24 -10
  61. package/plugin/scripts/project-progression-store.mjs +7 -2
  62. package/plugin/scripts/project-store-resolver.mjs +10 -7
  63. package/plugin/scripts/project-transition-hook.mjs +20 -12
  64. package/plugin/scripts/session-snapshot-hook.mjs +20 -9
  65. package/plugin/scripts/session-start-budget.mjs +18 -0
  66. package/plugin/scripts/session-start-core.mjs +32 -6
  67. package/plugin/scripts/session-start-trace.mjs +2 -1
  68. package/plugin/scripts/turn-outcome-capture.mjs +7 -5
  69. package/plugin/scripts/unprompted-runtime.mjs +36 -10
  70. package/plugin/scripts/user-settings.mjs +7 -1
  71. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  72. package/scripts/codex-managed-terminal.mjs +1 -1
  73. package/scripts/console-runtime-identity.mjs +2 -0
  74. package/scripts/console-suite-update.mjs +117 -0
  75. package/scripts/doc-currency.mjs +6 -990
  76. package/scripts/hook-qualify-core.mjs +30 -1
  77. package/scripts/model-managed-prompt.mjs +1 -1
  78. package/scripts/model-managed-workflow-service.mjs +113 -14
  79. package/scripts/model-routing-controller.mjs +112 -21
  80. package/scripts/onboarding-console.mjs +41 -12
  81. package/scripts/practical-rule-selector.mjs +51 -0
  82. package/scripts/product-integrity-contract.mjs +11 -4
  83. package/scripts/release-qualification-contract.mjs +14 -0
  84. package/scripts/remedy-registry.mjs +1 -0
  85. package/scripts/source-scope-receipt.mjs +9 -4
  86. 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 brain refresh', type: 'bool', help: 'Rebuild the knowledge base from pinned versions overnight so answers stay current.' },
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 brain refresh',
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
- function kickRefreshDetailed({ force = false } = {}) {
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 = spawn(process.execPath, [SELF, '--refresh-cache'], { detached: true, stdio: 'ignore', cwd: process.cwd() });
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
- const r = spawnSync('npm', ['install', '-g', '--prefix', NPM_PREFIX, `${entry.pkg}@${entry.prevVersion}`], { encoding: 'utf8', timeout: 15 * 60 * 1000 });
2926
- if (r.status === 0) markUndoConsumed(undoToken);
2927
- return { ok: r.status === 0, log: r.status === 0 ? `reinstalled ${entry.pkg}@${entry.prevVersion}` : (r.stderr || '').slice(-800) };
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/activity') return sendJSON(res, 200, gatherActivity(cwd));
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: 1, kind: 'ruvnet-brain-product-integrity-trace', sourceSha, contractSha256: digest(validated), sourceScope: buildSourceScopeReceipt({ root, sourceSha, governedPaths: productIntegrityGovernedPaths(validated), inventory }), contract: validated, verdict: 'PASS', untested: [] };
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 !== 1 || trace?.kind !== 'ruvnet-brain-product-integrity-trace' || trace.sourceSha !== sourceSha || trace.verdict !== 'PASS' || canonicalJson(trace.untested) !== '[]' || trace.contractSha256 !== digest(trace.contract) || traceSha256 !== digest(unsigned)) throw new Error('product integrity trace identity is invalid');
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.', '', '## Processes', '', '| Process | Upstream | Owns | Contributes |', '|---|---|---|---|'];
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', traceSha256: trace.traceSha256 })}\n`); return 0; }
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
- const governedFiles = governed.map((relative) => ({ ...byPath.get(relative), readComplete: true }));
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: 1,
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 !== 1 || receipt?.kind !== 'ruvnet-brain-source-scope-receipt'
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.readComplete !== true)) throw new Error('source scope receipt is malformed');
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 });
@@ -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 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
+ // 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
- 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\/)/;
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
- export const PLUGIN_MARKETPLACES = new Set(['ruflo', 'ruview', 'ruvnet-brain', 'cognitum']);
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
- 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
- }
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 = TAG_POLICY[p.name] || DEFAULT_TAG;
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 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(` 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 install what is BEHIND (never downgrades), purge npx shadows, verify against disk
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}