ruvnet-brain 4.5.17 → 4.6.1

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 (36) hide show
  1. package/README.md +242 -514
  2. package/bin/developer-update.mjs +3 -0
  3. package/bin/install.mjs +14 -2
  4. package/bin/nightly-refresh.mjs +17 -1
  5. package/console/CONTRACT.md +47 -3
  6. package/console/app.js +112 -20
  7. package/package.json +4 -2
  8. package/plugin/.claude-plugin/plugin.json +1 -1
  9. package/plugin/.codex-plugin/plugin.json +1 -1
  10. package/plugin/commands/brain-console.md +13 -1
  11. package/plugin/commands/configure.md +2 -2
  12. package/plugin/commands/rnb.md +13 -1
  13. package/plugin/commands/rnbc.md +13 -1
  14. package/plugin/commands/rvbc.md +13 -1
  15. package/plugin/commands/rvcb.md +13 -1
  16. package/plugin/scripts/automatic-update.mjs +11 -2
  17. package/plugin/scripts/continuation-gate.mjs +2 -1
  18. package/plugin/scripts/decision-gate.mjs +0 -16
  19. package/plugin/scripts/developer-update-cleanup.mjs +38 -0
  20. package/plugin/scripts/developer-update-lock.mjs +30 -0
  21. package/plugin/scripts/developer-update-maintenance.mjs +178 -0
  22. package/plugin/scripts/developer-update-owner.mjs +24 -0
  23. package/plugin/scripts/developer-update-policy.mjs +48 -0
  24. package/plugin/scripts/developer-update.mjs +435 -0
  25. package/plugin/scripts/host-update.mjs +6 -3
  26. package/plugin/scripts/nightly-scheduler.mjs +55 -16
  27. package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
  28. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  29. package/scripts/codex-managed-terminal.mjs +1 -1
  30. package/scripts/console-runtime-identity.mjs +2 -0
  31. package/scripts/console-suite-update.mjs +117 -0
  32. package/scripts/hook-qualify-core.mjs +29 -0
  33. package/scripts/onboarding-console.mjs +41 -12
  34. package/scripts/release-qualification-contract.mjs +19 -0
  35. package/scripts/remedy-registry.mjs +1 -0
  36. package/scripts/stack-sync.mjs +19 -132
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { developerUpdateCli } from '../plugin/scripts/developer-update.mjs';
3
+ developerUpdateCli().catch(error => { console.error(error.message); process.exitCode = 1; });
package/bin/install.mjs CHANGED
@@ -27,6 +27,7 @@ import { acquireRefreshLock, finishRefreshReceipt, openRefreshReceipt, physicalP
27
27
  import { assessLifecycleEvidence, pruneLifecycleEvidence } from '../kb/lifecycle-evidence-retention.mjs';
28
28
  import { checkDiskSpace, recoverIncompleteStorageTransactions } from '../kb/update-storage-transaction.mjs';
29
29
  import { saveUpdateSource, stableNode } from '../plugin/scripts/automatic-update.mjs';
30
+ import { acquireDeveloperLock } from '../plugin/scripts/developer-update-lock.mjs';
30
31
  import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
31
32
  import { kbCopyProof } from '../plugin/scripts/kb-copy-proof.mjs';
32
33
  import { assessMoveLeftovers, isVolumeMetadata } from '../plugin/scripts/footprint-io.mjs';
@@ -4435,7 +4436,7 @@ function enableNightly() {
4435
4436
  testMode: TEST_MODE });
4436
4437
  if (status.state !== 'on') throw new Error(status.evidence);
4437
4438
  ok(`nightly updates enabled — ${status.evidence}`);
4438
- info(c.dim(`identity ${NIGHTLY_LABEL}; immutable runner ${registration.runnerSha256.slice(0, 16)}…; 03:47 local`));
4439
+ info(c.dim(`identity ${NIGHTLY_LABEL}; immutable runner ${registration.runnerSha256.slice(0, 16)}…; 03:30 local`));
4439
4440
  if (TEST_MODE) warn('RUVNET_BRAIN_TEST=1 — scheduler registration was written but OS activation was safely simulated');
4440
4441
  } catch (error) {
4441
4442
  console.error(`\n${c.red('✗ nightly updates were NOT enabled:')} ${error.message}`);
@@ -6331,7 +6332,18 @@ the installer reports that boot-level declarations changed.
6331
6332
  }
6332
6333
  if (FLAG_DEMO) return runDemo();
6333
6334
  if (FLAG_FEEDBACK) return runFeedback();
6334
- if (FLAG_UPDATE) return refuseOverSetAsideBrain() || runUpdate();
6335
+ if (FLAG_UPDATE) {
6336
+ if (refuseOverSetAsideBrain()) return;
6337
+ const developerLock = acquireDeveloperLock({ brainHome: process.env.RUVNET_BRAIN_HOME || path.dirname(resolvedKbDir()) });
6338
+ const previousToken = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN;
6339
+ process.env.RUVNET_DEVELOPER_UPDATE_TOKEN = developerLock.token;
6340
+ try { return await runUpdate(); }
6341
+ finally {
6342
+ if (previousToken === undefined) delete process.env.RUVNET_DEVELOPER_UPDATE_TOKEN;
6343
+ else process.env.RUVNET_DEVELOPER_UPDATE_TOKEN = previousToken;
6344
+ developerLock.release();
6345
+ }
6346
+ }
6335
6347
  if (FLAG_ENABLE_NIGHTLY) return enableNightly();
