ruvnet-brain 4.7.17 → 4.7.19

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 (38) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +82 -15
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/.codex-plugin/plugin.json +1 -1
  6. package/plugin/commands/configure.md +3 -3
  7. package/plugin/scripts/agentdb-recall-process.mjs +1 -1
  8. package/plugin/scripts/agentdb-recall-reader.mjs +1 -1
  9. package/plugin/scripts/brain-confirmation.mjs +8 -1
  10. package/plugin/scripts/brain-footprint.mjs +3 -1
  11. package/plugin/scripts/decision-record.mjs +1 -1
  12. package/plugin/scripts/degradation-watch.mjs +13 -1
  13. package/plugin/scripts/developer-update-policy.mjs +31 -5
  14. package/plugin/scripts/developer-update.mjs +17 -5
  15. package/plugin/scripts/learning-store.mjs +1 -1
  16. package/plugin/scripts/nightly-scheduler.mjs +29 -8
  17. package/plugin/scripts/ruflo-bin.mjs +5 -1
  18. package/plugin/scripts/ruflo-memory-hooks.mjs +1 -1
  19. package/plugin/scripts/turn-outcome-capture.mjs +1 -1
  20. package/scripts/agentdb-fleet-doctor.mjs +1 -1
  21. package/scripts/codex-hook-trust-reconcile.mjs +126 -9
  22. package/scripts/codex-hook-trust.mjs +45 -0
  23. package/scripts/corpus-dispatch-decision.mjs +5 -4
  24. package/scripts/distill-project.mjs +1 -1
  25. package/scripts/falsify.mjs +1 -1
  26. package/scripts/health-repair.mjs +1 -1
  27. package/scripts/learning-replay-execution.mjs +3 -3
  28. package/scripts/learning-replay-fixture.mjs +3 -0
  29. package/scripts/lesson-migrate-agentdb.mjs +1 -1
  30. package/scripts/nightly-wrapper.sh +2 -2
  31. package/scripts/proxy/claude-proxied.sh +1 -0
  32. package/scripts/proxy/proxy-revert.sh +1 -0
  33. package/scripts/proxy/proxy-up.sh +1 -0
  34. package/scripts/proxy/proxy-verify.mjs +1 -1
  35. package/scripts/record-lesson.mjs +1 -1
  36. package/scripts/selfcheck.mjs +1 -1
  37. package/scripts/single-source-check.mjs +1 -1
  38. package/scripts/stack-sync.mjs +11 -3
package/README.md CHANGED
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
9
9
 
10
10
  ### Discover the stack. Read the source. Build with confidence.
11
11
 
