ruvnet-brain 4.3.40 → 4.4.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 (48) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +111 -27
  3. package/kb/brain-profile.mjs +17 -2
  4. package/kb/forge-update.mjs +6 -2
  5. package/kb/lifecycle-evidence-retention.mjs +12 -9
  6. package/kb/refresh-run.mjs +17 -1
  7. package/kb/update-storage-transaction.mjs +33 -5
  8. package/package.json +1 -1
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/hooks/codex-hooks.json +2 -2
  12. package/plugin/hooks/hooks.json +1 -1
  13. package/plugin/scripts/capability-registry.mjs +3 -3
  14. package/plugin/scripts/codex-hook-wrapper.mjs +7 -2
  15. package/plugin/scripts/design-wall.sh +1 -0
  16. package/plugin/scripts/ground-before-write.sh +1 -0
  17. package/plugin/scripts/ground-ruvnet.sh +3 -3
  18. package/plugin/scripts/grounding-answer.mjs +129 -0
  19. package/plugin/scripts/grounding-stamp.sh +32 -31
  20. package/plugin/scripts/grounding-turn-evidence.mjs +99 -5
  21. package/plugin/scripts/grounding-turn-gate.mjs +25 -6
  22. package/plugin/scripts/hook-shim.mjs +3 -0
  23. package/plugin/scripts/kling-preflight.sh +1 -0
  24. package/plugin/scripts/learn-capture.sh +1 -0
  25. package/plugin/scripts/project-progression-sources.mjs +16 -4
  26. package/plugin/scripts/project-progression-store.mjs +49 -1
  27. package/plugin/scripts/protect-brain-state.sh +1 -0
  28. package/plugin/scripts/route-dispatch.sh +1 -0
  29. package/plugin/scripts/session-snapshot-hook.mjs +224 -38
  30. package/plugin/scripts/session-start-health.mjs +24 -3
  31. package/plugin/scripts/session-start-update-plane.mjs +1 -1
  32. package/plugin/scripts/update-apply.mjs +22 -2
  33. package/scripts/console-instances.mjs +145 -0
  34. package/scripts/console-runtime-identity.mjs +2 -0
  35. package/scripts/corpus-canary.mjs +130 -18
  36. package/scripts/customer-seams.mjs +84 -0
  37. package/scripts/customer-state-matrix.mjs +363 -0
  38. package/scripts/full-suite-gate.mjs +155 -0
  39. package/scripts/grounding-turn-replay.mjs +11 -3
  40. package/scripts/hook-qualify-core.mjs +346 -0
  41. package/scripts/hook-qualify-hosts.mjs +115 -0
  42. package/scripts/hook-qualify.mjs +101 -0
  43. package/scripts/host-cli.mjs +115 -0
  44. package/scripts/qe/agentic-qe-4.3.mjs +0 -1
  45. package/scripts/route-gold-rank.mjs +156 -0
  46. package/scripts/route-index-memory.mjs +51 -0
  47. package/scripts/route-latency-warm.mjs +123 -0
  48. package/scripts/wired-check.mjs +17 -3
