ruvnet-brain 4.7.17 → 4.7.18

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 (34) 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/scripts/agentdb-recall-process.mjs +1 -1
  7. package/plugin/scripts/agentdb-recall-reader.mjs +1 -1
  8. package/plugin/scripts/brain-confirmation.mjs +8 -1
  9. package/plugin/scripts/brain-footprint.mjs +3 -1
  10. package/plugin/scripts/decision-record.mjs +1 -1
  11. package/plugin/scripts/degradation-watch.mjs +13 -1
  12. package/plugin/scripts/developer-update-policy.mjs +31 -5
  13. package/plugin/scripts/developer-update.mjs +17 -5
  14. package/plugin/scripts/learning-store.mjs +1 -1
  15. package/plugin/scripts/ruflo-bin.mjs +5 -1
  16. package/plugin/scripts/ruflo-memory-hooks.mjs +1 -1
  17. package/plugin/scripts/turn-outcome-capture.mjs +1 -1
  18. package/scripts/agentdb-fleet-doctor.mjs +1 -1
  19. package/scripts/corpus-dispatch-decision.mjs +5 -4
  20. package/scripts/distill-project.mjs +1 -1
  21. package/scripts/falsify.mjs +1 -1
  22. package/scripts/health-repair.mjs +1 -1
  23. package/scripts/learning-replay-execution.mjs +3 -3
  24. package/scripts/learning-replay-fixture.mjs +3 -0
  25. package/scripts/lesson-migrate-agentdb.mjs +1 -1
  26. package/scripts/nightly-wrapper.sh +2 -2
  27. package/scripts/proxy/claude-proxied.sh +1 -0
  28. package/scripts/proxy/proxy-revert.sh +1 -0
  29. package/scripts/proxy/proxy-up.sh +1 -0
  30. package/scripts/proxy/proxy-verify.mjs +1 -1
  31. package/scripts/record-lesson.mjs +1 -1
  32. package/scripts/selfcheck.mjs +1 -1
  33. package/scripts/single-source-check.mjs +1 -1
  34. 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.18-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.18",
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.18",
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.18",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -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
  }
@@ -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
 
@@ -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';