12
- [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.17-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
12
+ [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.19-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
13
13
 
14
14
  **A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
15
15
 
@@ -255,7 +255,7 @@ uses the protected release workflow; a consumer update downloads an available ge
255
255
  does not rebuild upstream knowledge. Check `--doctor`, `SOURCE.json` and refresh receipts
256
256
  before treating an installation as current.
257
257
 
258
- The **4.6.0 published-corpus baseline** is **205 public stores · 165,153 public source chunks**.
258
+ The **4.6.0 published-corpus baseline** is **205 public stores · 165,488 public source chunks**.
259
259
  Those are bundle census values, not a claim that every upstream repository is fresh today.
260
260
  Later corpus publication may advance the knowledge generation independently of code changes.
261
261
 
package/bin/install.mjs CHANGED
@@ -2028,25 +2028,53 @@ export function codexMarketplaceRows(value) {
2028
2028
  return Array.isArray(value) ? value : [];
2029
2029
  }
2030
2030
 
2031
- function runCodexJson(args, {
2031
+ // Codex refuses EVERY plugin command while any configured marketplace snapshot fails to load, including one that is
2032
+ // not the Brain's. Measured 2026-10-23: a 7:21 p.m. ET update failed host convergence on "`claude-code-plugins` at
2033
+ // ~/.codex/.tmp/marketplaces/claude-code-plugins: marketplace root does not contain a supported manifest" while Codex
2034
+ // was refreshing that Git snapshot; the same step passed at 5 a.m. Reproduced with a snapshot missing its manifest:
2035
+ // `codex plugin list` exits 1, and `codex plugin marketplace upgrade <name>` repairs it (codex-cli 0.164.0).
2036
+ const CODEX_SNAPSHOT_FAILURE = /failed to load configured marketplace snapshot/i;
2037
+ export function codexBrokenMarketplaces(text) {
2038
+ return [...String(text || '').matchAll(/^- `([^`]+)` at /gm)].map((m) => m[1]);
2039
+ }
2040
+ const sleepSync = (ms) => { if (ms > 0) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
2041
+
2042
+ export function runCodexJson(args, {
2032
2043
  codexBin = process.env.CODEX_BIN || 'codex',
2033
2044
  codexHome = codexHomeDir(),
2034
2045
  cwd = process.cwd(),
2046
+ run = runHostCli,
2047
+ waitMs = 5_000,
2048
+ // Refreshing another tool's marketplace is a network write; only an install/update that is about to change the
2049
+ // Brain's Codex plugin opts in. --doctor and listings report the broken snapshot and change nothing (review 2026-10-24).
2050
+ repair = false,
2035
2051
  } = {}) {
2036
- const r = runHostCli(codexBin, args, {
2037
- stdio: 'pipe',
2038
- shell: false,
2039
- cwd,
2040
- env: { ...process.env, CODEX_HOME: codexHome },
2041
- encoding: 'utf8',
2042
- timeout: 30_000,
2043
- maxBuffer: 20 * 1024 * 1024,
2044
- });
2052
+ const options = { stdio: 'pipe', shell: false, cwd, env: { ...process.env, CODEX_HOME: codexHome }, encoding: 'utf8',
2053
+ timeout: 30_000, maxBuffer: 20 * 1024 * 1024 };
2054
+ const snapshotFailure = (res) => !res.missingBinary && res.status !== 0 && CODEX_SNAPSHOT_FAILURE.test(String(res.stderr || res.stdout || ''));
2055
+ let r = run(codexBin, args, options);
2056
+ // One wait covers a refresh Codex is finishing; then refresh only the OTHER Git marketplaces it named (the Brain's
2057
+ // own local snapshot is repaired by prepareCodexMarketplace), once, and ask again. Bounded: at most three asks.
2058
+ const repaired = [];
2059
+ if (snapshotFailure(r)) { sleepSync(waitMs); r = run(codexBin, args, options); }
2060
+ if (repair && snapshotFailure(r)) {
2061
+ for (const name of codexBrokenMarketplaces(r.stderr || r.stdout).filter((n) => n !== CODEX_MARKETPLACE)) {
2062
+ const up = run(codexBin, ['plugin', 'marketplace', 'upgrade', name, '--json'], options);
2063
+ repaired.push(`${name}: ${up.status === 0 ? 'refreshed' : `refresh failed (exit ${up.status})`}`);
2064
+ }
2065
+ if (repaired.length) r = run(codexBin, args, options);
2066
+ }
2045
2067
  if (r.missingBinary) return { ok: false, error: r.message };
2046
2068
  if (r.error || r.status !== 0) {
2047
- const detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
2048
- return { ok: false, error: detail };
2069
+ let detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
2070
+ if (snapshotFailure(r)) {
2071
+ const names = codexBrokenMarketplaces(r.stderr || r.stdout);
2072
+ detail += ` — Codex cannot load its ${names.map((n) => `\`${n}\``).join(', ') || 'configured'} marketplace snapshot`
2073
+ + `${repaired.length ? ` (tried: ${repaired.join('; ')})` : ''}; repair with: ${names.map((n) => `codex plugin marketplace upgrade ${n}`).join(' && ') || 'codex plugin marketplace upgrade'}`;
2074
+ }
2075
+ return { ok: false, error: detail, ...(repaired.length ? { repaired } : {}) };
2049
2076
  }
2077
+ if (repaired.length && !TEST_MODE) info(` Codex marketplace snapshot repaired before the Brain step (${repaired.join('; ')})`);
2050
2078
  try { return { ok: true, value: JSON.parse(r.stdout || 'null') }; }
2051
2079
  catch { return { ok: false, error: `Codex returned non-JSON output for ${args.join(' ')}` }; }
2052
2080
  }
@@ -2105,7 +2133,7 @@ export function wireCodexPlugin({
2105
2133
  aliasHome,
2106
2134
  } = {}) {
2107
2135
  if (!fs.existsSync(codexDir)) return { host: false, action: 'no-host' };
2108
- const options = { codexBin, codexHome, cwd };
2136
+ const options = { codexBin, codexHome, cwd, repair: true };
2109
2137
  // Codex loads every configured marketplace before it can report plugin state. Repair only an
2110
2138
  // absent/malformed Brain-owned snapshot before that probe, or the probe can fail before its own
2111
2139
  // repair path is reachable. A healthy current cache stays byte-untouched, and plugin enablement
@@ -3874,7 +3902,9 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
3874
3902
  const fail = (detail = {}) => {
3875
3903
  runtimeTransaction.rollback();
3876
3904
  hostLock.release();
3877
- return { ok: false, results, ...detail };
3905
+ // Three paths below returned no error at all, so the refresh receipt recorded `state: null, error: null` and the
3906
+ // failure could not be diagnosed afterwards (2026-10-23 7:21 p.m. ET). Every failure now carries its reason.
3907
+ return { ok: false, results, ...detail, error: detail.error || hostFailureReason(results) };
3878
3908
  };
3879
3909
 
3880
3910
  try {
@@ -4035,6 +4065,18 @@ export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSI
4035
4065
  }
4036
4066
 
4037
4067
  /** Activation and host readiness are independent; never claim a rollback from a host refusal. */
4068
+ /** Why host synchronization stopped, from the per-host results it had gathered; null when nothing says. */
4069
+ export function hostFailureReason(results = {}) {
4070
+ const firstLines = (text) => String(text || '').trim().split('\n').slice(0, 3).join(' ');
4071
+ if (results.claude?.host && !results.claude.wired) {
4072
+ return `Claude Code plugin ${results.claude.action || 'wiring failed'}${results.claude.version ? ` (installed ${results.claude.version}, required ${results.claude.expectedVersion || PACKAGE_VERSION})` : ''}`;
4073
+ }
4074
+ if (results.codex && !['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
4075
+ return `Codex plugin ${results.codex.action || 'failed'}${results.codex.error ? `: ${firstLines(results.codex.error)}` : ''}`;
4076
+ }
4077
+ return null;
4078
+ }
4079
+
4038
4080
  export function hostSynchronizationFailureMessage(convergence, active, expectedVersion = PACKAGE_VERSION) {
4039
4081
  const verified = active?.version === expectedVersion && Number.isSafeInteger(active.generation) && active.generation > 0
4040
4082
  && active.codeRoot === `versions/${expectedVersion}`;
@@ -4093,11 +4135,36 @@ function refuseOverSetAsideBrain() {
4093
4135
  return true;
4094
4136
  }
4095
4137
 
4138
+ /**
4139
+ * A SOURCE CHECKOUT older than the runtime already installed cannot apply the current release: it downloads ~736 MB
4140
+ * and then refuses on "staged ReleaseCoverage failed integrity — release version 4.7.17 differs from 4.5.2" (measured
4141
+ * 2026-10-23: `npx ruvnet-brain --update` run inside a stale checkout resolves that checkout's own bin). Say so
4142
+ * before the download, with the command that works. Only a checkout: a published installer can legitimately be
4143
+ * older than active.json (a plugin seeded from main during a release window, a release candidate installed from a
4144
+ * checkout), and the nightly and self-heal `@latest` updates must keep working then (review, 2026-10-24).
4145
+ */
4146
+ export function olderInstallerRefusal({ brainHome, packageVersion = PACKAGE_VERSION, repoRoot = REPO_ROOT } = {}) {
4147
+ let installed;
4148
+ try { installed = JSON.parse(fs.readFileSync(path.join(brainHome, 'active.json'), 'utf8'))?.version; } catch { return null; }
4149
+ if (!/^\d+\.\d+\.\d+$/.test(String(installed || ''))) return null;
4150
+ try { if (cmpVersion(installed, packageVersion) <= 0) return null; } catch { return null; }
4151
+ if (!fs.existsSync(path.join(repoRoot, '.git'))) return null;
4152
+ return `this is ruvnet-brain ${packageVersion}, a source checkout at ${repoRoot}, older than the ${installed} already `
4153
+ + 'installed here, and an older checkout cannot apply the current release. '
4154
+ + 'Run npx ruvnet-brain@latest --update from any other folder, or bring this checkout up to date first (git pull).';
4155
+ }
4156
+
4096
4157
  async function runUpdate() {
4097
4158
  printBanner('update');
4098
4159
  refuseUnmountedBrain();
4099
4160
  const kbDir = resolvedKbDir();
4100
4161
  const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(kbDir);
4162
+ const olderInstaller = olderInstallerRefusal({ brainHome });
4163
+ if (olderInstaller) {
4164
+ console.error(`\n${c.red('✗ update stopped before downloading:')} ${olderInstaller}\n`);
4165
+ process.exitCode = 1;
4166
+ return;
4167
+ }
4101
4168
  if (FLAG_HOST_SYNC_ONLY) {
4102
4169
  info(c.dim('repairing host shells and managed model catalog without mutating KB bytes…\n'));
4103
4170
  const convergence = syncHostsAfterUpdate(kbDir);
@@ -5650,7 +5717,7 @@ const rufloProbeRun = (args, cli = 'ruflo', timeoutMs = 10_000) => {
5650
5717
  // (verified live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
5651
5718
  // services/daemon-autostart.js:85) — a read-only health probe must not leave one running.
5652
5719
  const result = spawnSync(cli, args, { cwd: process.cwd(), encoding: 'utf8', timeout: timeoutMs, shell: IS_WIN && /\.(?:cmd|bat)$/i.test(cli),
5653
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
5720
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' } });
5654
5721
  // A spawn failure or a timeout is marked, so it can never pass for an (empty, "healthy") answer.
5655
5722
  return `${result.stdout || ''}\n${result.stderr || ''}${result.error ? `\n[ruflo-probe-error] ${result.error.code || result.error.message}` : ''}`;
5656
5723
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.17",
3
+ "version": "4.7.19",
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": {
@@ -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.7.17",
4
+ "version": "4.7.19",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.17",
3
+ "version": "4.7.19",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -1,7 +1,7 @@
1
1
  ---
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-10 06:00:22 EDT
4
- version: 1.0.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-24 18:00:00 EDT
4
+ version: 1.0.3
5
5
  ---
6
6
 
7
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -33,7 +33,7 @@ export function searchOnce({ ruflo, store, args, deadline, env, scratch, operati
33
33
  catch { cleanup(); resolve({ rows: [], state: 'unavailable' }); return; }
34
34
  let child;
35
35
  try {
36
- child = spawn(inv.executable, inv.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined },
36
+ child = spawn(inv.executable, inv.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false', CLAUDE_FLOW_MEMORY_PATH: undefined },
37
37
  stdio: ['ignore', 'pipe', 'ignore'], detached: process.platform !== 'win32', windowsHide: true });
38
38
  } catch { cleanup(); resolve({ rows: [], state: 'unavailable' }); return; }
39
39
  let out = '', outputBytes = 0;
@@ -8,7 +8,7 @@ import { resolveRuflo, rufloInvocationOrPlain } from './ruflo-bin.mjs';
8
8
  const call = (bin, args, deadline) => new Promise((resolve) => {
9
9
  const timeout = Math.max(1, deadline - Date.now());
10
10
  const inv = rufloInvocationOrPlain(bin, args, { env: process.env });
11
- execFile(inv.executable, inv.args, { timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } },
11
+ execFile(inv.executable, inv.args, { timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' } },
12
12
  (error, stdout) => resolve(error ? null : String(stdout || '')));
13
13
  });
14
14
 
@@ -181,8 +181,15 @@ export function confirm({ footprint, env = process.env, home = os.homedir(), now
181
181
  // Footprint
182
182
  const parts = Object.entries(footprint.breakdown).sort((a, b) => b[1] - a[1]).map(([k, v]) => `${k} ${formatBytes(v)}`);
183
183
  const foreign = footprint.unowned.reduce((n, i) => n + (i.bytes || 0), 0);
184
+ // `--clean` is the fix only when it can remove something. Measured 2026-10-24: 3.01 GB of a 2.23 GB budget with
185
+ // zero cruft, the line said "fix: --clean", and --clean freed nothing — the bytes were a 1.3 GB npm install ruflo's
186
+ // self-update left in ruvnet-brain/ruflo-cwd, counted as Brain state. Name where the bytes are instead.
187
+ const reclaimable = footprint.cruft.some((i) => ['remove', 'remove-if-proven', 'rotate', 'truncate', 'collect', 'prune'].includes(i.action));
188
+ const largest = footprint.items.filter((i) => i.kind === 'state' && i.bytes).sort((a, b) => b.bytes - a.bytes).slice(0, 2);
189
+ const footprintFix = reclaimable || !largest.length ? CLEAN
190
+ : `${CLEAN} has nothing to remove; the largest Brain folders are ${largest.map((i) => `${i.path} (${formatBytes(i.bytes)})`).join(' and ')} — see what is filling them`;
184
191
  lines.push(line('footprint', 'Footprint', footprint.withinBudget ? 'ok' : 'fail',
185
- `${formatBytes(footprint.totalBytes)} of ${formatBytes(footprint.budgetBytes)} budget (${parts.join(' · ')})${foreign ? `; not counted: ${formatBytes(foreign)} owned by other tools` : ''}`, CLEAN));
192
+ `${formatBytes(footprint.totalBytes)} of ${formatBytes(footprint.budgetBytes)} budget (${parts.join(' · ')})${foreign ? `; not counted: ${formatBytes(foreign)} owned by other tools` : ''}`, footprintFix));
186
193
 
187
194
  // No cruft
188
195
  const cruft = footprint.cruft;
@@ -25,6 +25,7 @@ import path from 'node:path';
25
25
  import { fileURLToPath, pathToFileURL } from 'node:url';
26
26
  import { isKbTree, kbCopyProof } from './kb-copy-proof.mjs';
27
27
  import { brainLocation } from './brain-location.mjs';
28
+ import { RUFLO_CWD_ARTIFACTS } from './ruflo-bin.mjs';
28
29
  import { assessMoveLeftovers, cachedKept, cmpVersion, isVolumeMetadata, keptCopyFix, physical, pidAlive, readProofCache, rememberKept, removeWithin, rotate,
29
30
  treeBytes, truncateToTail } from './footprint-io.mjs';
30
31
 
@@ -59,7 +60,8 @@ export const LOG_FILES = Object.freeze(['evidence.jsonl', 'token-ledger.jsonl',
59
60
  'capability-live-evidence.jsonl', 'design-grades.jsonl', 'grounding-overrides.jsonl']);
60
61
  const TEXT_LOG = /^\.(?:last-[A-Za-z0-9-]+|seed)\.log$/;
61
62
  const RECOVERY_LEFTOVER = /(?:\.(?:bak|retired|dead)-\d{6,}|^bootstrap-backup-\d)/;
62
- const RUFLO_DEBRIS = new Set(['.swarm', '.claude', '.claude-flow', 'ruvector.db']);
63
+ // One list with ruflo-bin.mjs, which clears the same names before each call (incl. a self-update's npm install, 1.3 GB on 2026-10-22).
64
+ const RUFLO_DEBRIS = new Set(RUFLO_CWD_ARTIFACTS);
63
65
  // Directories in the brain home that hold data another tool owns: measured, shown, never budgeted or removed.
64
66
  const FOREIGN = { 'ruvector-mcp': 'RuVector MCP server working store (its own live memory)',
65
67
  'worktree-recovery': 'hand-made recovery archives, not created by the Brain' };
@@ -138,7 +138,7 @@ function runRuflo(ruflo, db, args, { spawn = spawnSync } = {}) {
138
138
  try {
139
139
  const { executable, args: argv } = rufloInvocation(ruflo, args);
140
140
  const r = spawn(executable, argv, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true,
141
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined } });
141
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false', CLAUDE_FLOW_MEMORY_PATH: undefined } });
142
142
  return { status: Number.isInteger(r.status) ? r.status : 1, stdout: String(r.stdout || ''), output: `${r.stderr || ''}\n${r.stdout || ''}${r.error ? `\n${r.error.message}` : ''}` };
143
143
  } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
144
144
  }
@@ -52,6 +52,7 @@ import path from 'node:path';
52
52
  // is the exact defect class this file exists to close. Caught by counting occurrences, not by
53
53
  // reading the code and believing it.
54
54
  import { fileURLToPath } from 'node:url';
55
+ import { rufloRunDir } from './ruflo-bin.mjs';
55
56
 
56
57
  /**
57
58
  * THE PROBE TARGET IS A SCRATCH DB, NOT THE PROJECT'S.
@@ -73,8 +74,19 @@ import { fileURLToPath } from 'node:url';
73
74
  */
74
75
  const defaultDb = () => path.join(os.tmpdir(), `ruvnet-durability-probe-${process.pid}.db`);
75
76
 
77
+ // ruflo writes into its cwd on every call (.claude/, .claude-flow/, ruvector.db, agentdb.rvf) and its startup
78
+ // self-update runs `npm install @claude-flow/cli --save-exact` there. This probe runs on every `git push`, so with the
79
+ // caller's cwd it could leave those in the user's project (measured 2026-10-22: a 1.4 GB node_modules and a
80
+ // package.json in a project). It now runs in a private scratch dir, removed after the call, with both switches off.
76
81
  function defaultRun(cmd, args) {
77
- return execFileSync(cmd, args, { encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'] });
82
+ let cwd = null;
83
+ try { cwd = rufloRunDir(path.join(os.tmpdir(), 'ruvnet-durability-probe')); } catch { /* fall back to tmp below */ }
84
+ try {
85
+ return execFileSync(cmd, args, { cwd: cwd || os.tmpdir(), encoding: 'utf8', timeout: 30_000, stdio: ['ignore', 'pipe', 'pipe'],
86
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' } });
87
+ } finally {
88
+ if (cwd) { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } }
89
+ }
78
90
  }
79
91
 
80
92
  /**
@@ -32,17 +32,43 @@ export function normalizeNpmDistTags(value) {
32
32
  }
33
33
  return value;
34
34
  }
35
- export function pickTargetTag(tags, want = 'latest', defaultTag = 'latest') {
36
- const candidates = [...new Set([want, defaultTag])].filter(tag => typeof tags?.[tag] === 'string');
35
+ export function normalizeNpmTimes(value) {
36
+ // `npm view <pkg> time --json`: an object of version -> ISO time (npm 12 wraps it in a one-record array).
37
+ if (Array.isArray(value)) value = value.length === 1 ? value[0] : null;
38
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
39
+ }
40
+ /**
41
+ * The newer of {want, defaultTag} by version — EXCEPT a non-default tag whose version was published BEFORE the
42
+ * default tag's version. That is an abandoned pre-release line, not a newer build: measured 2026-10-23, agentic-flow
43
+ * `alpha` = 3.0.0-alpha.2 (published 2026-03-25) against `latest` = 2.1.4 (2026-10-09); by version alone the alpha
44
+ * channel would install seven-month-old code. `published` is `npm view <pkg> time`; without it, ordering is unchanged.
45
+ */
46
+ export function pickTargetTag(tags, want = 'latest', defaultTag = 'latest', { published = null } = {}) {
47
+ const at = (version) => Date.parse(published?.[version] ?? '');
48
+ // `published` null: the caller did not ask, ordering is by version. An object: times were REQUIRED, so a missing or
49
+ // unreadable time makes the non-default tag unverifiable and it is skipped (cross-model review 2026-10-24: a failed
50
+ // lookup must not let a seven-month-old pre-release through).
51
+ const abandoned = (tag) => tag !== defaultTag && typeof tags?.[defaultTag] === 'string' && published !== null
52
+ && (!Number.isFinite(at(tags[tag])) || !Number.isFinite(at(tags[defaultTag])) || at(tags[tag]) < at(tags[defaultTag]));
53
+ const candidates = [...new Set([want, defaultTag])].filter(tag => typeof tags?.[tag] === 'string' && !abandoned(tag));
37
54
  candidates.sort((a, b) => cmpVersion(tags[b], tags[a]));
38
55
  return { tag: candidates[0] || null, target: tags?.[candidates[0]] || null };
39
56
  }
40
- export function selectTag(name, current, tags, channel = 'latest') {
57
+ /** Whether choosing between `want` and `latest` needs publish times: only when the two tags name different versions. */
58
+ export function needsPublishTimes(tags, want) {
59
+ return want !== 'latest' && typeof tags?.[want] === 'string' && typeof tags?.latest === 'string' && tags[want] !== tags.latest;
60
+ }
61
+ /** The ONE answer to "which tag does this package track": shared by the updater and the stack-sync notice. */
62
+ export function wantedTag(name, channel = 'latest') {
41
63
  if (!['latest', 'alpha'].includes(channel)) throw Error('unsupported update channel');
42
64
  // Kit's installed release contract uses next; never convert it to an npm alpha alias.
65
+ return name === '@pacphi/agentic-kit' ? 'next' : FAMILY.test(name) && channel === 'alpha' ? 'alpha' : 'latest';
66
+ }
67
+ export function selectTag(name, current, tags, channel = 'latest', { published = null } = {}) {
68
+ const wanted = wantedTag(name, channel);
43
69
  tags = normalizeNpmDistTags(tags);
44
- const wanted = name === '@pacphi/agentic-kit' ? 'next' : FAMILY.test(name) && channel === 'alpha' ? 'alpha' : 'latest';
45
- const { tag, target: version } = pickTargetTag(tags, wanted);
70
+ const times = published === null || published === undefined ? null : (normalizeNpmTimes(published) || {});
71
+ const { tag, target: version } = pickTargetTag(tags, wanted, 'latest', { published: times });
46
72
  if (!tag) throw Error(`no allowed release tag: ${name}`);
47
73
  return { tag, version, upgrade: cmpVersion(version, current) > 0, ahead: cmpVersion(version, current) < 0 };
48
74
  }
@@ -6,7 +6,7 @@ import os from 'node:os';
6
6
  import crypto from 'node:crypto';
7
7
  import { spawnSync } from 'node:child_process';
8
8
  import { pathToFileURL } from 'node:url';
9
- import { cmpVersion as compare, selectTag, FAMILY, PLUGIN_MARKETPLACES, REVIEWED_INSTALL_SCRIPTS, EXECUTION_MODULES } from './developer-update-policy.mjs';
9
+ import { cmpVersion as compare, selectTag, needsPublishTimes, normalizeNpmDistTags, wantedTag, FAMILY, PLUGIN_MARKETPLACES, REVIEWED_INSTALL_SCRIPTS, EXECUTION_MODULES } from './developer-update-policy.mjs';
10
10
  import { acquireDeveloperLock, sharedLockStatus } from './developer-update-lock.mjs';
11
11
  import { maintenance } from './developer-update-maintenance.mjs';
12
12
  import { cleanupNpxDuplicates } from './developer-update-cleanup.mjs';
@@ -31,7 +31,7 @@ export function atomic(file, value) {
31
31
  function invoke(command, args, { cwd = HOME, timeout = 600_000, allowed = [0], env = process.env, capture = false } = {}) {
32
32
  const r = spawnSync(command, args, { cwd, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024,
33
33
  env: { ...env, PATH: [...new Set([path.dirname(NODE), path.join(env.npm_config_prefix || PREFIX, process.platform === 'win32' ? '' : 'bin'), ...(env.PATH || '').split(path.delimiter)])].join(path.delimiter),
34
- CI: '1', DISABLE_AUTOUPDATER: '1', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'Never', GH_PROMPT_DISABLED: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1', HOMEBREW_NO_AUTO_UPDATE: '1', RUVNET_BRAIN_HOME: env.RUVNET_BRAIN_HOME || CACHE } });
34
+ CI: '1', DISABLE_AUTOUPDATER: '1', CLAUDE_FLOW_AUTO_UPDATE: 'false', GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'Never', GH_PROMPT_DISABLED: '1', HOMEBREW_NO_INSTALL_CLEANUP: '1', HOMEBREW_NO_AUTO_UPDATE: '1', RUVNET_BRAIN_HOME: env.RUVNET_BRAIN_HOME || CACHE } });
35
35
  if (!capture && (r.error || !allowed.includes(r.status))) throw Error(`${path.basename(command)} ${args[0]}: ${r.error?.message || `exit ${r.status}`}: ${(r.stderr || r.stdout || '').trim().slice(-600)}`);
36
36
  return { stdout: (r.stdout || '').trim(), stderr: (r.stderr || '').trim(), exitCode: r.status, error: r.error?.message || null };
37
37
  }
@@ -120,7 +120,7 @@ function verifyPackage(before, expected, run) {
120
120
  }
121
121
  return after;
122
122
  }
123
- export function upgradePackage(before, tags, scripts = REVIEWED_INSTALL_SCRIPTS, { run = invokeText, dryRun = false, channel = 'latest', npm = NPM, preservePackages = [] } = {}) {
123
+ export function upgradePackage(before, tags, scripts = REVIEWED_INSTALL_SCRIPTS, { run = invokeText, dryRun = false, channel = 'latest', npm = NPM, preservePackages = [], published = null } = {}) {
124
124
  // Snapshot identity must still exist and own the original paths before any installer runs.
125
125
  if (before.localSource && before.rawLink !== undefined) {
126
126
  if (!fs.lstatSync(before.location).isSymbolicLink() || fs.readlinkSync(before.location) !== before.rawLink) throw Error(`source link changed before preservation: ${before.name}`);
@@ -133,7 +133,7 @@ export function upgradePackage(before, tags, scripts = REVIEWED_INSTALL_SCRIPTS,
133
133
  if (live.localSource) return { name: before.name, state: 'local-source-preserved', before, after: live };
134
134
  if (live.launchers.some(b => !b.real)) throw Error(`absent launcher: ${before.name}`);
135
135
  if (live.launchers.some(b => !b.owned)) return { name: before.name, state: 'shadowed-preserved', before, after: live };
136
- const target = selectTag(before.name, before.version, tags, channel);
136
+ const target = selectTag(before.name, before.version, tags, channel, { published });
137
137
  if (preservePackages.includes(before.name)) return { name: before.name, state: 'local-modification-preserved', reason: 'preservedLocalModification', target, before, after: live };
138
138
  if (!target.upgrade || dryRun) return { name: before.name, state: target.upgrade ? 'update-available' : target.ahead ? 'ahead-preserved' : 'current', target, before, after: live };
139
139
  const spec = `${before.name}@${target.tag}`;
@@ -422,7 +422,19 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
422
422
  persistReceipt();
423
423
  for (const before of receipt.before) {
424
424
  const tags = before.localSource ? {} : JSON.parse(run(npm, ['view', before.name, 'dist-tags', '--json'], { timeout: 90_000 }));
425
- receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages }));
425
+ // A tracked non-latest tag (alpha, Kit's next) needs publish times to tell a newer pre-release from an abandoned
426
+ // one (developer-update-policy pickTargetTag); fetched only when that tag and latest name different versions. A
427
+ // failed lookup is {} — required but unknown — so the pre-release is skipped, never installed blind.
428
+ let published = null;
429
+ if (!before.localSource) {
430
+ let needed = false;
431
+ try { needed = needsPublishTimes(normalizeNpmDistTags(tags), wantedTag(before.name, config.channel)); } catch { needed = false; }
432
+ if (needed) {
433
+ try { published = JSON.parse(run(npm, ['view', before.name, 'time', '--json'], { timeout: 90_000 })); }
434
+ catch { published = {}; }
435
+ }
436
+ }
437
+ receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages, published }));
426
438
  persistReceipt();
427
439
  }
428
440
  receipt.plugins = await synchronizePlugins(run, mode === 'check', receipt.notes, { home, prefix, scope: config.scope, locate, unattended: !!env.RUVNET_NIGHTLY_IDENTITY });
@@ -32,7 +32,7 @@ export function runLearningRuflo(binary, args, db, { env = process.env, deadline
32
32
  const cwd = rufloRunDir(db, { root: env.RUVNET_RUFLO_CWD_ROOT || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.cache', 'ruvnet-brain', 'learning-ruflo') });
33
33
  try {
34
34
  const invocation = rufloInvocation(binary, args);
35
- return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
35
+ return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' },
36
36
  encoding: 'utf8', shell: false, timeout: Math.min(maxStepMs, remaining), killSignal: 'SIGKILL', maxBuffer: 65536, windowsHide: true });
37
37
  } finally { fs.rmSync(cwd, { force: true, recursive: true }); }
38
38
  }
@@ -186,7 +186,8 @@ function verifyUpdateModules(modules) {
186
186
  * the allowlisted values from env; an object is kept verbatim; null omits the field (an older registration without it).
187
187
  */
188
188
  export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
189
- identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false } = {}) {
189
+ identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false,
190
+ replacedUpdateModules = null } = {}) {
190
191
  validateIdentity(identity);
191
192
  if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
192
193
  const bytes = fs.readFileSync(source);
@@ -204,7 +205,8 @@ export function installNightlyRunner({ brainHome, source, nodePath = process.exe
204
205
  const record = { updateModules: modules, ...(developerSuite ? { mode: 'developer-suite' } : {}), schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
205
206
  nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
206
207
  ...(environment === null ? {} : { environment: environment ?? Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])) }),
207
- packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity) };
208
+ packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity),
209
+ ...(Array.isArray(replacedUpdateModules) && replacedUpdateModules.length ? { replacedUpdateModules } : {}) };
208
210
  const recordPath = path.join(dir, registrationName(identity));
209
211
  if (dryRun) return { ...record, recordPath };
210
212
  const tmp = `${recordPath}.tmp-${process.pid}`;
@@ -239,6 +241,12 @@ export function nightlyModuleCurrency({ brainHome, identity = NIGHTLY_LABEL, mod
239
241
  * and the LaunchAgent command stays exactly what it was; anything else is refused and left as it was.
240
242
  */
241
243
  export const SCHEDULER_VISIBLE_FIELDS = Object.freeze(['identity', 'nodePath', 'runnerPath', 'runnerSha256', 'argv', 'environment', 'packageTarget', 'bundleTarget', 'mode']);
244
+ /** The replaced-module history after replacing `record.updateModules` now: newest first, last 48 h, at most 20. */
245
+ export function nextReplacedSets(record, now = Date.now()) {
246
+ return [{ updateModules: record.updateModules, replacedAt: new Date(now).toISOString() },
247
+ ...(Array.isArray(record.replacedUpdateModules) ? record.replacedUpdateModules : [])]
248
+ .filter((entry) => now - Date.parse(entry?.replacedAt || '') <= 48 * 3_600_000).slice(0, 20);
249
+ }
242
250
  export function refreshNightlyModules(options = {}) {
243
251
  const currency = nightlyModuleCurrency(options);
244
252
  if (currency.state !== 'stale') return currency;
@@ -250,7 +258,11 @@ export function refreshNightlyModules(options = {}) {
250
258
  const candidate = (options.build || ((p) => installNightlyRunner({ ...p, dryRun: true })))(plan);
251
259
  const differs = SCHEDULER_VISIBLE_FIELDS.filter((key) => JSON.stringify(candidate[key] ?? null) !== JSON.stringify(record[key] ?? null));
252
260
  if (differs.length) return { ...currency, state: 'refused', reason: `refresh would change ${differs.join(', ')}; registration left as it was`, fix: NIGHTLY_ENABLE_FIX };
253
- const refreshed = installNightlyRunner(plan);
261
+ // Remember the module set this replaces: the receipt of the night that ran on it stays valid evidence until the next
262
+ // night (2026-10-24: doctor said "nightly refresh FAILED" all day after a session-start update re-pointed the modules).
263
+ // Newest first, every set replaced in the last 48 h (a receipt older than the 30 h health window is stale anyway), at
264
+ // most 20: any number of refreshes before the next night keeps the set that night ran on (reviews 2026-10-24).
265
+ const refreshed = installNightlyRunner({ ...plan, replacedUpdateModules: nextReplacedSets(record) });
254
266
  return { ...currency, state: 'refreshed', registered: registeredModuleDigest(refreshed), fix: undefined };
255
267
  }
256
268
 
@@ -329,10 +341,19 @@ export function developerRunHealth({ brainHome, registration, now = Date.now(),
329
341
  } catch (error) { return { state: 'failed', evidence: `Coordinated update receipt unreadable or refused: ${error.message}`, receipt: null }; }
330
342
  if (!receipt) return { state: 'never-ran', evidence: 'No coordinated update receipt exists yet.', receipt: null };
331
343
  const validDigest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
332
- const bound = validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
333
- && receipt.sourceSha256 === registration.updateModules?.['developer-update.mjs']?.sha256
334
- && Object.entries(receipt.sourceSnapshot || {}).every(([name, digest]) => validDigest(digest) && digest === registration.updateModules?.[name]?.sha256)
335
- && EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || registration.updateModules?.[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === registration.updateModules?.[name]?.sha256);
344
+ const boundTo = modules => Boolean(modules) && validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
345
+ && receipt.sourceSha256 === modules['developer-update.mjs']?.sha256
346
+ && Object.entries(receipt.sourceSnapshot || {}).every(([name, digest]) => validDigest(digest) && digest === modules[name]?.sha256)
347
+ && EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || modules[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === modules[name]?.sha256);
348
+ // A receipt from the module set an update just replaced is still the night's proof, but only one that FINISHED before
349
+ // the replacement: the next scheduled run must bind to the current set.
350
+ // Judged by LAUNCH time: a night's own knowledge step can install a release and re-point the modules before the run
351
+ // records finishedAt (cross-model review 2026-10-24). A run launched after its set was replaced must bind to the new one.
352
+ const launched = Date.parse(receipt.startedAt || receipt.finishedAt || '');
353
+ const ranOnPrevious = !boundTo(registration.updateModules) && Array.isArray(registration.replacedUpdateModules)
354
+ && registration.replacedUpdateModules.some((entry) => Number.isFinite(Date.parse(entry?.replacedAt || ''))
355
+ && launched <= Date.parse(entry.replacedAt) && boundTo(entry.updateModules));
356
+ const bound = boundTo(registration.updateModules) || ranOnPrevious;
336
357
  const ageHours = (now - Date.parse(receipt.finishedAt || receipt.startedAt || '')) / 3_600_000;
337
358
  const times = [receipt.startedAt, receipt.finishedAt].filter(value => value !== undefined).map(value => Date.parse(value));
338
359
  const timestampsValid = times.length > 0 && times.every(value => Number.isFinite(value) && value <= now)
@@ -349,7 +370,7 @@ export function developerRunHealth({ brainHome, registration, now = Date.now(),
349
370
  if (receipt.state !== 'completed') return { state: 'failed', ageHours, receipt, evidence: 'Coordinated update terminal state is unverified.' };
350
371
  if (ageHours > maxAgeHours) return { state: 'stale', ageHours, receipt, evidence: `Last coordinated update is ${ageHours.toFixed(1)}h old.` };
351
372
  const verification = { ok: bound && receipt.mode === 'apply' && receipt.schedulerIdentity === registration.identity && receipt.state === 'completed' && receipt.ok === true && ageHours >= 0 && ageHours <= maxAgeHours };
352
- return { state: verification.ok ? 'ok' : 'failed', verification, ageHours, receipt, evidence: verification.ok ? `Coordinated updates completed ${ageHours.toFixed(1)}h ago; ${receipt.config?.scope || 'unknown'} scope.` : 'Coordinated update timestamp or terminal evidence is invalid.' };
373
+ return { state: verification.ok ? 'ok' : 'failed', verification, ageHours, receipt, evidence: verification.ok ? `Coordinated updates completed ${ageHours.toFixed(1)}h ago; ${receipt.config?.scope || 'unknown'} scope.${ranOnPrevious ? ' That run used the code an update replaced since; the next night runs the update.' : ''}` : 'Coordinated update timestamp or terminal evidence is invalid.' };
353
374
  }
354
375
 
355
376
  export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Date.now(), maxAgeHours = 30,
@@ -245,7 +245,11 @@ export function rufloCwdFor(storePath, { root = rufloScratchRoot() } = {}) {
245
245
  return ensurePrivateDir(path.join(root, projectKey));
246
246
  }
247
247
 
248
- const RUFLO_CWD_ARTIFACTS = Object.freeze(['.swarm', '.claude', '.claude-flow', 'ruvector.db']);
248
+ // node_modules / package.json / package-lock.json: ruflo's startup self-update runs `npm install @claude-flow/cli@X
249
+ // --save-exact` in its cwd (ruflo v3 @claude-flow/cli dist/src/update/executor.js). Measured 2026-10-22: a 1.3 GB
250
+ // install landed here. Every Brain spawn now sets CLAUDE_FLOW_AUTO_UPDATE=false (update/rate-limiter.js reads it);
251
+ // these names are the backstop for a ruflo started without it.
252
+ export const RUFLO_CWD_ARTIFACTS = Object.freeze(['.swarm', '.claude', '.claude-flow', 'ruvector.db', 'node_modules', 'package.json', 'package-lock.json']);
249
253
  const STALE_RUN_MS = 3_600_000;
250
254
 
251
255
  /**
@@ -48,7 +48,7 @@ export function projectStore(projectDir, { deadlineAt = Date.now() + 3000 } = {}
48
48
  export function rufloSpawnOptions(db, env = process.env) {
49
49
  let cwd;
50
50
  try { cwd = rufloRunDir(db, { root: rufloScratchRoot(env) }); } catch { cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-ruflo-')); }
51
- return { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' }, cleanup: () => { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } } };
51
+ return { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' }, cleanup: () => { try { fs.rmSync(cwd, { recursive: true, force: true }); } catch { /* swept later as a stale run- dir */ } } };
52
52
  }
53
53
 
54
54
  /** One `ruflo memory <args>` call, bounded by the caller's deadline. Resolves, never rejects. */
@@ -60,7 +60,7 @@ const TRANSCRIPT_TAIL_BYTES = 2 * 1024 * 1024;
60
60
  const STEP_TIMEOUT_MS = 60_000;
61
61
  export const RECEIPTS_MAX_BYTES = 256 * 1024;
62
62
  export const TURN_STATUS_FRESH_MS /* ✓ only when the newest receipt is verified and younger */ = 24 * 3_600_000;
63
- const RUFLO_ENV = { RUFLO_DAEMON_AUTOSTART: '0' };
63
+ const RUFLO_ENV = { RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' };
64
64
 
65
65
  function textOf(content) {
66
66
  if (typeof content === 'string') return content;
@@ -35,7 +35,7 @@ function ruflo(cwd, args) {
35
35
  // live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/services/
36
36
  // daemon-autostart.js:85) — this doctor walks a whole fleet of project dirs and must not leave
37
37
  // one daemon running per project as a side effect of asking each one a question.
38
- const r = spawnSync(RUFLO, args, { cwd, encoding: 'utf8', timeout: 120000, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
38
+ const r = spawnSync(RUFLO, args, { cwd, encoding: 'utf8', timeout: 120000, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' } });
39
39
  return { out: (r.stdout || '') + (r.stderr || ''), status: r.status };
40
40
  }
41
41
 
@@ -5,7 +5,103 @@ import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { spawn, spawnSync } from 'node:child_process';
8
- import { codexHookIdentities } from './codex-hook-trust.mjs';
8
+ import zlib from 'node:zlib';
9
+ import { codexHookIdentities, codexPluginContentHash } from './codex-hook-trust.mjs';
10
+
11
+ /**
12
+ * sha256 of every regular file under package/plugin/ in a verified npm tarball, read in memory (ustar with pax `path`
13
+ * records; nothing is extracted or written). Strict: a bad header checksum, a non-octal size, a body past the end, or
14
+ * any entry type other than file, directory or pax is refused rather than guessed (review 2026-10-24).
15
+ */
16
+ export function publishedPluginFiles(archive) {
17
+ const tar = zlib.gunzipSync(archive);
18
+ const files = new Map();
19
+ const field = (b) => { const i = b.indexOf(0); return (i < 0 ? b : b.subarray(0, i)).toString('utf8'); };
20
+ const octal = (b, what) => { const t = field(b).trim(); if (!/^[0-7]+$/.test(t)) throw new Error(`Published archive has a non-octal ${what}`); return parseInt(t, 8); };
21
+ const paxRecords = (body) => {
22
+ const out = {}; let i = 0;
23
+ while (i < body.length) {
24
+ const space = body.indexOf(0x20, i);
25
+ if (space < 0) throw new Error('Published archive has a malformed pax record');
26
+ const len = Number(body.subarray(i, space).toString('ascii'));
27
+ if (!Number.isSafeInteger(len) || len <= space - i || i + len > body.length || body[i + len - 1] !== 0x0a) throw new Error('Published archive has a malformed pax record');
28
+ const rec = body.subarray(space + 1, i + len - 1).toString('utf8');
29
+ const eq = rec.indexOf('=');
30
+ if (eq < 1) throw new Error('Published archive has a malformed pax record');
31
+ out[rec.slice(0, eq)] = rec.slice(eq + 1);
32
+ i += len;
33
+ }
34
+ return out;
35
+ };
36
+ let pax = null; let off = 0;
37
+ for (; off + 512 <= tar.length;) {
38
+ const header = tar.subarray(off, off + 512);
39
+ if (header.every((b) => b === 0)) break;
40
+ const sum = header.reduce((n, b, i) => n + (i >= 148 && i < 156 ? 32 : b), 0);
41
+ if (octal(header.subarray(148, 156), 'checksum') !== sum) throw new Error('Published archive header checksum mismatch');
42
+ const size = octal(header.subarray(124, 136), 'size');
43
+ const type = header[156] === 0 ? '0' : String.fromCharCode(header[156]);
44
+ const prefix = field(header.subarray(345, 500));
45
+ const name = pax?.path ?? (prefix ? `${prefix}/${field(header.subarray(0, 100))}` : field(header.subarray(0, 100)));
46
+ if (off + 512 + size > tar.length) throw new Error('Published archive entry runs past the end');
47
+ const body = tar.subarray(off + 512, off + 512 + size);
48
+ off += 512 + Math.ceil(size / 512) * 512;
49
+ if (type === 'x') { pax = paxRecords(body); continue; }
50
+ if (type === 'g') { pax = null; continue; }
51
+ pax = null;
52
+ if (type === '5') continue;
53
+ if (type !== '0') throw new Error(`Published archive has an unsupported entry type ${JSON.stringify(type)}`);
54
+ if (name.startsWith('package/plugin/')) files.set(name.slice('package/plugin/'.length), digest(body));
55
+ }
56
+ if (!files.size) throw new Error('Published archive carries no plugin files');
57
+ return files;
58
+ }
59
+
60
+ // Codex writes this under the installed plugin itself: one SKILL.md per migrated slash command (openai/codex
61
+ // rust-v0.164.0 codex-rs/core-plugins/src/command_migration.rs render_command_skill). It is not release bytes, so it is
62
+ // accepted only when every byte is what that template renders from the release's own verified command file.
63
+ const CODEX_GENERATED = 'migrated-command-skills';
64
+ function commandParts(text) {
65
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/.exec(text);
66
+ if (!m) return null;
67
+ const d = /^description:\s*"((?:[^"\\]|\\.)*)"\s*$/m.exec(m[1]);
68
+ return d ? { description: JSON.parse(`"${d[1]}"`).trim(), body: m[2] } : null;
69
+ }
70
+ function expectedGeneratedSkill(skillName, sourceName, commandText) {
71
+ const parts = commandParts(commandText);
72
+ if (!parts || !parts.description) return null;
73
+ const body = parts.body.trim() || 'No command template body was found.';
74
+ return `---\nname: ${JSON.stringify(skillName)}\ndescription: ${JSON.stringify(parts.description)}\n---\n\n# ${skillName}\n\n`
75
+ + `Use this skill when the user asks to run the migrated source command \`${sourceName}\`.\n\n## Command Template\n\n${body}\n`;
76
+ }
77
+
78
+ /**
79
+ * ONE pass over the installed plugin: each file's bytes are checked against the verified release (or, for Codex's own
80
+ * generated skills, against the template rendered from the verified command file) and the SAME bytes feed the content
81
+ * fingerprint returned. Throws on any changed, extra or missing file, link, or generated file that is not exactly
82
+ * Codex's rendering.
83
+ */
84
+ export function verifiedPluginContentHash(root, pluginFiles, { platform = process.platform } = {}) {
85
+ const seen = new Set(); const commands = new Map(); const generated = new Map();
86
+ const genPrefix = `.codex-plugin/${CODEX_GENERATED}/`;
87
+ const hash = codexPluginContentHash(root, { platform, onFile: (rel, bytes) => {
88
+ if (rel.startsWith(genPrefix)) { generated.set(rel.slice(genPrefix.length), bytes); return; }
89
+ if (pluginFiles.get(rel) !== digest(bytes)) throw new Error(`Installed plugin file differs from the verified release: ${rel}`);
90
+ seen.add(rel);
91
+ if (/^commands\/[^/]+\.md$/.test(rel)) commands.set(rel.slice('commands/'.length, -3), bytes.toString('utf8'));
92
+ } });
93
+ const missing = [...pluginFiles.keys()].filter((k) => !seen.has(k));
94
+ if (missing.length) throw new Error(`Installed plugin lacks ${missing.length} released file(s), e.g. ${missing[0]}`);
95
+ for (const [rel, bytes] of generated) {
96
+ const m = /^source-command-([a-z0-9-]+)\/SKILL\.md$/.exec(rel);
97
+ const source = m && commands.get(m[1]);
98
+ const expected = source && expectedGeneratedSkill(`source-command-${m[1]}`, m[1], source);
99
+ if (!expected || bytes.toString('utf8') !== expected) throw new Error(`Codex-generated file is not the rendering of a verified release command: ${rel}`);
100
+ }
101
+ return hash;
102
+ }
103
+ /** Kept for callers that only need the yes/no: the installed plugin is the verified release. */
104
+ export function installedPluginMatchesRelease(root, pluginFiles) { verifiedPluginContentHash(root, pluginFiles); return true; }
9
105
 
10
106
  /** Authenticate exact published package bytes before reading one archive member. No extraction/install. */
11
107
  export async function obtainVerifiedPublishedHookIdentity({ version, fetch: fetcher = globalThis.fetch,
@@ -43,7 +139,9 @@ export async function obtainVerifiedPublishedHookIdentity({ version, fetch: fetc
43
139
  { input: archive, timeout: Math.min(remaining, 3000), maxBuffer: 256 * 1024 });
44
140
  if (result.error || result.status !== 0 || !result.stdout?.length) throw new Error('Platform tar cannot read exact published hooks entry');
45
141
  if (!codexHookIdentities(JSON.parse(result.stdout)).size) throw new Error('Published native hooks are invalid');
46
- return { state: 'verified', releaseIdentity: { version, integrity, hooksSha256: digest(result.stdout) },
142
+ let pluginFiles = null;
143
+ try { pluginFiles = publishedPluginFiles(archive); } catch { pluginFiles = null; /* content trust is then refused, never guessed */ }
144
+ return { state: 'verified', releaseIdentity: { version, integrity, hooksSha256: digest(result.stdout), pluginFiles },
47
145
  metadataUrl, tarballUrl, archiveBytes: archive.length };
48
146
  } catch (error) { return blocked(error.message); }
49
147
  }
@@ -142,9 +240,14 @@ const rowsFor = (response) => {
142
240
  if (!Array.isArray(response?.data)) throw new Error('Native hook registry unavailable');
143
241
  return response.data.flatMap((r) => r.hooks || []).filter((r) => r.pluginId === OWNER);
144
242
  };
145
- function assertRegistry(rows, identities) {
243
+ // Codex <0.164: currentHash is the declaration hash. 0.164+: "<declaration hash>/<plugin content hash>" (discovery.rs).
244
+ const splitHash = (current) => { const [declaration, content = null] = String(current || '').split('/'); return { declaration, content }; };
245
+ function assertRegistry(rows, identities, contentHash = null) {
146
246
  if (rows.length !== identities.size || new Set(rows.map((r) => r.key)).size !== identities.size
147
- || rows.some((row) => identities.get(row.key) !== row.currentHash)) throw new Error('Native registry differs from verified released artifact');
247
+ || rows.some((row) => identities.get(row.key) !== splitHash(row.currentHash).declaration)) throw new Error('Native registry differs from verified released artifact');
248
+ if (rows.some((row) => splitHash(row.currentHash).content !== null && splitHash(row.currentHash).content !== contentHash)) {
249
+ throw new Error('Native plugin content fingerprint differs from the verified installed plugin');
250
+ }
148
251
  }
149
252
 
150
253
  function otherSettings(config, keys) {
@@ -153,6 +256,7 @@ function otherSettings(config, keys) {
153
256
  const state = clone?.hooks?.state?.[key];
154
257
  if (state && typeof state === 'object') {
155
258
  delete state.trusted_hash;
259
+ delete state.trusted_content_hash;
156
260
  if (!Object.keys(state).length) delete clone.hooks.state[key];
157
261
  }
158
262
  }
@@ -170,7 +274,7 @@ function otherSettings(config, keys) {
170
274
  export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, installedVersion, hooksPath,
171
275
  configPath, nativeBinary, cwd = process.cwd(), backupDir = path.join(path.dirname(configPath || ''), 'backups'),
172
276
  rpc: suppliedRpc, timeoutMs = 10000 } = {}) {
173
- let client; let wrote = false; let writeAttempted = false; let backupPath;
277
+ let client; let wrote = false; let writeAttempted = false; let backupPath; let contentRefusal = 'Verified release plugin files unavailable';
174
278
  try {
175
279
  if (!releaseIdentity || !/^\d+\.\d+\.\d+$/.test(releaseIdentity.version || '')
176
280
  || installedVersion !== releaseIdentity.version || !/^[a-f0-9]{64}$/.test(releaseIdentity.hooksSha256 || '')
@@ -181,9 +285,19 @@ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, install
181
285
  if (digest(bytes) !== releaseIdentity.hooksSha256) return blocked('Installed hooks differ from verified release');
182
286
  const identities = codexHookIdentities(JSON.parse(bytes));
183
287
  if (!identities.size) return blocked('Verified artifact has no native hooks');
288
+ // Codex 0.164+ also pins the installed plugin folder. Approve that fingerprint only for a folder that holds exactly
289
+ // the verified release's plugin/ bytes (Codex's own generated migrations aside); otherwise refuse, never guess.
290
+ const pluginRoot = path.dirname(path.dirname(hooksPath));
291
+ let contentHash = null;
292
+ if (releaseIdentity.pluginFiles instanceof Map) {
293
+ try { contentHash = verifiedPluginContentHash(pluginRoot, releaseIdentity.pluginFiles); }
294
+ catch (error) { contentHash = null; contentRefusal = error.message; }
295
+ }
184
296
  if (!suppliedRpc) client = await nativeClient(nativeBinary, cwd, Date.now() + Math.min(timeoutMs, 20000), configPath);
185
297
  const rpc = suppliedRpc || client.rpc;
186
- const before = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(before, identities);
298
+ const before = rowsFor(await rpc('hooks/list', { cwds: [cwd] }));
299
+ if (!contentHash && before.some((row) => splitHash(row.currentHash).content !== null)) return blocked(contentRefusal);
300
+ assertRegistry(before, identities, contentHash);
187
301
  const disabled = before.filter((r) => r.enabled === false).map((r) => r.key).sort();
188
302
  if (before.some((r) => !['trusted', 'modified', 'untrusted', 'managed'].includes(r.trustStatus))) return blocked('Unknown native trust state');
189
303
  const pending = before.filter((r) => r.enabled === true && ['modified', 'untrusted'].includes(r.trustStatus));
@@ -194,8 +308,11 @@ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, install
194
308
  // Config stays memory-only except the private backup. No logging of effective config values.
195
309
  const prior = fs.readFileSync(configPath);
196
310
  backupPath = privateBackup(configPath, prior, backupDir);
197
- const edits = pending.map((r) => ({ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_hash`,
198
- value: identities.get(r.key), mergeStrategy: 'replace' }));
311
+ const edits = pending.flatMap((r) => [{ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_hash`,
312
+ value: identities.get(r.key), mergeStrategy: 'replace' },
313
+ // The second approval Codex 0.164 stores separately (plugin_trust.rs matches_hash).
314
+ ...(splitHash(r.currentHash).content !== null ? [{ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_content_hash`,
315
+ value: contentHash, mergeStrategy: 'replace' }] : [])]);
199
316
  writeAttempted = true;
200
317
  await rpc('config/batchWrite', { filePath: configPath, expectedVersion: users[0].version, reloadUserConfig: true, edits });
201
318
  wrote = true;
@@ -203,7 +320,7 @@ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, install
203
320
  const afterUsers = (afterRead.layers || []).filter((r) => r.name?.type === 'user' && r.name.file === configPath);
204
321
  if (afterUsers.length !== 1 || otherSettings(users[0].config, pending.map((r) => r.key))
205
322
  !== otherSettings(afterUsers[0].config, pending.map((r) => r.key))) throw new Error('Unrelated config changed; no whole-file rollback attempted');
206
- const after = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(after, identities);
323
+ const after = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(after, identities, contentHash);
207
324
  if (JSON.stringify(after.filter((r) => r.enabled === false).map((r) => r.key).sort()) !== JSON.stringify(disabled)
208
325
  || after.some((r) => r.enabled === true && !['trusted', 'managed'].includes(r.trustStatus))) throw new Error('Native trust repair verification incomplete');
209
326
  return { state: 'registry-verified', changed: true, editedTrustHashes: edits.length,
@@ -23,6 +23,7 @@
23
23
  // that one hook `modified`, the other ten still `trusted`. currentHash from `hooks/list` equals
24
24
  // codexHookHash() below for every hook (tests/unit/codex-hook-trust.test.mjs pins the real values).
25
25
  import crypto from 'node:crypto';
26
+ import nodeFs from 'node:fs';
26
27
 
27
28
  const EVENT_LABEL = Object.freeze({
28
29
  PreToolUse: 'pre_tool_use', PermissionRequest: 'permission_request', PostToolUse: 'post_tool_use',
@@ -92,3 +93,47 @@ export function codexTrustChanges(prevHooksFile, nextHooksFile, keySource) {
92
93
  export const CODEX_TRUST_ACTION = 'Start Codex (interactive, in any project). At the "Hooks need review" prompt choose'
93
94
  + ' "Trust all and continue" — or type /hooks and trust each RuvNet Brain hook. Until then Codex skips them'
94
95
  + ' (codex exec never asks). Then re-run npx ruvnet-brain --doctor.';
96
+
97
+ /**
98
+ * Codex's plugin bundle fingerprint, ported byte for byte from openai/codex rust-v0.164.0
99
+ * codex-rs/hooks/src/engine/plugin_fingerprint.rs plugin_content_hash(). Since 0.164 a plugin hook's
100
+ * `currentHash` is "<declaration hash>/<this hash>" and its approval stores the two separately
101
+ * (`trusted_hash` + `trusted_content_hash`, plugin_trust.rs matches_hash). Measured 2026-10-24: every Brain update
102
+ * left all 13 Codex hooks "modified" because the Brain wrote only the declaration hash.
103
+ * Walk: depth-first, entries sorted by file name (bytes), skipping __pycache__, .venv, *.pyc, *.egg-info; each entry
104
+ * hashes its root-relative path (u64 LE length + bytes), then "directory", or "file" + u64 LE size + contents.
105
+ * Anything that is neither (a symlink) throws, as it does in Codex.
106
+ */
107
+ export function codexPluginContentHash(root, { fsImpl = null, onFile = null, platform = process.platform } = {}) {
108
+ const fs = fsImpl || nodeFs;
109
+ const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; };
110
+ if (!fs.lstatSync(root).isDirectory()) throw new Error('plugin root is not a directory');
111
+ const hash = crypto.createHash('sha256').update('codex-plugin-bundle-v1');
112
+ // Rust hashes the OS-native relative path bytes: '\\' separators on Windows (review 2026-10-24).
113
+ const sep = platform === 'win32' ? '\\' : '/';
114
+ const pending = [''];
115
+ while (pending.length) {
116
+ const rel = pending.pop();
117
+ const base = rel.split('/').pop();
118
+ if (rel && (base === '__pycache__' || base === '.venv' || base.endsWith('.pyc') || base.endsWith('.egg-info'))) continue;
119
+ const name = Buffer.from(rel.split('/').join(sep));
120
+ hash.update(u64(name.length)).update(name);
121
+ const full = rel ? `${root}/${rel}` : root;
122
+ const st = fs.lstatSync(full);
123
+ if (st.isDirectory()) {
124
+ hash.update('directory');
125
+ const entries = fs.readdirSync(full).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
126
+ for (const entry of entries.reverse()) pending.push(rel ? `${rel}/${entry}` : entry);
127
+ } else if (st.isFile()) {
128
+ const bytes = fs.readFileSync(full);
129
+ if (bytes.length !== st.size) throw new Error('plugin file changed while fingerprinting');
130
+ // The caller sees exactly the bytes that enter the fingerprint: one read, so nothing can change between a check
131
+ // and the hash it approves (review 2026-10-24: a separate verification pass was a swap window).
132
+ onFile?.(rel, bytes);
133
+ hash.update('file').update(u64(st.size)).update(bytes);
134
+ } else {
135
+ throw new Error(`unsupported plugin entry type: ${full}`);
136
+ }
137
+ }
138
+ return `sha256:${hash.digest('hex')}`;
139
+ }
@@ -21,7 +21,7 @@
21
21
 
22
22
  //
23
23
  // THE RETRY SLOT (2026-10-20). Measured on 38 nights: 24 failed, most on one transient blip, and nothing
24
- // retried until the next calendar day. The dispatcher now also fires at RETRY_CRON (11:17 UTC, four hours
24
+ // retried until the next calendar day. The dispatcher now also fires at RETRY_CRON (09:17 UTC since 2026-10-24, four hours
25
25
  // after the night's first slot) and asks decideRetry(): re-dispatch ONLY when every corpus run of the
26
26
  // night so far ended `failed` (or reached no outcome), none is still running, none ended published /
27
27
  // no-change / superseded / canary-rejected (those are answers, not weather), and fewer than
@@ -37,9 +37,10 @@ import { pathToFileURL } from 'node:url';
37
37
 
38
38
  export const MIN_PIPELINE_VERSION = '4.3.38'; // sync-version-ignore: a fixed pipeline floor, not the current version
39
39
  /** The dispatcher's second schedule entry; corpus-nightly-dispatch.yml's `on.schedule` must carry exactly this line. */
40
- export const RETRY_CRON = '17 11 * * *';
41
- /** How far back the retry slot looks for the night's runs: past the 07:17 UTC first slot, never into yesterday. */
42
- export const RETRY_WINDOW_MS = 10 * 3_600_000;
40
+ export const RETRY_CRON = '17 9 * * *';
41
+ /** How far back the retry slot looks for the night's runs: past the 05:17 UTC first slot (with room for GitHub's late
42
+ * schedule starts), never into yesterday's slot or an evening manual dispatch. */
43
+ export const RETRY_WINDOW_MS = 8 * 3_600_000;
43
44
  export const MAX_CORPUS_RUNS_PER_NIGHT = 2;
44
45
  // `unknown` (every job green, no outcome) is retried only when the run itself did NOT conclude success: a green
45
46
  // run that reached no outcome is a workflow defect the terminal-outcome job already fails loudly, not weather.
@@ -48,7 +48,7 @@ const HOME = os.homedir();
48
48
  // install whose npm prefix is not the owner's (Homebrew, nvm, Volta, plain `npm -g`) was told its
49
49
  // tool was missing while ruflo sat on their PATH. One resolver, shared — see ruflo-bin.mjs.
50
50
  const RUFLO = resolveRuflo();
51
- const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
51
+ const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' };
52
52
 
53
53
  const argv = process.argv.slice(2);
54
54
  const arg = (f, d) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : d; };
@@ -179,7 +179,7 @@ export const CHECKS = [
179
179
  const rufloBin = resolveRuflo();
180
180
  const r = rufloBin
181
181
  ? spawnSync(rufloBin, ['memory', 'search', '-q', 'nightly job supervision heartbeat receipts'], {
182
- cwd: ROOT, encoding: 'utf8', timeout: 120_000, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
182
+ cwd: ROOT, encoding: 'utf8', timeout: 120_000, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' },
183
183
  })
184
184
  : { error: new Error(RUFLO_MISSING), stdout: '' };
185
185
  const recalled = /Found\s+\d+\s+results/.test(r.stdout || '') && !/Found\s+0\s+results/.test(r.stdout || '');
@@ -54,7 +54,7 @@ const PROJECT = learningContext({ cwd: requestedProject || process.env.RUVNET_BR
54
54
  * that one global binary is rather than assuming a path.
55
55
  */
56
56
  const RUFLO = resolveRuflo();
57
- const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
57
+ const RUFLO_ENV = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' };
58
58
 
59
59
  const sqlite = (db, sql) => execFileSync('sqlite3', [db, sql], { encoding: 'utf8', timeout: 120_000 }).trim();
60
60
 
@@ -32,7 +32,7 @@ const MUTATING_SUBCOMMANDS = new Set([
32
32
  // node_modules/@claude-flow/cli/dist/src/services/daemon-autostart.js:85). Applied here, once, so
33
33
  // every caller of spawnRuflo() gets it regardless of whether it passed its own `env`.
34
34
  function spawnRuflo(bin, args, options = {}) {
35
- const opts = { ...options, env: { ...(options.env || process.env), RUFLO_DAEMON_AUTOSTART: '0' } };
35
+ const opts = { ...options, env: { ...(options.env || process.env), RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' } };
36
36
  return /\.[cm]?js$/i.test(bin)
37
37
  ? spawnSync(process.execPath, [bin, ...args], opts)
38
38
  : spawnSync(bin, args, opts);
@@ -120,7 +120,7 @@ export function executeProducedCommand(cmd, {
120
120
  .find((value) => MUTATING_SUBCOMMANDS.has(value));
121
121
  if (mutating) return reject(`refused mutating subcommand "${mutating}"`, { argv: ['ruflo', ...args] });
122
122
 
123
- const env = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' };
123
+ const env = { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' };
124
124
  delete env.CLAUDE_FLOW_DB_PATH;
125
125
  delete env.CLAUDE_FLOW_MEMORY_PATH;
126
126
  const result = spawnRuflo(ruflo, args, {
@@ -151,7 +151,7 @@ export function verifyRufloFlag(bin = RUFLO_BIN) {
151
151
  const result = spawnSync(bin, ['memory', 'search', '--help'], {
152
152
  encoding: 'utf8',
153
153
  timeout: 30_000,
154
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
154
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' },
155
155
  });
156
156
  const output = `${result.stdout || ''}${result.stderr || ''}`;
157
157
  if (result.status !== 0 && !output) return { ok: false, why: `help exited ${result.status} empty` };
@@ -32,6 +32,9 @@ const sh = (cmd, args, options = {}) => {
32
32
  encoding: 'utf8',
33
33
  timeout: 120_000,
34
34
  ...options,
35
+ // A ruflo started here would auto-start a daemon and run its in-cwd self-update (`npm install @claude-flow/cli`,
36
+ // 1.3 GB measured 2026-10-22) in the fixture project; a caller's own env still wins.
37
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false', ...(options.env || {}) },
35
38
  });
36
39
  };
37
40
  const remove = (target) => {
@@ -96,7 +96,7 @@ function rufloJson(args) {
96
96
  const tmp = path.join(SCRATCH_DIR, `call-${callCounter++}.json`);
97
97
  const fd = fs.openSync(tmp, 'w');
98
98
  try {
99
- execFileSync(RUFLO, args, { stdio: ['ignore', fd, 'ignore'], timeout: CLI_TIMEOUT_MS, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
99
+ execFileSync(RUFLO, args, { stdio: ['ignore', fd, 'ignore'], timeout: CLI_TIMEOUT_MS, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' } });
100
100
  } catch (e) {
101
101
  if (e.signal || e.code === 'ETIMEDOUT') {
102
102
  throw new Error(`ruflo ${args.join(' ')} did not finish within ${CLI_TIMEOUT_MS}ms (killed with ${e.signal || e.code}) — likely lock contention from a concurrent writer on this shared machine. Re-run.`);
@@ -150,10 +150,10 @@ fi
150
150
  # Memory distillation (ADR-174) had gone stale 3 days — same silent-death pattern as everything
151
151
  # else tonight. Independent of rebuild success/failure: mine raw memory_entries into structured
152
152
  # episodes/reasoning_patterns on every nightly run. Best-effort, never blocks the real job.
153
- RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory distill run --path "$RUVNET_PROJECT_MEMORY_DB" >> "$LOG" 2>&1 || true
153
+ RUFLO_DAEMON_AUTOSTART=0 CLAUDE_FLOW_AUTO_UPDATE=false ~/.npm-global/bin/ruflo memory distill run --path "$RUVNET_PROJECT_MEMORY_DB" >> "$LOG" 2>&1 || true
154
154
  # Durability: was a one-off manual snapshot before tonight. Now recurring — WAL-safe, rotates
155
155
  # automatically (keeps last 14), zero risk to the live DB (reads only).
156
- RUFLO_DAEMON_AUTOSTART=0 ~/.npm-global/bin/ruflo memory backup --db "$RUVNET_PROJECT_MEMORY_DB" --keep 14 >> "$LOG" 2>&1 || true
156
+ RUFLO_DAEMON_AUTOSTART=0 CLAUDE_FLOW_AUTO_UPDATE=false ~/.npm-global/bin/ruflo memory backup --db "$RUVNET_PROJECT_MEMORY_DB" --keep 14 >> "$LOG" 2>&1 || true
157
157
 
158
158
  # LESSON BRIDGE (ADR-066, 2026-08-10) — carry newly-tagged machine-wide lessons into the store the
159
159
  # hooks actually read. Without this the bridge is a command someone has to remember, and a learning
@@ -26,6 +26,7 @@ set -euo pipefail
26
26
  # daemon-autostart.js:85). This script only checks/starts the proxy — it has no business leaving a
27
27
  # swarm daemon running as a side effect.
28
28
  export RUFLO_DAEMON_AUTOSTART=0
29
+ export CLAUDE_FLOW_AUTO_UPDATE=false
29
30
 
30
31
  TOKEN_FILE="$HOME/.ruflo/proxy-token"
31
32
 
@@ -13,6 +13,7 @@ set -uo pipefail
13
13
  # live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/services/
14
14
  # daemon-autostart.js:85). A teardown script has no business leaving one running.
15
15
  export RUFLO_DAEMON_AUTOSTART=0
16
+ export CLAUDE_FLOW_AUTO_UPDATE=false
16
17
 
17
18
  echo "Reverting the Meta LLM Proxy trial..."
18
19
  echo
@@ -11,6 +11,7 @@ set -uo pipefail
11
11
  # daemon-autostart.js:85). Installing/starting the proxy has no business leaving a swarm daemon
12
12
  # running as a side effect.
13
13
  export RUFLO_DAEMON_AUTOSTART=0
14
+ export CLAUDE_FLOW_AUTO_UPDATE=false
14
15
 
15
16
  REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
16
17
 
@@ -60,7 +60,7 @@ try {
60
60
  // Every `ruflo` invocation auto-starts a project background daemon unless this is set
61
61
  // (verified live: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/
62
62
  // services/daemon-autostart.js:85) — a health-check probe must not leave one running.
63
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
63
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' },
64
64
  });
65
65
  doctorOk = /Summary:.*passed/.test(doctorOut) && !/\b\d+ failed/.test(doctorOut);
66
66
  } catch (err) {
@@ -71,7 +71,7 @@ const ruflo = (args) => {
71
71
  const invocation = rufloInvocation(RUFLO, args);
72
72
  return execFileSync(invocation.executable, invocation.args, {
73
73
  cwd: dir, encoding: 'utf8', timeout: 60000, shell: false,
74
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
74
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' },
75
75
  });
76
76
  };
77
77
 
@@ -613,7 +613,7 @@ export function runSecurityScan({ cwd = process.cwd(), timeoutMs = 20000, env =
613
613
  // Every `ruflo` invocation auto-starts a project background daemon unless this is set (verified
614
614
  // live against the installed CLI: ~/.npm-global/lib/node_modules/ruflo/node_modules/@claude-flow/cli/dist/src/services/daemon-autostart.js:85).
615
615
  // A read-only version probe + two scan subcommands have no business leaving a daemon running.
616
- env = { ...env, RUFLO_DAEMON_AUTOSTART: '0' };
616
+ env = { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' };
617
617
  const probe = spawnSync('ruflo', ['--version'], { encoding: 'utf8', timeout: 5000, env });
618
618
  if (probe.error || probe.status !== 0) {
619
619
  return { available: false, reason: 'ruflo not found on PATH — MCP surface not scanned (install: npm i -g ruflo)' };
@@ -240,7 +240,7 @@ const checks = [
240
240
  run: () => { const s = readAbs(path.join(HOME, '.claude/settings.json')); return { ok: !/agentdb-autocapture/.test(s), detail: /agentdb-autocapture/.test(s) ? 'global agentdb-autocapture still registered alongside the plugin session-snapshot' : 'single writer' }; } },
241
241
 
242
242
  { id: 'E3', area: 'hooks', scope: 'machine', title: 'Lessons live in one store (no .swarm lesson-* rows outside the plugin lesson store)',
243
- run: () => { const r = spawnSync('ruflo', ['memory', 'list', '--path', path.join(HOME, 'Code/ruvnet-brain/.swarm/memory.db'), '--limit', '500'], { encoding: 'utf8', timeout: 60000, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
243
+ run: () => { const r = spawnSync('ruflo', ['memory', 'list', '--path', path.join(HOME, 'Code/ruvnet-brain/.swarm/memory.db'), '--limit', '500'], { encoding: 'utf8', timeout: 60000, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' } });
244
244
  const n = (r.stdout.match(/lesson-/g) || []).length; return { ok: n === 0, detail: `${n} lesson-* rows in project .swarm (plugin store: ~/.config/ruvnet-brain/lessons.json)` }; } },
245
245
  { id: 'E4', area: 'hooks', scope: 'machine', title: 'One session-start continuity restorer (global hook stands down when the plugin is enabled)',
246
246
  run: () => { const s = readAbs(path.join(HOME, '.claude/hooks/agentdb-ensure.sh')); return { ok: s.includes('SINGLE CONTINUITY OWNER'), detail: 'agentdb-ensure.sh must stand down its project-state recall' }; } },
@@ -37,7 +37,7 @@ import fs from 'node:fs';
37
37
  import os from 'node:os';
38
38
  import path from 'node:path';
39
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';
40
+ import { FAMILY, PLUGIN_MARKETPLACES, cmpVersion, pickTargetTag, normalizeNpmDistTags, normalizeNpmTimes, needsPublishTimes, wantedTag } from '../plugin/scripts/developer-update-policy.mjs';
41
41
  export { FAMILY, PLUGIN_MARKETPLACES, cmpVersion, pickTargetTag, normalizeNpmDistTags } from '../plugin/scripts/developer-update-policy.mjs';
42
42
 
43
43
  const HOME = os.homedir();
@@ -193,6 +193,13 @@ function registryTags(pkg) {
193
193
  try { return normalizeNpmDistTags(JSON.parse(r.stdout)); } catch { return null; }
194
194
  }
195
195
 
196
+ // Publish times, so an abandoned pre-release tag is not reported as "behind" (same rule as the updater).
197
+ function registryTimes(pkg) {
198
+ const r = spawnSync('npm', ['view', pkg, 'time', '--json'], { encoding: 'utf8', timeout: 30000 });
199
+ if (r.status !== 0 || !r.stdout) return null;
200
+ try { return normalizeNpmTimes(JSON.parse(r.stdout)); } catch { return null; }
201
+ }
202
+
196
203
  // A second copy of a stack package in the npx cache can only ever SHADOW the global one.
197
204
  // It is never useful. This is how "two ruflos" happens.
198
205
  export function findShadows(npxCache = NPX_CACHE, lib = GLOBAL_LIB) {
@@ -228,11 +235,12 @@ export function classify(pkgs, { channel = readDeveloperUpdateConfig().channel }
228
235
  if (p.source === 'plugin') {
229
236
  return { ...p, tag: 'plugin', target: null, state: p.installed ? 'INSTALLED_UNVERIFIED' : 'BROKEN', evidence: 'local install record only; marketplace revision not checked' };
230
237
  }
231
- const want = p.name === '@pacphi/agentic-kit' ? 'next' : channel === 'alpha' && FAMILY.test(p.name) ? 'alpha' : DEFAULT_TAG;
238
+ const want = wantedTag(p.name, channel);
232
239
  const tags = registryTags(p.name);
233
240
  // Newest of {policy tag, latest} — see pickTargetTag: "track @alpha" alone pinned the core behind
234
241
  // @latest on 2026-07-18. A package with no alpha tag falls through to latest.
235
- const { tag, target } = pickTargetTag(tags, want);
242
+ const published = needsPublishTimes(tags, want) ? (registryTimes(p.name) || {}) : null;
243
+ const { tag, target } = pickTargetTag(tags, want, 'latest', { published });
236
244
  let state;
237
245
  if (!p.installed) state = 'BROKEN';
238
246
  else if (!target) state = 'UNRESOLVED';