@@ -1,6 +1,8 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { spawnSync } from 'node:child_process';
3
+ import { spawn, spawnSync } from 'node:child_process';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { ProgressionOutbox } from './project-progression-outbox.mjs';
4
6
  import {
5
7
  captureProjectTransition,
6
8
  hasProjectProgression,
@@ -19,6 +21,23 @@ import { captureTurnOutcome } from './turn-outcome-capture.mjs';
19
21
  */
20
22
  export const CAPTURE_BUDGET_MS = 8_000;
21
23
 
24
+ /**
25
+ * Replaying an interrupted session's outbox costs one `ruflo` write per pending snapshot, each ~3s
26
+ * cold (project-progression-store.mjs). Under this budget there is room for the NEW snapshot or the
27
+ * old ones, not both — and the new one is the one nothing else will ever write.
28
+ */
29
+ export const REPLAY_MIN_BUDGET_MS = 4_000;
30
+
31
+ /**
32
+ * The budget this invocation really has. The Codex wrapper hands its own kill deadline down as
33
+ * RUVNET_CODEX_BUDGET_MS (2200ms at SessionEnd, which Codex caps at 3s); planning for 8s there meant
34
+ * being SIGKILLed mid-write with nothing reported. 300ms is left for the adapter → shim → body spawns.
35
+ */
36
+ export function effectiveBudgetMs(env = process.env) {
37
+ const handed = Number(env.RUVNET_CODEX_BUDGET_MS);
38
+ return Number.isFinite(handed) && handed > 0 ? Math.max(0, Math.min(CAPTURE_BUDGET_MS, handed - 300)) : CAPTURE_BUDGET_MS;
39
+ }
40
+
22
41
  function regularOrAbsent(file) {
23
42
  try {
24
43
  const stat = fs.lstatSync(file);
@@ -96,11 +115,16 @@ export function runSessionSnapshotHook(projectDir, event, {
96
115
  host = process.env.RUVNET_HOOK_HOST || 'claude',
97
116
  captureProgression = captureProjectTransition,
98
117
  produce = buildProjectProgression,
99
- budgetMs = CAPTURE_BUDGET_MS,
118
+ budgetMs = effectiveBudgetMs(),
100
119
  now = Date.now,
101
120
  captureTurn = captureTurnOutcome,
121
+ writeMetadata = true,
122
+ makeStoreFactory = boundedStoreFactory,
123
+ spawnReplay = replayOutboxDetached,
124
+ ordered = null,
102
125
  } = {}) {
103
- const metadataWritten = writeSessionSnapshot(projectDir, event);
126
+ // The detached worker re-runs a QUEUED boundary; its session receipt was already written then.
127
+ const metadataWritten = writeMetadata ? writeSessionSnapshot(projectDir, event) : false;
104
128
  let payload;
105
129
  try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
106
130
  // TURN OUTCOMES FIRST, and independent of `.swarm`: every turn in every repository is recorded
@@ -134,51 +158,213 @@ export function runSessionSnapshotHook(projectDir, event, {
134
158
  return { ...idle, skipped: 'project has not adopted the canonical store' };
135
159
  }
136
160
 
137
- const deadlineAt = now() + budgetMs;
138
- const storeFactory = boundedStoreFactory(deadlineAt);
161
+ const root = resolution.projectRoot;
162
+ const pendingCount = () => {
163
+ try { return new ProgressionOutbox({ projectRoot: root }).pendingSnapshots().length; } catch { return 0; }
164
+ };
139
165
 
140
- // Commit anything a previously interrupted session left durable-but-uncommitted. SessionStart is
141
- // forbidden from doing this (ADR-073 §5) because replay is a write; a capture boundary already
142
- // owns a write budget, so this is where that debt is settled.
143
- let replayed = 0;
144
- try {
145
- replayed = storeFactory({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath }).replay().length;
146
- } catch { /* the new capture below is still worth attempting */ }
166
+ // CAUSAL ORDER. The producer links a new snapshot to the COMMITTED heads
167
+ // (project-progression-producer.mjs), so a snapshot produced while older work is uncommitted would
168
+ // not descend from it and the project would end with two unrelated heads
169
+ // (tests/acceptance/cross-host-project-resume.test.mjs). "Older work" is BOTH the outbox (captures
170
+ // interrupted after their fsync) AND the capture queue (whole boundaries waiting for a worker).
171
+ //
172
+ // THE REPLAY LOCK IS THE RIGHT TO COMMIT IN ORDER (4.4.0 re-review S-A). Every boundary takes it
173
+ // before doing anything that commits. If it cannot — a live worker holds it — or older work is
174
+ // queued, or (on a short budget) outbox debt cannot be replayed here, this boundary QUEUES itself
175
+ // behind that work and the lock passes to a DETACHED, bounded worker that drains everything in
176
+ // order. On Codex no boundary has the replay budget (Stop 3700ms effective, SessionEnd 1900ms, no
177
+ // PreCompact). Any boundary, of any budget, therefore also drains a queue a dead worker stranded.
178
+ // `ordered` is the worker's own re-entry: it already holds the lock and is draining in order.
179
+ let token = ordered;
180
+ const handOff = (why) => {
181
+ const queued = queueCapture({ projectDir: root, event, host, payload });
182
+ const handed = queued ? spawnReplay({ projectDir: root, token }) : false;
183
+ if (!handed && token && token !== ordered) releaseReplayLock(root, token);
184
+ return { ...idle, replayed: 0, progressionCaptured: false, deferredToReplayer: Boolean(queued),
185
+ replaySkipped: `${why}; this capture ${queued ? 'queued behind it' : 'NOT queued (queue unwritable)'}`
186
+ + `${queued ? (handed ? ', handed to a detached worker' : ' (a live worker holds the lock and drains the queue)') : ''}` };
187
+ };
188
+ if (!ordered) {
189
+ token = takeReplayLock(root);
190
+ if (!token) return handOff('a worker is committing older work');
191
+ const queuedAhead = queuedCaptures(root).length;
192
+ if (queuedAhead) return handOff(`${queuedAhead} older capture(s) queued`);
193
+ if (budgetMs < REPLAY_MIN_BUDGET_MS) {
194
+ const pending = pendingCount();
195
+ if (pending) return handOff(`outbox replay deferred: budget ${budgetMs}ms < ${REPLAY_MIN_BUDGET_MS}ms; ${pending} pending`);
196
+ }
197
+ }
147
198
 
148
- let produced;
199
+ let handedLock = false;
149
200
  try {
150
- produced = produce({ resolution, payload, host, trigger: event });
151
- } catch (error) {
152
- return { ...idle, replayed, skipped: `producer failed: ${error.message}` };
201
+ const deadlineAt = now() + budgetMs;
202
+ const storeFactory = makeStoreFactory(deadlineAt);
203
+ let replayed = 0;
204
+ if (budgetMs >= REPLAY_MIN_BUDGET_MS) {
205
+ try {
206
+ replayed = storeFactory({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath }).replay().length;
207
+ } catch { /* the debt stays durable in the outbox; this capture is still worth attempting */ }
208
+ }
209
+
210
+ let produced;
211
+ try {
212
+ produced = produce({ resolution, payload, host, trigger: event });
213
+ } catch (error) {
214
+ return { ...idle, replayed, skipped: `producer failed: ${error.message}` };
215
+ }
216
+ if (produced.skipped) return { ...idle, replayed, skipped: produced.skipped.reason };
217
+
218
+ let result;
219
+ try {
220
+ result = captureProgression({
221
+ host,
222
+ payload: { ...payload, hook_event_name: event, projectProgression: produced.projectProgression },
223
+ projectDir,
224
+ storeFactory,
225
+ });
226
+ } catch (error) {
227
+ // NOT LOST — DEFERRED. capture() fsyncs the snapshot to the durable outbox BEFORE it writes to
228
+ // the store, so a budget overrun leaves the evidence on disk. On a short budget nothing later in
229
+ // this process can settle it, so the lock goes straight to a detached worker.
230
+ handedLock = !ordered && budgetMs < REPLAY_MIN_BUDGET_MS && pendingCount() > 0 && spawnReplay({ projectDir: root, token });
231
+ return { ...idle, replayed, skipped: `capture deferred: ${error.message}`,
232
+ ...(handedLock ? { replaySkipped: 'deferred capture handed to a detached worker' } : {}) };
233
+ }
234
+ return {
235
+ metadataWritten,
236
+ progressionCaptured: true,
237
+ turn,
238
+ replayed,
239
+ receipt: result.receipt,
240
+ provenance: produced.provenance,
241
+ };
242
+ } finally {
243
+ if (!ordered && !handedLock) releaseReplayLock(root, token);
153
244
  }
154
- if (produced.skipped) return { ...idle, replayed, skipped: produced.skipped.reason };
245
+ }
246
+
247
+ /**
248
+ * How long the detached worker may spend per step. The lock is refreshed between steps and goes stale
249
+ * after REPLAY_LOCK_STALE_MS, which is more than twice a step, so a live worker never looks dead.
250
+ */
251
+ export const DETACHED_REPLAY_BUDGET_MS = 45_000;
252
+ export const REPLAY_LOCK_STALE_MS = 120_000;
253
+ const REPLAY_LOCK = '.progression-replay.lock';
254
+ const QUEUE_PREFIX = '.progression-capture-queue-';
255
+ const lockPath = (projectDir) => path.join(projectDir, '.swarm', REPLAY_LOCK);
256
+ const readLock = (projectDir) => { try { return fs.readFileSync(lockPath(projectDir), 'utf8').trim(); } catch { return null; } };
257
+ let queueSeq = 0;
155
258
 
156
- let result;
259
+ /** Queue one boundary's capture for the worker (0600, inside the project's own .swarm), in arrival order. */
260
+ export function queueCapture({ projectDir, event, host, payload, now = Date.now() }) {
157
261
  try {
158
- result = captureProgression({
159
- host,
160
- payload: { ...payload, hook_event_name: event, projectProgression: produced.projectProgression },
161
- projectDir,
162
- storeFactory,
262
+ queueSeq += 1;
263
+ const name = `${QUEUE_PREFIX}${String(now).padStart(15, '0')}-${String(process.hrtime.bigint() % 1_000_000_000n).padStart(9, '0')}-${process.pid}-${queueSeq}.json`;
264
+ const file = path.join(projectDir, '.swarm', name);
265
+ fs.writeFileSync(file, JSON.stringify({ event, host, payload }), { flag: 'wx', mode: 0o600 });
266
+ return file;
267
+ } catch { return null; }
268
+ }
269
+
270
+ export function queuedCaptures(projectDir) {
271
+ try {
272
+ return fs.readdirSync(path.join(projectDir, '.swarm')).filter((n) => n.startsWith(QUEUE_PREFIX) && n.endsWith('.json'))
273
+ .sort().map((n) => path.join(projectDir, '.swarm', n));
274
+ } catch { return []; }
275
+ }
276
+
277
+ /**
278
+ * Take the lock. Returns this holder's TOKEN, or null when a live holder has it. A lock not refreshed
279
+ * for REPLAY_LOCK_STALE_MS belongs to a dead worker and is taken over: renamed aside first, so two
280
+ * would-be successors cannot both win the exclusive create.
281
+ */
282
+ export function takeReplayLock(projectDir, now = Date.now()) {
283
+ const lock = lockPath(projectDir);
284
+ const token = `${process.pid}-${now}-${Math.random().toString(36).slice(2, 10)}`;
285
+ const create = () => { fs.writeFileSync(lock, `${token}\n`, { flag: 'wx', mode: 0o600 }); return token; };
286
+ try { return create(); } catch { /* held, or stale */ }
287
+ try {
288
+ if (now - fs.statSync(lock).mtimeMs <= REPLAY_LOCK_STALE_MS) return null;
289
+ const aside = `${lock}.stale-${process.pid}-${now}`;
290
+ fs.renameSync(lock, aside);
291
+ fs.rmSync(aside, { force: true });
292
+ return create();
293
+ } catch { return null; }
294
+ }
295
+
296
+ /** Heartbeat: refresh the lock's mtime if (and only if) this holder still owns it. */
297
+ export function refreshReplayLock(projectDir, token) {
298
+ if (!token || readLock(projectDir) !== token) return false;
299
+ try { const t = new Date(); fs.utimesSync(lockPath(projectDir), t, t); return true; } catch { return false; }
300
+ }
301
+
302
+ /** Release ONLY a lock this holder owns; a successor's lock is never deleted. */
303
+ export function releaseReplayLock(projectDir, token) {
304
+ if (!token || readLock(projectDir) !== token) return false;
305
+ try { fs.rmSync(lockPath(projectDir), { force: true }); return true; } catch { return false; }
306
+ }
307
+
308
+ /** Hand the lock (or take it, if free) to a detached worker. Returns whether one was started. Never throws. */
309
+ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn } = {}) {
310
+ const held = token || takeReplayLock(projectDir);
311
+ if (!held) return false;
312
+ try {
313
+ const child = spawnFn(process.execPath, [fileURLToPath(import.meta.url), '--replay-outbox'], {
314
+ cwd: projectDir, detached: true, stdio: 'ignore', windowsHide: true,
315
+ env: { ...process.env, RUVNET_REPLAY_LOCK_TOKEN: held },
163
316
  });
164
- } catch (error) {
165
- // NOT LOST — DEFERRED. capture() fsyncs the snapshot to the durable outbox BEFORE it writes to
166
- // the store, so a budget overrun here leaves the evidence on disk and the next capture boundary
167
- // (or /checkpoint) commits it. Reporting that plainly is the whole difference between a bounded
168
- // hook and a lossy one, so the reason is returned rather than thrown at a lifecycle boundary.
169
- return { ...idle, replayed, skipped: `capture deferred: ${error.message}` };
317
+ child.unref?.();
318
+ return true;
319
+ } catch {
320
+ releaseReplayLock(projectDir, held);
321
+ return false;
170
322
  }
171
- return {
172
- metadataWritten,
173
- progressionCaptured: true,
174
- turn,
175
- replayed,
176
- receipt: result.receipt,
177
- provenance: produced.provenance,
178
- };
179
323
  }
180
324
 
181
- if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
325
+ /**
326
+ * The detached worker's body, holding the lock `token`: replay the outbox, then run every queued capture
327
+ * IN ORDER — each re-entering the boundary as `ordered`, so it replays before it produces — refreshing
328
+ * the lock between steps and STOPPING the moment the lock is no longer its own (a successor took it
329
+ * over: running on would duplicate its work). Releases only its own lock, then re-checks for captures
330
+ * queued while it held it.
331
+ */
332
+ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
333
+ makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook } = {}) {
334
+ let held = token || takeReplayLock(projectDir);
335
+ let replayed = 0;
336
+ for (let round = 0; held && round < 8; round += 1) {
337
+ try {
338
+ if (!refreshReplayLock(projectDir, held)) return replayed;
339
+ const resolution = resolveProjectStore({ projectDir });
340
+ const store = makeStoreFactory(now() + budgetMs)({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath });
341
+ for (const snapshot of store.outbox.pendingSnapshots()) {
342
+ if (!refreshReplayLock(projectDir, held)) return replayed;
343
+ store.outbox.markCommitted(store.appendExact(snapshot));
344
+ replayed += 1;
345
+ }
346
+ for (const file of queuedCaptures(projectDir)) {
347
+ if (!refreshReplayLock(projectDir, held)) return replayed;
348
+ let job = null;
349
+ try { job = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { /* torn: dropped below */ }
350
+ try {
351
+ if (job) runCapture(projectDir, job.event, { rawInput: JSON.stringify(job.payload), host: job.host,
352
+ budgetMs, makeStoreFactory, now, ordered: held, writeMetadata: false,
353
+ captureTurn: () => ({ recorded: false, skipped: 'detached replay' }) });
354
+ } catch { /* a failed capture leaves its own snapshot durable in the outbox */ }
355
+ try { fs.rmSync(file, { force: true }); } catch { /* best effort */ }
356
+ }
357
+ } catch { /* the debt stays durable; the next boundary hands it on again */ } finally {
358
+ releaseReplayLock(projectDir, held);
359
+ }
360
+ held = queuedCaptures(projectDir).length ? takeReplayLock(projectDir) : null;
361
+ }
362
+ return replayed;
363
+ }
364
+
365
+ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs') && process.argv[2] === '--replay-outbox') {
366
+ try { runOutboxReplay({ projectDir: process.cwd() }); } catch { /* the debt stays durable in the outbox */ }
367
+ } else if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
182
368
  // projectDirectory() is the SAME derivation the Console's detector uses. Deriving it here
183
369
  // independently is what let this hook write a receipt the Console then reported as missing (#85).
184
370
  const rawInput = fs.readFileSync(0, 'utf8');
@@ -93,13 +93,29 @@ export const knowledgeFacts = ({ env = process.env, home, now = Date.now() } = {
93
93
  lockMs: Date.parse(json(auto.lockFile)?.at || '') || mtimeMs(auto.lockFile) };
94
94
  };
95
95
 
96
+ /**
97
+ * agentic-kit ownership is a CLAIM in kit.json, not a delivery: on the owner's Mac (2026-09-30)
98
+ * kit.json said ruvnetBrain:true while agentic-kit scheduled nothing, so the self-heal stood down
99
+ * forever and the knowledge base aged by hand only. Ownership is honoured only while an update is
100
+ * PROVEN inside this window (a successful refresh receipt or a CURRENT --check verdict); 36h leaves
101
+ * the self-heal 12h to land one before the 48h invariant breaks.
102
+ */
103
+ export const AGENTIC_KIT_PROOF_HOURS = 36;
104
+ /** 'none' | 'delivering' (kit.json claims it AND an update is proven) | 'not-delivering'. */
105
+ export const agenticKitUpdates = ({ home, facts }) => {
106
+ if (!updateOwnedByAgenticKit(home)) return 'none';
107
+ return facts.provenWithin(AGENTIC_KIT_PROOF_HOURS) ? 'delivering' : 'not-delivering';
108
+ };
109
+
96
110
  /** Why the SessionStart knowledge auto-update may NEVER run on this machine ('' = it may). */
97
111
  export const autoUpdateOptOut = ({ env = process.env, home, facts }) => {
98
112
  const flag = String(env.RUVNET_AUTO_UPDATE || '').toLowerCase();
99
113
  if (flag === 'off') return 'RUVNET_AUTO_UPDATE=off';
100
114
  if (env.RUVNET_BRAIN_TEST === '1' && flag !== 'on') return 'test mode';
101
115
  if (read(path.join(facts.brainHome, '.auto-update-pref')).trim() === 'no') return 'you answered no to background auto-update';
102
- if (updateOwnedByAgenticKit(home)) return 'agentic-kit owns updates: ak sync';
116
+ if (agenticKitUpdates({ home, facts }) === 'delivering') {
117
+ return `agentic-kit owns updates and one is proven within ${AGENTIC_KIT_PROOF_HOURS}h: ak sync`;
118
+ }
103
119
  if (!exists(path.join(facts.kbDir, 'forge-update.mjs'))) return 'this install predates the self-updater';
104
120
  return '';
105
121
  };
@@ -122,8 +138,10 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
122
138
  const ageKnown = Number.isFinite(builtMs);
123
139
  if (!failing && proven) return '';
124
140
  if (!failing && ageKnown && hours(builtMs) <= windowHours) return '';
125
- const agentKit = updateOwnedByAgenticKit(home);
126
- const scheduled = agentKit || readNightlyRegistration({ brainHome }).ok;
141
+ const kit = agenticKitUpdates({ home, facts });
142
+ const agentKit = kit === 'delivering';
143
+ // An agentic-kit machine must never be told to also --enable-nightly (one owner per machine).
144
+ const scheduled = kit !== 'none' || readNightlyRegistration({ brainHome }).ok;
127
145
  const parts = [ageKnown ? `knowledge base built ${day(builtMs)} (${age(builtMs)})`
128
146
  : 'knowledge base age UNKNOWN (SOURCE.json missing or unreadable)'];
129
147
  if (autoFailed) {
@@ -138,6 +156,9 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
138
156
  parts.push(history.receipts
139
157
  ? `${history.failuresSinceSuccess} failed run(s) since the last success (${history.lastSuccess ? day(history.lastSuccess.at) : 'none recorded'})`
140
158
  : 'no refresh has ever run on this machine');
159
+ if (kit === 'not-delivering') {
160
+ parts.push(`agentic-kit claims updates (kit.json ruvnetBrain:true) but no update is proven in ${AGENTIC_KIT_PROOF_HOURS}h, so the Brain's own self-heal runs instead`);
161
+ }
141
162
  if (!scheduled) parts.push('no nightly refresh is scheduled');
142
163
  if (history.unreadable) parts.push(`${history.unreadable} unreadable receipt(s)`);
143
164
  const fix = agentKit ? 'ak sync' : scheduled ? 'npx ruvnet-brain@latest --update'
@@ -145,7 +145,7 @@ export const heartbeat = ({ env, hookDir, stateDir, home, running, seedDispatche
145
145
  if (pref === 'yes' && exists(path.join(kbDir, 'forge-update.mjs'))) {
146
146
  const kbLog = path.join(stateDir, '.last-kb-check.log');
147
147
  if (/\bBEHIND\b/.test(read(kbLog))) {
148
- emit('[RuvNet Brain — a newer knowledge bundle is available. It is signed (Ed25519) and the updater verifies that signature before extracting anything. We do NOT auto-apply it: applying replaces executable tool files, which is your call. To update: cd ~/.cache/ruvnet-brain/kb && node forge-update.mjs --apply]');
148
+ emit('[RuvNet Brain — a newer knowledge bundle is available. It is signed (Ed25519) and the updater verifies that signature before extracting anything. We do NOT auto-apply it: applying replaces executable tool files, which is your call. To update: npx ruvnet-brain@latest --update]');
149
149
  }
150
150
  // S2 (ONE CURRENCY VERDICT): --result-file records the SAME structured verdict --check/--apply
151
151
  // and bin/install.mjs already read (forge-update.mjs's currencyVerdict()), at the well-known path
@@ -53,10 +53,19 @@ const RECEIPTS = path.join(BRAIN_HOME, 'update-receipts.jsonl');
53
53
  const LEASES = path.join(BRAIN_HOME, 'leases');
54
54
  const DEV = path.join(BRAIN_HOME, 'dev.json');
55
55
  const SEEDED = path.join(BRAIN_HOME, '.spine-seeded');
56
+ // Claude Code honours CLAUDE_CONFIG_DIR for its whole config tree, plugins included; Codex honours CODEX_HOME.
57
+ const CLAUDE_CONFIG = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
58
+ const CODEX_CONFIG = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
56
59
  const PLUGIN_CACHES = [
57
- path.join(os.homedir(), '.claude', 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
58
- path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
60
+ path.join(CLAUDE_CONFIG, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
61
+ path.join(CODEX_CONFIG, 'plugins', 'cache', 'ruvnet-brain', 'ruvnet-brain'),
59
62
  ];
63
+ // Evidence that a host has been pointed at the Brain's plugin at all (marketplace registered or plugin
64
+ // cache created) — even when `plugin install` then failed and staged nothing.
65
+ const HOST_PLUGIN_EVIDENCE = [CLAUDE_CONFIG, CODEX_CONFIG].flatMap((root) => [
66
+ path.join(root, 'plugins', 'marketplaces', 'ruvnet-brain'),
67
+ path.join(root, 'plugins', 'cache', 'ruvnet-brain'),
68
+ ]);
60
69
  const LEASE_FRESH_MS = 6 * 3600_000; // a lease older than 6h is stale (its process is long gone)
61
70
 
62
71
  const argv = process.argv.slice(2);
@@ -425,6 +434,17 @@ function main() {
425
434
  console.log(`already on ${activeNow.version}, at or above requested ${expectedVersion} — nothing to apply.`);
426
435
  return 0;
427
436
  }
437
+ // NO HOST AT ALL is not a stale spine. With no active spine and no payload of ANY version in
438
+ // any host cache, nothing was ever seeded, so nothing can be behind (a desktop-app/IDE-extension
439
+ // customer whose shell has no host CLI). A host cache holding some OTHER version still fails
440
+ // closed below — issue #64's exact-selection guard is untouched.
441
+ // Existence, not a parse: a corrupt active.json is a damaged spine and still fails closed below.
442
+ // A host that registered the Brain's plugin but staged nothing (its `plugin install` failed) is
443
+ // NOT "no host": that is a failed install and must keep failing.
444
+ if (!fs.existsSync(ACTIVE) && !newestStagedCC() && !HOST_PLUGIN_EVIDENCE.some((dir) => fs.existsSync(dir))) {
445
+ console.log(`no host has staged a payload and no spine is active — nothing to converge for ${expectedVersion}.`);
446
+ return 0;
447
+ }
428
448
  console.error(`✗ no staged host payload exactly matches expected version ${expectedVersion} — spine unchanged`);
429
449
  return 1;
430
450
  }
@@ -0,0 +1,145 @@
1
+ // console-instances.mjs — the installer's view of running Consoles: which receipts are real, and how
2
+ // to replace an owned Console that is still serving a pre-update runtime without asking anyone.
3
+ //
4
+ // Two owner-Mac failures (2026-09-30) this exists for:
5
+ // 1. Receipts left by Consoles that had long since died (2026-09-16/17) were counted as "stale
6
+ // running" forever, so --doctor said pending-console-restart / FAILING with nothing to restart.
7
+ // A receipt whose pid is gone AND whose port does not answer /api/runtime with the receipt's
8
+ // own identity is debris: it is pruned.
9
+ // 2. After a successful --update the installer told the owner to "restart Console". The Console
10
+ // already knows how to replace an owned stale instance of itself (scripts/onboarding-console.mjs
11
+ // launchConsole, state 'stale-running': token-authenticated shutdown, then serve on the same
12
+ // port). The installer now runs exactly that, from the freshly activated runtime, without --open.
13
+ //
14
+ // Everything here is synchronous because syncHostsAfterUpdate is; the one network probe runs in a
15
+ // bounded child node process.
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { spawn, spawnSync } from 'node:child_process';
19
+
20
+ const PRODUCT = 'ruvnet-brain-console';
21
+ // The same identity fields onboarding-console.mjs sameRuntimeIdentity compares.
22
+ const IDENTITY_KEYS = ['product', 'schema', 'apiContract', 'pid', 'port', 'startedAt', 'scope',
23
+ 'scriptRealpath', 'runtimeVersion', 'sourceSha256'];
24
+
25
+ export function pidAlive(pid) {
26
+ try { process.kill(pid, 0); return true; }
27
+ catch (error) { return error?.code !== 'ESRCH'; } // EPERM: alive, owned by someone else
28
+ }
29
+
30
+ /** GET 127.0.0.1:<port>/api/runtime → parsed JSON, or null. Bounded; never throws. */
31
+ export function probeRuntimeSync(port, timeoutMs = 1500) {
32
+ if (!Number.isInteger(port) || port <= 0 || port > 65535) return null;
33
+ const code = `const h=require('node:http');const r=h.get({host:'127.0.0.1',port:${port},path:'/api/runtime',timeout:${timeoutMs}},(s)=>{let b='';s.on('data',(c)=>{b+=c;});s.on('end',()=>{if(s.statusCode===200)process.stdout.write(b);process.exit(0);});});r.on('error',()=>process.exit(0));r.on('timeout',()=>{r.destroy();process.exit(0);});`;
34
+ const run = spawnSync(process.execPath, ['-e', code], { encoding: 'utf8', timeout: timeoutMs + 1000, windowsHide: true });
35
+ try { return run.stdout ? JSON.parse(run.stdout) : null; } catch { return null; }
36
+ }
37
+
38
+ export const sameIdentity = (left, right) => IDENTITY_KEYS.every((key) => left?.[key] === right?.[key]);
39
+
40
+ /**
41
+ * Console receipts in `receiptDir`, with dead ones pruned. A receipt is dead only when BOTH hold:
42
+ * its pid is not alive, and its port does not answer /api/runtime with the receipt's identity.
43
+ * A receipt with no integer pid cannot be proven dead and is kept (counted as before).
44
+ */
45
+ export function readConsoleReceipts(receiptDir, { alive = pidAlive, probe = probeRuntimeSync } = {}) {
46
+ const live = [];
47
+ const pruned = [];
48
+ let names = [];
49
+ try { names = fs.readdirSync(receiptDir).filter((name) => name.endsWith('.json')); }
50
+ catch { return { live, pruned }; } // no receipt directory is the ordinary "no Console" state
51
+ for (const name of names) {
52
+ const file = path.join(receiptDir, name);
53
+ let receipt;
54
+ try { receipt = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { continue; }
55
+ if (receipt?.product !== PRODUCT || receipt.schema !== 1) continue;
56
+ if (Number.isInteger(receipt.pid) && receipt.pid > 0 && !alive(receipt.pid)) {
57
+ const answer = probe(receipt.port);
58
+ if (!answer || !sameIdentity(answer, receipt)) {
59
+ try { fs.unlinkSync(file); } catch { /* raced or read-only: still not a running Console */ }
60
+ pruned.push({ file, pid: receipt.pid, port: receipt.port ?? null, startedAt: receipt.startedAt ?? null });
61
+ continue;
62
+ }
63
+ }
64
+ live.push({ file, receipt });
65
+ }
66
+ return { live, pruned };
67
+ }
68
+
69
+ // The replacement Console is a user-facing process, not part of the installer run. It inherits the
70
+ // user's environment — provider API keys (ANTHROPIC/OPENAI/OPENROUTER/GEMINI/GOOGLE/XAI) the Console reads,
71
+ // HTTP(S)_PROXY / NO_PROXY / NODE_EXTRA_CA_CERTS its release fetch needs — minus the flags that describe
72
+ // THIS run: the scheduler's nightly identity, test-harness switches, the refresh-run lock token, Node/npm
73
+ // process plumbing. A denylist, so nothing the Console legitimately reads is silently lost.
74
+ const CONSOLE_ENV_DENY = new Set(['RUVNET_NIGHTLY', 'RUVNET_BRAIN_TEST', 'RUVNET_BRAIN_TEST_LATEST_TAG', 'RUVNET_BRAIN_SCHEDULER_TEST',
75
+ 'RUVNET_BRAIN_IMPORT_ONLY', 'RUVNET_BRAIN_NO_UPDATE_FALLBACK', 'RUVNET_STRICT_INSTALL', 'RUVNET_REFRESH_RUN_TOKEN',
76
+ 'RUVNET_REFRESH_RECEIPT', 'RUVNET_UPGRADE_NOTICE_FILE', 'NODE_OPTIONS', 'NODE_TEST_CONTEXT', 'INIT_CWD', 'CONSOLE_PORT']);
77
+ const CONSOLE_ENV_DENY_PREFIX = /^(?:RUVNET_NIGHTLY_|npm_|VITEST)/;
78
+ export function consoleEnv(env = process.env, port) {
79
+ const clean = Object.fromEntries(Object.entries(env).filter(([key, value]) => value != null
80
+ && !CONSOLE_ENV_DENY.has(key) && !CONSOLE_ENV_DENY_PREFIX.test(key)));
81
+ return { ...clean, CONSOLE_PORT: String(port) };
82
+ }
83
+
84
+ const sleepSync = (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
85
+ const readJson = (file) => { try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } };
86
+
87
+ /**
88
+ * Replace every owned Console still serving an older runtime by running the activated runtime's own
89
+ * launcher (`<entry> --serve`, never --open) in that Console's scope on that Console's port. The
90
+ * launcher authenticates the shutdown with the private control token from the receipt; we only start
91
+ * it and wait for the receipt to name the new runtime. Returns one result per stale receipt, each
92
+ * with `replaced` and, when false, the exact reason.
93
+ */
94
+ export function replaceStaleConsoles({ entry, identity, receiptDir, env = process.env, timeoutMs = 20_000,
95
+ spawnFn = spawn, alive = pidAlive, probe = probeRuntimeSync } = {}) {
96
+ const results = [];
97
+ const { live } = readConsoleReceipts(receiptDir, { alive, probe });
98
+ for (const { file, receipt } of live) {
99
+ if (receipt.sourceSha256 === identity.sourceSha256) continue;
100
+ const where = { scope: receipt.scope ?? null, port: receipt.port ?? null, pid: receipt.pid ?? null };
101
+ const refuse = (reason) => results.push({ ...where, replaced: false, reason });
102
+ if (!Number.isInteger(receipt.pid) || !Number.isInteger(receipt.port) || typeof receipt.scope !== 'string') {
103
+ refuse('its receipt has no pid/port/scope, so it cannot be addressed safely'); continue;
104
+ }
105
+ if (!/^[a-f0-9]{48}$/.test(receipt.controlToken || '')) {
106
+ refuse('its receipt carries no control token, so the installer cannot prove it owns that Console'); continue;
107
+ }
108
+ if (!fs.existsSync(entry)) { refuse(`the activated Console runtime is missing (${entry})`); continue; }
109
+ if (!fs.existsSync(receipt.scope)) { refuse(`its project directory no longer exists (${receipt.scope})`); continue; }
110
+ // The pid is alive (dead ones were pruned by readConsoleReceipts). If its port does not answer with
111
+ // this receipt's identity, it is either a busy Console or a reused pid — we cannot tell which, so we
112
+ // never delete the receipt (that could orphan a live stale Console). Report it at once instead of
113
+ // launching and waiting 20s for a process that may never release anything.
114
+ const answer = probe(receipt.port) || probe(receipt.port);
115
+ if (!answer || !sameIdentity(answer, receipt)) {
116
+ refuse(`pid ${receipt.pid} is alive but port ${receipt.port} does not answer with that Console's identity (busy, or the pid was reused); its receipt was kept — restart Console`);
117
+ continue;
118
+ }
119
+ try {
120
+ const child = spawnFn(process.execPath, [entry, '--serve'], { cwd: receipt.scope, detached: true, stdio: 'ignore',
121
+ windowsHide: true, env: consoleEnv(env, receipt.port) });
122
+ child.on?.('error', () => {});
123
+ child.unref?.();
124
+ } catch (error) { refuse(`could not start the current Console: ${error.message}`); continue; }
125
+ // Done = the scope's receipt names the new runtime from a new live process AND the old process
126
+ // is gone (the launcher falls back to a free port if the old one never lets go — that is not a
127
+ // replacement, it is two Consoles).
128
+ const until = Date.now() + timeoutMs;
129
+ let current = null;
130
+ let oldAlive = true;
131
+ while (Date.now() < until) {
132
+ const seen = readJson(file);
133
+ current = seen?.sourceSha256 === identity.sourceSha256 && seen.pid !== receipt.pid && alive(seen.pid) ? seen : null;
134
+ oldAlive = alive(receipt.pid);
135
+ if (current && !oldAlive) break;
136
+ sleepSync(200);
137
+ }
138
+ const secs = Math.round(timeoutMs / 1000);
139
+ if (current && !oldAlive) { results.push({ ...where, replaced: true, newPid: current.pid, newPort: current.port }); continue; }
140
+ if (current) refuse(`the current Console started (pid ${current.pid}, port ${current.port}) but the old one (pid ${receipt.pid}) did not exit within ${secs}s`);
141
+ else if (oldAlive) refuse(`the old Console (pid ${receipt.pid}) did not release port ${receipt.port} within ${secs}s`);
142
+ else refuse(`the old Console stopped but the current one did not report the new runtime within ${secs}s`);
143
+ }
144
+ return results;
145
+ }
@@ -42,6 +42,8 @@ export const CONSOLE_RUNTIME_SURFACE = Object.freeze([
42
42
  // Keep their bytes in the same copy/digest authority as the installer itself.
43
43
  'kb/refresh-run.mjs',
44
44
  'kb/lifecycle-evidence-retention.mjs',
45
+ // install.mjs --update recovers an interrupted storage transaction before it looks for the updater.
46
+ 'kb/update-storage-transaction.mjs',
45
47
  'kb/model-requirements.mjs',
46
48
  'kb/zip-extract.mjs',
47
49
  // install.mjs imports this STATICALLY (corpus transport identity + approved-runtime stamping,