6336
6348
  if (FLAG_DISABLE_NIGHTLY) return disableNightly();
6337
6349
  // Standalone, like the nightly pair above. Without these, the flags existed only as a way to
@@ -61,13 +61,29 @@ Object.assign(process.env, registration.environment || {});
61
61
  let invocation;
62
62
  try {
63
63
  const modules = registration.updateModules;
64
- if (!modules || Object.keys(modules).sort().join(',') !== 'automatic-update.mjs,ruvnet-gate1-pattern.mjs,user-settings.mjs') throw new Error('registered update module closure is missing');
64
+ const legacy = ['automatic-update.mjs', 'ruvnet-gate1-pattern.mjs', 'user-settings.mjs'];
65
+ const bridged = [...legacy, 'developer-update-owner.mjs'];
66
+ const currentLegacy = [...bridged, 'developer-update-policy.mjs'];
67
+ if (!modules?.['developer-update-policy.mjs'] && registration.mode === 'developer-suite') throw new Error('suite policy closure is missing');
68
+ if (registration.mode === 'developer-suite' && (!path.isAbsolute(modules['developer-update-policy.mjs'].path) || path.basename(modules['developer-update-policy.mjs'].path) !== 'developer-update-policy.mjs' || path.dirname(modules['developer-update-policy.mjs'].path) !== path.dirname(modules['automatic-update.mjs'].path) || !fs.lstatSync(modules['developer-update-policy.mjs'].path).isFile())) throw new Error('suite policy path identity mismatch');
69
+ if (registration.mode === 'developer-suite' && crypto.createHash('sha256').update(fs.readFileSync(modules['developer-update-policy.mjs'].path)).digest('hex') !== modules['developer-update-policy.mjs'].sha256) throw new Error('suite policy digest mismatch');
70
+ const executionPolicy = registration.mode === 'developer-suite' ? await import(pathToFileURL(modules['developer-update-policy.mjs'].path).href) : null;
71
+ const suite = executionPolicy ? [...new Set([...currentLegacy, ...executionPolicy.EXECUTION_MODULES])] : [];
72
+ const expected = registration.mode === 'developer-suite' ? [suite] : [legacy, bridged, currentLegacy];
73
+ if (!modules || !expected.some(names => Object.keys(modules).sort().join(',') === names.sort().join(','))) throw new Error('registered update module closure is missing');
65
74
  for (const [name, item] of Object.entries(modules)) {
66
75
  if (!path.isAbsolute(item.path) || path.basename(item.path) !== name
67
76
  || path.dirname(item.path) !== path.dirname(modules['automatic-update.mjs'].path)
68
77
  || !fs.lstatSync(item.path).isFile()
69
78
  || crypto.createHash('sha256').update(fs.readFileSync(item.path)).digest('hex') !== item.sha256) throw new Error('update module digest mismatch');
70
79
  }
80
+ if (registration.mode === 'developer-suite') {
81
+ const coordinator = await import(pathToFileURL(modules['developer-update.mjs'].path).href);
82
+ const receipt = await coordinator.runDeveloperUpdate({ mode: 'apply', env: { ...process.env,
83
+ RUVNET_NIGHTLY_IDENTITY: registration.identity, RUVNET_NIGHTLY: '1' } });
84
+ console.log(JSON.stringify(receipt));
85
+ process.exit(0);
86
+ }
71
87
  const policy = await import(pathToFileURL(modules['automatic-update.mjs'].path).href);
72
88
  process.env.PATH = policy.automaticPath({ nodePath: registration.nodePath });
73
89
  invocation = policy.automaticInvocation(['--update', '--no-nightly-prompt'], {
@@ -1,8 +1,8 @@
1
- # Onboarding Console — API + data contract (v1)
2
-
3
- Updated: 2026-07-17
1
+ Updated: 2026-10-10 04:37:54 EDT | Version 1.1.2
4
2
  Created: 2026-07-15
5
3
 
4
+ # Onboarding Console — API + data contract (v1)
5
+
6
6
  The single source of truth both the backend (`scripts/onboarding-console.mjs`) and the
7
7
  frontend (`console/index.html` + `app.js` + `style.css`) build against. Implements ADR-0013
8
8
  and DDD-0002. **The ordering is the design: Mirror → Explain → Recommend → (consent) Apply → Undo.**
@@ -13,6 +13,50 @@ A GET with a wrong/absent token still serves read-only state; a POST with a wron
13
13
 
14
14
  ---
15
15
 
16
+ ## Coordinated developer updates
17
+
18
+ `GET /api/suite-update` reads the installed coordinator's canonical policy, latest receipt,
19
+ and shared ownership lock on every request. It never starts an update or writes a policy.
20
+ The same live `suiteUpdate` field decorates `/api/state` and `/api/activity`; it is not persisted
21
+ inside their heavier measurement caches.
22
+
23
+ The response distinguishes `never-run`, `running`, `succeeded`, `failed`, `interrupted`,
24
+ `checked`, and `unavailable`. A scheduled job or a saved setting cannot prove a completed run.
25
+ A check-only receipt cannot prove an apply. The public card shows the saved release policy and
26
+ scope; Latest is recommended, Alpha uses published Ruv alpha tags with Latest fallback, and
27
+ other providers retain their release policies. Newer installed versions remain preserved.
28
+
29
+ `POST /api/suite-update` requires the existing per-launch token and rejects a supplied Origin
30
+ that differs from this loopback server. The body contains only `channel: "latest" | "alpha"`.
31
+ An explicit **Keep all tools updated** click saves that channel and opts existing installations
32
+ into scope `all`, including supported Homebrew, uv, Cargo, and native inventories. It enables the
33
+ existing canonical nightly job through the same installer scheduler adapter, verifies enforcement,
34
+ mirrors the nightly choice, and then runs one immediate update. Reads never enroll a scheduler. It preserves
35
+ cleanup and managed-callback preferences, never installs a missing provider, and never changes
36
+ project dependencies. An invalid channel or non-idle shared lock returns a refusal before
37
+ policy mutation or child execution.
38
+
39
+ The server starts the shipped coordinator with its own Node interpreter, a fixed user-home
40
+ working directory and explicit Brain home. The coordinator acquires the shared lock. The adapter
41
+ records an asynchronous launch receipt and accepts success only from a completed apply receipt
42
+ matching the child's PID, exact coordinator source hash, and complete five-module execution snapshot. Activity polls this live read model;
43
+ launch failure, interrupted execution, and a failed provider are distinct from success.
44
+
45
+ The nightly Settings control uses the same coordinator through the installer's existing scheduler
46
+ adapter. Schedule enforcement and the last completed receipt remain separate measured facts.
47
+
48
+ ## Refresh settlement
49
+
50
+ A fresh state response is painted directly from the snapshot that satisfied the freshness poller;
51
+ no second fetch can substitute an older or withdrawn state. Stack, capability, and memory cards
52
+ keep waiting when their own cache is warming or stale. Each fresh recommendation source replaces
53
+ its previous source snapshot, so resolved proposals disappear and changed evidence is repainted.
54
+ The header describes the machine reading's age, without claiming all independent cards finished.
55
+ A forced refresh during an active measurement queues one follow-up in the same project scope.
56
+ While it waits, caches from the earlier writer remain stale. When that writer exits, its late
57
+ snapshots are withdrawn before the follow-up begins, so a pre-mutation scan cannot settle a
58
+ post-mutation request. Repeated requests coalesce into that one follow-up.
59
+
16
60
  ## GET `/api/state` — fast sections (no network)
17
61
 
18
62
  Returns in well under a second. `memory.fleet` is **`null` here by design** — scanning every memory
package/console/app.js CHANGED
@@ -25,6 +25,7 @@ let preStateHash = null; // echoed on /api/apply so the server can refu
25
25
  let lastMemory = null; // last rendered memory card, so the late fleet scan can merge into it
26
26
  const renderedRecIds = new Set();
27
27
  const renderedRecommendations = new Map();
28
+ const recommendationSources = new Map();
28
29
  let stateRecsSettled = false;
29
30
  let stackRecsSettled = false;
30
31
  // Health advocacy (ADR-027) hydrates from /api/memory, the slowest source. Until it has answered,
@@ -1374,7 +1375,7 @@ async function loadCapabilities(attempt = 0) {
1374
1375
  const r = await fetchCapabilities();
1375
1376
  if (r.status === 404) { capsMissingEndpoint(); return; }
1376
1377
  if (!r.ok) throw new Error(`/api/capabilities answered HTTP ${r.status}`);
1377
- if (r.data && r.data.warming) {
1378
+ if (r.data && (r.data.warming || r.data.stale === true)) {
1378
1379
  // "Not measured yet" is NOT "off" — this card's entire reason for existing. Rendering a
1379
1380
  // warming answer would produce an empty row list, which on this surface says "you own
1380
1381
  // nothing", the single most damaging thing this page could get wrong.
@@ -1720,14 +1721,26 @@ function addRecommendations(recs, source) {
1720
1721
  'Ordered by what you’re working on — machine-wide updates first (they affect every project), then your most recently active projects.'));
1721
1722
  }
1722
1723
  let dropped = 0;
1723
- const nodes = [];
1724
+ const next = new Map();
1724
1725
  for (const rec of Array.isArray(recs) ? recs : []) {
1725
- if (!rec || rec.id == null || renderedRecIds.has(rec.id)) continue;
1726
- // The DDD invariant, honored in the UI too: no evidence/cost/undo → not rendered.
1726
+ if (!rec || rec.id == null) continue;
1727
+ // A fresh audit replaces this source, including an empty result after an update.
1727
1728
  if (!Array.isArray(rec.evidence) || !rec.evidence.length || !rec.cost || !rec.undo) { dropped += 1; continue; }
1728
- renderedRecIds.add(rec.id);
1729
- renderedRecommendations.set(rec.id, rec);
1730
- nodes.push(buildRecCard(rec));
1729
+ next.set(rec.id, rec);
1730
+ }
1731
+ const previous = recommendationSources.get(source) || new Map();
1732
+ recommendationSources.set(source, next);
1733
+ const nodes = [];
1734
+ for (const id of new Set([...previous.keys(), ...next.keys()])) {
1735
+ const rec = next.get(id) || [...recommendationSources.values()].map(rows => rows.get(id)).find(Boolean);
1736
+ const existing = document.getElementById(`rec-${id}`);
1737
+ if (!rec) {
1738
+ existing?.remove(); renderedRecIds.delete(id); renderedRecommendations.delete(id);
1739
+ continue;
1740
+ }
1741
+ renderedRecIds.add(id); renderedRecommendations.set(id, rec);
1742
+ const card = buildRecCard(rec);
1743
+ if (existing) existing.replaceWith(card); else nodes.push(card);
1731
1744
  }
1732
1745
  // Ordering is by BLAST RADIUS, not arrival time — the slow sources arrive last and matter most.
1733
1746
  //
@@ -2954,6 +2967,83 @@ function nightlyFactsLine(f) {
2954
2967
  f.agree === false ? ' — these disagree, so the toggle above shows no single state until they do.' : '');
2955
2968
  }
2956
2969
 
2970
+ let suiteUpdateTimer = null;
2971
+ let suiteUpdateBusy = false;
2972
+ let suiteUpdateWasActive = false;
2973
+ function suiteUpdateSummary(state) {
2974
+ const labels = { running: 'Update in progress', succeeded: 'Last update completed successfully',
2975
+ failed: 'Last update failed', interrupted: 'Last update has no completed result',
2976
+ checked: 'Last run checked for updates; it did not apply them', 'never-run': 'No update run has been recorded' };
2977
+ const label = state.status === 'succeeded' && state.unverified?.length ? 'Update finished; some checks remain unverified' : labels[state.status];
2978
+ return `${label || 'Update status unknown'}${state.finishedAt ? ` · ${fmtDate(state.finishedAt)}` : ''}${state.error ? ` · ${state.error}` : ''}`;
2979
+ }
2980
+ function renderSuiteUpdate(state) {
2981
+ if (!state) return;
2982
+ const updateFinished = suiteUpdateWasActive && !state.active && ['succeeded', 'failed'].includes(state.status);
2983
+ suiteUpdateWasActive = !!state.active;
2984
+ if (updateFinished) void recheckMachine(); // Measure the changed machine once, including partial failures.
2985
+ let card = document.getElementById('card-suite-update');
2986
+ if (!card) {
2987
+ card = el('section', { class: 'card sub-card rail-act', id: 'card-suite-update', 'aria-labelledby': 'suite-update-heading' },
2988
+ el('div', { class: 'body' },
2989
+ el('h2', { id: 'suite-update-heading' }, 'Keep all tools updated'),
2990
+ chip('recommended', 'cyan'),
2991
+ el('p', { class: 'fineprint' }, 'One coordinated update for your installed developer tools, plugins and Brain knowledge. Enable nightly updates and run the same coordinator now.'),
2992
+ el('label', { for: 'suite-update-channel' }, 'Release policy '),
2993
+ el('select', { id: 'suite-update-channel' },
2994
+ el('option', { value: '', disabled: true }, 'Policy not measured'),
2995
+ el('option', { value: 'latest' }, 'Latest (recommended)'),
2996
+ el('option', { value: 'alpha' }, 'Alpha')),
2997
+ el('p', { class: 'fineprint' }, 'Alpha uses published alpha releases for supported Ruv tools, with Latest as the fallback. Other providers keep their own release channels; installed newer versions are preserved.'),
2998
+ el('button', { class: 'btn btn-apply', id: 'suite-update-run', type: 'button', onclick: async () => {
2999
+ if (suiteUpdateBusy) return;
3000
+ const button = document.getElementById('suite-update-run');
3001
+ const channel = document.getElementById('suite-update-channel').value;
3002
+ suiteUpdateBusy = true; button.disabled = true;
3003
+ try {
3004
+ const result = await postJSON('/api/suite-update', { channel });
3005
+ if (!result.ok || !result.data.ok) throw new Error(result.status === 403 ? TOKEN_MSG : result.data.error || 'The update could not start.');
3006
+ renderSuiteUpdate(result.data.state);
3007
+ } catch (error) { document.getElementById('suite-update-result').textContent = String(error.message || error); }
3008
+ finally { suiteUpdateBusy = false; void pollSuiteUpdate(); }
3009
+ } }, 'Keep all tools updated'),
3010
+ el('p', { id: 'suite-update-result', role: 'status', 'aria-live': 'polite' }),
3011
+ el('p', { id: 'suite-update-nightly', class: 'fineprint' })));
3012
+ document.getElementById('card-settings')?.before(card);
3013
+ }
3014
+ const select = document.getElementById('suite-update-channel');
3015
+ // Preserve an unsaved policy choice during receipt polling.
3016
+ if (!select.dataset.initialized) {
3017
+ select.value = state.channel || '';
3018
+ select.dataset.initialized = '1';
3019
+ }
3020
+ select.disabled = !!state.active;
3021
+ const button = document.getElementById('suite-update-run');
3022
+ button.disabled = suiteUpdateBusy || !!state.active || !state.available || state.lockState !== 'idle';
3023
+ button.textContent = state.active ? 'Updating…' : 'Keep all tools updated';
3024
+ document.getElementById('suite-update-result').textContent = state.available ? suiteUpdateSummary(state) : state.policyError || 'The coordinated updater is unavailable.';
3025
+ document.getElementById('suite-update-nightly').textContent = `Nightly updates: ${state.nightlyState === 'on' ? 'enabled' : state.nightlyState === 'off' ? 'not scheduled' : 'not verified'}. Saved scope: ${state.scope === 'all' ? 'all installed tools' : state.scope === 'ruvnet' ? 'Ruv tools' : 'not measured'}. Clicking this button includes existing npm, Homebrew, uv, Cargo and native tools. It does not install missing tools or change project dependencies.`;
3026
+ let activity = document.getElementById('suite-update-activity');
3027
+ if (!activity) {
3028
+ activity = el('section', { class: 'body', id: 'suite-update-activity', 'aria-label': 'Tool update activity' });
3029
+ document.getElementById('card-activity')?.append(activity);
3030
+ }
3031
+ activity.replaceChildren(el('h3', {}, 'Tool update activity'),
3032
+ el('p', { role: 'status' }, suiteUpdateSummary(state)),
3033
+ state.startedAt ? el('p', { class: 'fineprint' }, `Started ${fmtDate(state.startedAt)} · ${state.mode === 'check' ? 'check only' : 'apply'}`) : '',
3034
+ state.steps?.length ? el('ul', {}, state.steps.map(step => el('li', {}, `${step.name}: ${step.state}`))) : '',
3035
+ state.unverified?.length ? el('details', { class: 'sub' }, el('summary', {}, 'Checks not verified'),
3036
+ el('ul', {}, state.unverified.map(note => el('li', {}, note)))) : '',
3037
+ state.exclusions?.length ? el('details', { class: 'sub' }, el('summary', {}, `${state.exclusions.length} tools preserved`),
3038
+ el('ul', {}, state.exclusions.map(item => el('li', {}, `${item.name}: ${item.reason}`)))) : '',
3039
+ state.sourceSha256 ? el('details', { class: 'sub' }, el('summary', {}, 'Update receipt'), el('p', { class: 'fineprint' }, `Updater source: ${state.sourceSha256.slice(0, 16)}`)) : '');
3040
+ }
3041
+ async function pollSuiteUpdate() {
3042
+ try { renderSuiteUpdate(await getJSON('/api/suite-update')); }
3043
+ catch { const result = document.getElementById('suite-update-result'); if (result) result.textContent = 'Update status could not be measured. Reload to reconnect.'; }
3044
+ if (!suiteUpdateTimer) suiteUpdateTimer = setInterval(() => { if (!document.hidden) void pollSuiteUpdate(); }, 3000);
3045
+ }
3046
+
2957
3047
  function renderSettings(cfg, us, bp) {
2958
3048
  const body = $('#body-settings');
2959
3049
  // UNSAVED CHOICES SURVIVE A REPAINT. RNBC QA 2026-10-01: a background measurement landing while
@@ -3350,7 +3440,7 @@ function bpParts(bp) {
3350
3440
  'Version updates and the health alarm keep running even when the brain is off — an off machine ',
3351
3441
  'has to be able to receive the fix for an off-state bug. The nightly refresh is the part of that ',
3352
3442
  'you can pause: it is the ',
3353
- el('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => jumpToSetting('nightly') }, 'Nightly brain refresh'),
3443
+ el('button', { class: 'btn btn-ghost btn-sm', type: 'button', onclick: () => jumpToSetting('nightly') }, 'Nightly coordinated updates'),
3354
3444
  ' switch in Settings.'),
3355
3445
 
3356
3446
  part('Installed knowledge',
@@ -3683,16 +3773,16 @@ function renderFreshness(state, { polling = false, justLanded = false } = {}) {
3683
3773
  }
3684
3774
  const at = state && (state.measuredAt || state.cachedAt || state.generatedAt);
3685
3775
  if (at) FRESH_BASE = at;
3686
- if (justLanded) {
3687
- pill.className = 'chip tone-green';
3688
- pill.textContent = 'measured just now';
3689
- return;
3690
- }
3691
3776
  if (!at || stampWithdrawn(at)) {
3692
3777
  pill.className = polling ? 'chip tone-cyan' : 'chip tone-warn';
3693
3778
  pill.textContent = polling ? 're-measuring your machine…' : 'this reading was cleared — click to measure again';
3694
3779
  return;
3695
3780
  }
3781
+ if (justLanded) {
3782
+ pill.className = 'chip tone-green';
3783
+ pill.textContent = `machine reading updated · as of ${fmtAge(at)}`;
3784
+ return;
3785
+ }
3696
3786
  pill.className = polling ? 'chip tone-cyan' : (state && state.stale ? 'chip tone-warn' : 'chip tone-grey');
3697
3787
  pill.textContent = `as of ${fmtAge(at)}${polling ? ' · re-measuring…' : ''}`;
3698
3788
  }
@@ -3738,7 +3828,7 @@ function startFreshnessPolling() {
3738
3828
  // ONE painter — the same loadState() the first paint uses, so a polled repaint can never
3739
3829
  // drift from an initial one. It also refreshes preStateHash and the recommendations, which a
3740
3830
  // sections-only repaint silently left pointing at the pre-measurement machine.
3741
- await loadState({ landed: true });
3831
+ await loadState({ landed: true, snapshot: st });
3742
3832
  // The other cards measure on their own clocks (stack, capabilities, fleet). Re-ask them here
3743
3833
  // rather than letting the page claim page-wide freshness on the strength of state alone.
3744
3834
  void loadStack();
@@ -3790,10 +3880,12 @@ async function doManualRefresh() {
3790
3880
  that painted the same bytes twice and cost a round-trip on every open. There is now one call, and
3791
3881
  the "live" reading arrives the only way it can without freezing the server — from the detached
3792
3882
  child, via the freshness poller. */
3793
- async function loadState({ landed = false } = {}) {
3883
+ async function loadState({ landed = false, snapshot = null } = {}) {
3794
3884
  try {
3795
- const state = await getJSON('/api/state');
3885
+ const state = snapshot || await getJSON('/api/state');
3796
3886
  $('#global-error').hidden = true;
3887
+ renderSuiteUpdate(state.suiteUpdate);
3888
+ void pollSuiteUpdate();
3797
3889
 
3798
3890
  // WARMING: NOTHING HAS BEEN MEASURED FOR THIS PROJECT YET. Paint NOTHING. Every render* below
3799
3891
  // would turn a missing section into a positive claim — renderWiring(undefined) prints "No wiring
@@ -3968,7 +4060,7 @@ const WARM_RETRY_MAX = 60;
3968
4060
  async function loadMemoryFleet(attempt = 0) {
3969
4061
  try {
3970
4062
  const m = await getJSON('/api/memory');
3971
- if (m && m.warming) {
4063
+ if (m && (m.warming || m.stale === true)) {
3972
4064
  // The fleet scan is the slowest thing the console does. Until it lands, the memory card keeps
3973
4065
  // whatever /api/state already gave it and simply does not claim a fleet — it must never render
3974
4066
  // "0 projects", which on this card would read as "nothing on your machine learns anything".
@@ -3999,8 +4091,8 @@ async function loadMemoryFleet(attempt = 0) {
3999
4091
  async function loadStack(attempt = 0) {
4000
4092
  try {
4001
4093
  const stack = await getJSON('/api/stack');
4002
- if (stack && stack.warming) {
4003
- setChips('chips-stack', [chip('auditing your stack…', 'wait')]);
4094
+ if (stack && (stack.warming || stack.stale === true)) {
4095
+ setChips('chips-stack', [chip(stack.warming ? 'auditing your stack…' : 're-measuring your stack…', 'wait')]);
4004
4096
  if (attempt < WARM_RETRY_MAX) setTimeout(() => { void loadStack(attempt + 1); }, WARM_RETRY_MS);
4005
4097
  else { setChips('chips-stack', [chip('audit is taking unusually long', 'warn')]); recsSettled('stack', false); }
4006
4098
  return; // skeleton stays: an empty package list would read as "nothing is installed"
@@ -4076,7 +4168,7 @@ const MOCK_STATE = {
4076
4168
  values: { openrouterKey: true, nightly: true, routing: 'auto', qeFleet: false },
4077
4169
  schema: [
4078
4170
  { key: 'openrouterKey', label: 'OpenRouter API key', type: 'secret', help: 'Unlocks cheap-model routing + the self-improvement loop', secret: true },
4079
- { key: 'nightly', label: 'Nightly brain refresh', type: 'bool', help: 'Rebuild the KB from pinned SHAs overnight' },
4171
+ { key: 'nightly', label: 'Nightly coordinated updates', type: 'bool', help: 'Rebuild the KB from pinned SHAs overnight' },
4080
4172
  { key: 'routing', label: 'Token-smart routing', type: 'enum', options: ['auto', 'off'], help: 'Route cheap tasks to smaller models' },
4081
4173
  { key: 'qeFleet', label: 'On-demand QE fleet', type: 'bool', help: 'Agentic-QE test fleet, spun up on request' },
4082
4174
  ],
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.17",
3
+ "version": "4.6.1",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "ruvnet-brain": "bin/install.mjs"
7
+ "ruvnet-brain": "bin/install.mjs",
8
+ "ruvnet-brain-update": "bin/developer-update.mjs"
8
9
  },
9
10
  "scripts": {
10
11
  "prepublishOnly": "node scripts/protected-release-invocation.mjs --prepublish-only",
@@ -83,6 +84,7 @@
83
84
  ".claude-plugin/marketplace.json",
84
85
  "bin/install.mjs",
85
86
  "bin/nightly-refresh.mjs",
87
+ "bin/developer-update.mjs",
86
88
  "plugin/",
87
89
  "plugin/mcp/managed-cli-interface.mjs",
88
90
  "plugin/mcp/server.mjs",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.5.17",
4
+ "version": "4.6.1",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.17",
3
+ "version": "4.6.1",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: "Brain Console — the RuvNet Brain Console, also opened by /rnbc (same console as /rnb, /rvbc, /rvcb and /ruvnet-brain:configure — every spelling works). Your whole RuvNet stack on one page. Read-only until you click."
3
- updated: 2026-10-01
3
+ updated: 2026-10-10 06:00:22 EDT
4
+ version: 1.0.2
4
5
  ---
5
6
 
6
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -64,6 +65,17 @@ header chip says how fresh the reading is and re-measures if they click it.
64
65
  Report it plainly — the real error, not a guess — and offer to fix it. Never leave them staring at
65
66
  a dead tab wondering whether it is still loading.
66
67
 
68
+ ## Promise capture preference
69
+
70
+ If the user asks to stop automatic promise capture, explain the supported setting:
71
+ `RUVNET_PROMISE_CAPTURE=off` in the hook process environment. It can be supplied for the machine or
72
+ one project through the host's environment configuration. The exact value `off` stops capturing new
73
+ "I'll …" promises; unset it or set `on` to resume capture. This preference is separate from the Brain
74
+ on/off switch and is not currently a Console toggle. Existing promises remain visible and forceable,
75
+ and close only on verified evidence. Capability and completion integrity checks keep running. Do not
76
+ disable the Stop hook or edit the work ledger to apply this preference. Reopen the host if its current
77
+ session has not picked up the changed environment.
78
+
67
79
  ---
68
80
 
69
81
  ## Things NOT to do before the page is open
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  description: Open the RuvNet Brain Console (/rnbc) — a local web page that mirrors YOUR machine's RuvNet setup, explains it in plain English, and lets you safely configure and fix it. Read-only until you click; every machine change is explained first and is reversible.
3
- updated: 2026-10-04
4
- version: 1.0.1
3
+ updated: 2026-10-10 06:00:22 EDT
4
+ version: 1.0.2
5
5
  ---
6
6
 
7
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: "RNB — opens the RuvNet Brain Console (same console as /rnbc, /rvbc, /brain-console and /ruvnet-brain:configure — every spelling works). Your whole RuvNet stack on one page. Read-only until you click."
3
- updated: 2026-10-01
3
+ updated: 2026-10-10 06:00:22 EDT
4
+ version: 1.0.2
4
5
  ---
5
6
 
6
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -64,6 +65,17 @@ header chip says how fresh the reading is and re-measures if they click it.
64
65
  Report it plainly — the real error, not a guess — and offer to fix it. Never leave them staring at
65
66
  a dead tab wondering whether it is still loading.
66
67
 
68
+ ## Promise capture preference
69
+
70
+ If the user asks to stop automatic promise capture, explain the supported setting:
71
+ `RUVNET_PROMISE_CAPTURE=off` in the hook process environment. It can be supplied for the machine or
72
+ one project through the host's environment configuration. The exact value `off` stops capturing new
73
+ "I'll …" promises; unset it or set `on` to resume capture. This preference is separate from the Brain
74
+ on/off switch and is not currently a Console toggle. Existing promises remain visible and forceable,
75
+ and close only on verified evidence. Capability and completion integrity checks keep running. Do not
76
+ disable the Stop hook or edit the work ledger to apply this preference. Reopen the host if its current
77
+ session has not picked up the changed environment.
78
+
67
79
  ---
68
80
 
69
81
  ## Things NOT to do before the page is open
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: "RNBC — RuvNet Brain Console. Opens the live console page: your whole RuvNet stack on one page — what's installed, what your AI learned, one-click reversible fixes. Read-only until you click. (Also: /rnb, /rvbc, /rvcb, /brain-console — all the same thing.)"
3
- updated: 2026-10-01
3
+ updated: 2026-10-10 06:00:22 EDT
4
+ version: 1.0.2
4
5
  ---
5
6
 
6
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -64,6 +65,17 @@ header chip says how fresh the reading is and re-measures if they click it.
64
65
  Report it plainly — the real error, not a guess — and offer to fix it. Never leave them staring at
65
66
  a dead tab wondering whether it is still loading.
66
67
 
68
+ ## Promise capture preference
69
+
70
+ If the user asks to stop automatic promise capture, explain the supported setting:
71
+ `RUVNET_PROMISE_CAPTURE=off` in the hook process environment. It can be supplied for the machine or
72
+ one project through the host's environment configuration. The exact value `off` stops capturing new
73
+ "I'll …" promises; unset it or set `on` to resume capture. This preference is separate from the Brain
74
+ on/off switch and is not currently a Console toggle. Existing promises remain visible and forceable,
75
+ and close only on verified evidence. Capability and completion integrity checks keep running. Do not
76
+ disable the Stop hook or edit the work ledger to apply this preference. Reopen the host if its current
77
+ session has not picked up the changed environment.
78
+
67
79
  ---
68
80
 
69
81
  ## Things NOT to do before the page is open
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: "RvBC — the older spelling of /rnbc, the RuvNet Brain Console (same console as /rnbc, /rnb, /brain-console and /ruvnet-brain:configure — every spelling works). Your whole RuvNet stack on one page. Read-only until you click."
3
- updated: 2026-10-01
3
+ updated: 2026-10-10 06:00:22 EDT
4
+ version: 1.0.2
4
5
  ---
5
6
 
6
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -64,6 +65,17 @@ header chip says how fresh the reading is and re-measures if they click it.
64
65
  Report it plainly — the real error, not a guess — and offer to fix it. Never leave them staring at
65
66
  a dead tab wondering whether it is still loading.
66
67
 
68
+ ## Promise capture preference
69
+
70
+ If the user asks to stop automatic promise capture, explain the supported setting:
71
+ `RUVNET_PROMISE_CAPTURE=off` in the hook process environment. It can be supplied for the machine or
72
+ one project through the host's environment configuration. The exact value `off` stops capturing new
73
+ "I'll …" promises; unset it or set `on` to resume capture. This preference is separate from the Brain
74
+ on/off switch and is not currently a Console toggle. Existing promises remain visible and forceable,
75
+ and close only on verified evidence. Capability and completion integrity checks keep running. Do not
76
+ disable the Stop hook or edit the work ledger to apply this preference. Reopen the host if its current
77
+ session has not picked up the changed environment.
78
+
67
79
  ---
68
80
 
69
81
  ## Things NOT to do before the page is open
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: "RvCB — another spelling of /rnbc, the RuvNet Brain Console (same console as /rnbc, /rnb, /rvbc, /brain-console and /ruvnet-brain:configure — every spelling works). Read-only until you click."
3
- updated: 2026-10-01
3
+ updated: 2026-10-10 06:00:22 EDT
4
+ version: 1.0.2
4
5
  ---
5
6
 
6
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -64,6 +65,17 @@ header chip says how fresh the reading is and re-measures if they click it.
64
65
  Report it plainly — the real error, not a guess — and offer to fix it. Never leave them staring at
65
66
  a dead tab wondering whether it is still loading.
66
67
 
68
+ ## Promise capture preference
69
+
70
+ If the user asks to stop automatic promise capture, explain the supported setting:
71
+ `RUVNET_PROMISE_CAPTURE=off` in the hook process environment. It can be supplied for the machine or
72
+ one project through the host's environment configuration. The exact value `off` stops capturing new
73
+ "I'll …" promises; unset it or set `on` to resume capture. This preference is separate from the Brain
74
+ on/off switch and is not currently a Console toggle. Existing promises remain visible and forceable,
75
+ and close only on verified evidence. Capability and completion integrity checks keep running. Do not
76
+ disable the Stop hook or edit the work ledger to apply this preference. Reopen the host if its current
77
+ session has not picked up the changed environment.
78
+
67
79
  ---
68
80
 
69
81
  ## Things NOT to do before the page is open
@@ -5,6 +5,7 @@ import os from 'node:os';
5
5
  import path from 'node:path';
6
6
  import crypto from 'node:crypto';
7
7
  import { spawnSync } from 'node:child_process';
8
+ import { developerCoordinatorOwner } from './developer-update-owner.mjs';
8
9
  import { SETTINGS_VERSION, validate, saveSettings } from './user-settings.mjs';
9
10
 
10
11
  export const ownerSettingsPath = (home = os.homedir()) => path.join(home, '.config', 'ruvnet-brain', 'settings.json');
@@ -44,7 +45,7 @@ export function saveUpdateSource(source, { home = os.homedir() } = {}) {
44
45
  }
45
46
 
46
47
  export function automaticPath({ nodePath = process.execPath, home = os.homedir(), platform = process.platform, env = process.env } = {}) {
47
- const paths = [path.dirname(nodePath), path.join(home, '.npm-global', 'bin'), path.join(home, '.local', 'bin')];
48
+ const paths = [path.join(home, '.npm-global', 'bin'), path.dirname(nodePath), path.join(home, '.local', 'bin'), path.join(home, '.cargo', 'bin'), path.join(home, '.bun', 'bin')];
48
49
  if (platform === 'win32') {
49
50
  const system = env.SystemRoot || env.SYSTEMROOT;
50
51
  if (system && path.win32.isAbsolute(system)) paths.push(system, path.join(system, 'System32'));
@@ -91,8 +92,16 @@ export function installedUpdater({ home = os.homedir(), platform = process.platf
91
92
  return { entry, version: manifest.version, sha256 };
92
93
  }
93
94
 
94
- export function automaticInvocation(args, { source = updateSource(), home = os.homedir(), nodePath = process.execPath,
95
+ export function automaticInvocation(args, { source, home = os.homedir(), nodePath = process.execPath,
95
96
  packageTarget = 'ruvnet-brain@latest' } = {}) {
97
+ if (packageTarget === 'ruvnet-brain@latest') {
98
+ const coordinator = developerCoordinatorOwner({ home });
99
+ if (coordinator.active) {
100
+ if (!coordinator.ready) throw new Error(`coordinated updater is not ready: ${coordinator.reason}`);
101
+ return { executable: nodePath, args: [coordinator.entry, '--apply'], source: 'developer-suite', coordinator };
102
+ }
103
+ }
104
+ source ??= updateSource({ home });
96
105
  if (source === 'installed') {
97
106
  if (packageTarget !== 'ruvnet-brain@latest') throw new Error('installed mode cannot select a proof tarball');
98
107
  const installed = installedUpdater({ home });
@@ -392,7 +392,8 @@ function promiseBookkeeping() {
392
392
  if (!assistantCommitmentOwned(item, hookInput.session_id, projectIdentity)) continue;
393
393
  const claim = completion.claims.find((c) => claimClosesPromise(c.text, item.text));
394
394
  if (!claim) continue;
395
- Object.assign(item, { done: true, state: 'completed', doneAt: at, completionEvidence: { claim: claim.text,
395
+ const verifiedCompletion = completion.verdict === 'PASS' && Boolean(claim);
396
+ Object.assign(item, { done: verifiedCompletion, state: verifiedCompletion ? 'completed' : item.state, doneAt: at, completionEvidence: { claim: claim.text,
396
397
  checks: completion.verification.checks.slice(-5).map((c) => c.what),
397
398
  transcript: String(hookInput.transcript_path || ''), sessionId: hookInput.session_id } });
398
399
  changed = true;