ruvnet-brain 4.7.20 → 4.7.22

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 (260) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +618 -104
  3. package/console/activity.js +122 -38
  4. package/kb/download-retry.mjs +93 -21
  5. package/kb/forge-guard-injection.mjs +277 -0
  6. package/kb/forge-update.mjs +166 -38
  7. package/kb/lifecycle-evidence-retention.mjs +151 -14
  8. package/kb/model-requirements.mjs +4 -1
  9. package/kb/refresh-run.mjs +175 -27
  10. package/kb/verify-citation.mjs +15 -2
  11. package/package.json +3 -1
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  15. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  16. package/plugin/scripts/advisory-contract.mjs +21 -0
  17. package/plugin/scripts/advocacy-route.mjs +5 -4
  18. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  19. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  20. package/plugin/scripts/agentdb-recall.mjs +49 -17
  21. package/plugin/scripts/automatic-update.mjs +9 -1
  22. package/plugin/scripts/brain-confirmation.mjs +2 -1
  23. package/plugin/scripts/brain-footprint.mjs +72 -11
  24. package/plugin/scripts/capability-registry.mjs +1 -1
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  26. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  27. package/plugin/scripts/completion-claim-evidence.mjs +241 -23
  28. package/plugin/scripts/continuation-gate.mjs +75 -11
  29. package/plugin/scripts/decision-gate.mjs +13 -63
  30. package/plugin/scripts/decision-outcomes.mjs +36 -0
  31. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  32. package/plugin/scripts/decision-record.mjs +8 -3
  33. package/plugin/scripts/degradation-watch.mjs +2 -1
  34. package/plugin/scripts/developer-update-lock.mjs +172 -33
  35. package/plugin/scripts/developer-update-owner.mjs +8 -5
  36. package/plugin/scripts/developer-update-policy.mjs +4 -1
  37. package/plugin/scripts/developer-update.mjs +155 -53
  38. package/plugin/scripts/doc-currency.mjs +2 -16
  39. package/plugin/scripts/duplicate-gate.mjs +2 -1
  40. package/plugin/scripts/entrypoint.mjs +34 -0
  41. package/plugin/scripts/footprint-io.mjs +135 -8
  42. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  43. package/plugin/scripts/ground-ruvnet.sh +6 -3
  44. package/plugin/scripts/grounding-answer.mjs +2 -1
  45. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  46. package/plugin/scripts/grounding-substance.mjs +2 -1
  47. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  48. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  49. package/plugin/scripts/hook-context-budget.mjs +2 -1
  50. package/plugin/scripts/hook-input.mjs +2 -2
  51. package/plugin/scripts/hook-shim.mjs +9 -2
  52. package/plugin/scripts/host-update.mjs +29 -5
  53. package/plugin/scripts/identifier-preflight.mjs +2 -1
  54. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  55. package/plugin/scripts/learn-capture.mjs +2 -1
  56. package/plugin/scripts/learning-store.mjs +62 -16
  57. package/plugin/scripts/lesson-bridge.mjs +2 -2
  58. package/plugin/scripts/lesson-gate.mjs +7 -39
  59. package/plugin/scripts/lesson-promote.mjs +83 -22
  60. package/plugin/scripts/lesson-scope.mjs +51 -0
  61. package/plugin/scripts/lesson-store.mjs +1 -1
  62. package/plugin/scripts/md-stamp.mjs +2 -1
  63. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  64. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  65. package/plugin/scripts/package-recommender.mjs +2 -2
  66. package/plugin/scripts/project-store-resolver.mjs +19 -0
  67. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  68. package/plugin/scripts/ruflo-bin.mjs +14 -6
  69. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  70. package/plugin/scripts/session-snapshot-hook.mjs +25 -5
  71. package/plugin/scripts/session-start-core.mjs +7 -16
  72. package/plugin/scripts/session-start-health.mjs +98 -10
  73. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  74. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  75. package/plugin/scripts/signal-watch.mjs +2 -1
  76. package/plugin/scripts/spend-guard.mjs +2 -1
  77. package/plugin/scripts/substitution-wall.mjs +2 -1
  78. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  79. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  80. package/plugin/scripts/update-apply.mjs +186 -15
  81. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  82. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  83. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  84. package/scripts/adr-072-completion.mjs +2 -1
  85. package/scripts/agentdb-context.mjs +2 -1
  86. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  87. package/scripts/approved-runtime.mjs +2 -1
  88. package/scripts/architecture-review-lock.mjs +2 -1
  89. package/scripts/ascii-drift.mjs +2 -1
  90. package/scripts/brain-latency-50.mjs +2 -1
  91. package/scripts/brain-novice-50.mjs +2 -16
  92. package/scripts/brain-score.mjs +13 -2
  93. package/scripts/build-bundle.mjs +2 -1
  94. package/scripts/build-concepts.mjs +2 -1
  95. package/scripts/candidate-ci-receipt.mjs +2 -1
  96. package/scripts/candidate-host-evidence.mjs +2 -1
  97. package/scripts/card-from-source.mjs +2 -1
  98. package/scripts/ci/warm-brain-models.mjs +2 -1
  99. package/scripts/claims-verify.mjs +7 -3
  100. package/scripts/claude-controlled-terminal.mjs +2 -1
  101. package/scripts/claude-terminal-mod.mjs +2 -1
  102. package/scripts/code-release-corpus.mjs +2 -1
  103. package/scripts/codex-boot-surface-proof.mjs +208 -0
  104. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  105. package/scripts/codex-hook-trust.mjs +40 -1
  106. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  107. package/scripts/codex-host-execution-proof.mjs +2 -1
  108. package/scripts/completion-claim-replay.mjs +2 -1
  109. package/scripts/console-engine.mjs +8 -3
  110. package/scripts/corpus-canary.mjs +2 -1
  111. package/scripts/corpus-candidate.mjs +2 -2
  112. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  113. package/scripts/corpus-dispatch-decision.mjs +2 -1
  114. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  115. package/scripts/corpus-next-seed.mjs +2 -1
  116. package/scripts/corpus-qa.mjs +2 -1
  117. package/scripts/corpus-reconcile.mjs +2 -2
  118. package/scripts/corpus-watchdog.mjs +2 -1
  119. package/scripts/count-chunks.mjs +2 -1
  120. package/scripts/customer-state-matrix.mjs +2 -1
  121. package/scripts/derive-passage-content-map.mjs +2 -1
  122. package/scripts/development-push-check.mjs +2 -1
  123. package/scripts/dispatch-receipt.mjs +2 -1
  124. package/scripts/doc-currency.mjs +2 -1
  125. package/scripts/dream-issue-gate.mjs +2 -1
  126. package/scripts/dual-host-deliberation.mjs +2 -16
  127. package/scripts/dual-host-suggest.mjs +2 -16
  128. package/scripts/eval-brain.mjs +7 -3
  129. package/scripts/execution-policy.mjs +2 -1
  130. package/scripts/execution-preflight.mjs +2 -1
  131. package/scripts/falsify.mjs +2 -1
  132. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  133. package/scripts/fix-workstream.mjs +2 -1
  134. package/scripts/full-hints.mjs +2 -1
  135. package/scripts/full-suite-gate.mjs +2 -1
  136. package/scripts/grok-host.mjs +68 -0
  137. package/scripts/hook-qualify-core.mjs +3 -1
  138. package/scripts/hook-qualify.mjs +2 -1
  139. package/scripts/hook-retirement-check.mjs +2 -1
  140. package/scripts/host-registry.mjs +2 -1
  141. package/scripts/independent-review-receipt.mjs +2 -1
  142. package/scripts/ingest-new-repos.mjs +2 -1
  143. package/scripts/ingest-repo.mjs +12 -2
  144. package/scripts/integration-evidence.mjs +2 -1
  145. package/scripts/issue-fix.mjs +7 -24
  146. package/scripts/issue-watch.mjs +116 -40
  147. package/scripts/job-heartbeat.sh +54 -19
  148. package/scripts/key-canary.mjs +9 -3
  149. package/scripts/latency-to-surface.mjs +2 -1
  150. package/scripts/learning-replay-cli.mjs +97 -19
  151. package/scripts/learning-replay-execution.mjs +2 -0
  152. package/scripts/learning-replay-fixture.mjs +3 -0
  153. package/scripts/learning-replay-proof.mjs +14 -13
  154. package/scripts/learning-replay.mjs +3 -3
  155. package/scripts/learnings.mjs +2 -1
  156. package/scripts/lesson-ratify.mjs +88 -40
  157. package/scripts/loop-checkpoint.mjs +2 -1
  158. package/scripts/metaharness-receipts.mjs +2 -1
  159. package/scripts/model-catalog.mjs +2 -1
  160. package/scripts/model-currency-evidence.mjs +6 -4
  161. package/scripts/model-currency.mjs +33 -3
  162. package/scripts/model-native-catalog.mjs +2 -1
  163. package/scripts/model-router-agent-hook.mjs +2 -1
  164. package/scripts/model-router-dispatch.mjs +2 -1
  165. package/scripts/model-router-engine.mjs +2 -1
  166. package/scripts/model-router-outcome.mjs +2 -1
  167. package/scripts/model-router-setup.mjs +2 -1
  168. package/scripts/model-routing-eval.mjs +2 -1
  169. package/scripts/model-routing-gateway.mjs +2 -1
  170. package/scripts/model-routing-launchers.mjs +4 -1
  171. package/scripts/model-terminal-gateway.mjs +2 -1
  172. package/scripts/model-terminal-launchers.mjs +2 -1
  173. package/scripts/model-weekly-analyst.mjs +2 -1
  174. package/scripts/model-weekly-cycle.mjs +2 -1
  175. package/scripts/model-weekly-qualification.mjs +2 -1
  176. package/scripts/nightly-gists.sh +26 -20
  177. package/scripts/nightly-two-run-proof.mjs +2 -1
  178. package/scripts/nightly-watchdog.mjs +158 -47
  179. package/scripts/no-silent-substitution.mjs +2 -1
  180. package/scripts/notify.sh +11 -6
  181. package/scripts/ntfy-inbox.mjs +41 -18
  182. package/scripts/onboarding-console.mjs +123 -23
  183. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  184. package/scripts/oracle/abstain-trace.mjs +2 -1
  185. package/scripts/oracle/doc2query-generate.mjs +2 -1
  186. package/scripts/oracle/doc2query-reach.mjs +2 -1
  187. package/scripts/oracle/judge-train.mjs +2 -1
  188. package/scripts/oracle/need-set-split.mjs +2 -1
  189. package/scripts/oracle/produce-questions.mjs +2 -16
  190. package/scripts/oracle/repo-recall.mjs +2 -2
  191. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  192. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  193. package/scripts/oracle/source-tree.mjs +2 -15
  194. package/scripts/oracle/source-units.mjs +2 -16
  195. package/scripts/oracle/spike-run.mjs +2 -16
  196. package/scripts/oracle/validate-labels.mjs +2 -16
  197. package/scripts/org-repo-count.mjs +2 -1
  198. package/scripts/package-cards.mjs +2 -1
  199. package/scripts/prepublication-evidence.mjs +2 -1
  200. package/scripts/private-overlay.mjs +2 -1
  201. package/scripts/product-integrity-contract.mjs +2 -1
  202. package/scripts/protected-release-invocation.mjs +2 -1
  203. package/scripts/public-verification-abandon.mjs +2 -1
  204. package/scripts/public-verification-aggregate.mjs +2 -1
  205. package/scripts/public-verification-finalizer.mjs +2 -1
  206. package/scripts/public-verification-inputs.mjs +2 -1
  207. package/scripts/public-verification-lane.mjs +2 -1
  208. package/scripts/publication-receipt.mjs +2 -1
  209. package/scripts/published-surface-probe.mjs +2 -1
  210. package/scripts/qe/card-lane-gate.mjs +2 -16
  211. package/scripts/qe/session-start-gate.mjs +2 -16
  212. package/scripts/qe/ux-suite.mjs +2 -1
  213. package/scripts/qualified-candidate-check.mjs +2 -1
  214. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  215. package/scripts/recall-latency-probe.mjs +48 -0
  216. package/scripts/recommendation-eval.mjs +2 -1
  217. package/scripts/recommendation-floor.mjs +2 -1
  218. package/scripts/recommendation-judge-score.mjs +2 -1
  219. package/scripts/recommendation-real-host-score.mjs +2 -1
  220. package/scripts/recommendation-real-host.mjs +2 -1
  221. package/scripts/refresh-capability-only-store.mjs +2 -1
  222. package/scripts/refresh-model-catalog.mjs +2 -1
  223. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  224. package/scripts/release-authority.mjs +2 -1
  225. package/scripts/release-evidence-aggregate.mjs +2 -1
  226. package/scripts/release-payload.mjs +2 -1
  227. package/scripts/release-projection.mjs +2 -1
  228. package/scripts/release-proof.mjs +2 -1
  229. package/scripts/release-qualification-contract.mjs +82 -28
  230. package/scripts/release-qualification.mjs +8 -1
  231. package/scripts/release-vector.mjs +2 -1
  232. package/scripts/restore-local-ingests.mjs +2 -1
  233. package/scripts/retrieval-canary.mjs +2 -1
  234. package/scripts/route-cheap.mjs +2 -1
  235. package/scripts/route-gold-rank.mjs +2 -1
  236. package/scripts/route-index-memory.mjs +2 -1
  237. package/scripts/route-latency-warm.mjs +2 -1
  238. package/scripts/router-utilization.mjs +2 -1
  239. package/scripts/routing-flywheel.mjs +2 -1
  240. package/scripts/run-operational-benchmark.mjs +2 -1
  241. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  242. package/scripts/rvf-index-audit.mjs +2 -16
  243. package/scripts/seal-gist-receipt.mjs +2 -1
  244. package/scripts/security-guidance-codex-compat.mjs +2 -1
  245. package/scripts/selfcheck.mjs +2 -1
  246. package/scripts/signal-watch.mjs +2 -1
  247. package/scripts/source-coverage.mjs +2 -1
  248. package/scripts/stabilization-receipt.mjs +2 -1
  249. package/scripts/stamp-sweep.mjs +2 -1
  250. package/scripts/status-honesty.mjs +2 -1
  251. package/scripts/sync-commands.mjs +2 -2
  252. package/scripts/top100-benchmark.mjs +2 -1
  253. package/scripts/top100-corpus.mjs +2 -1
  254. package/scripts/user-model-hook-install.mjs +2 -1
  255. package/scripts/user-model-prompt-hook.mjs +2 -1
  256. package/scripts/verify-bundle.mjs +3 -1
  257. package/scripts/verify-model-catalog.mjs +2 -1
  258. package/scripts/version.mjs +2 -1
  259. package/scripts/wired-check.mjs +13 -5
  260. package/scripts/worktree-integrity.mjs +2 -2
@@ -3,41 +3,180 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
+ import { spawnSync } from 'node:child_process';
6
7
  const brainRoot = brainHome => brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache/ruvnet-brain');
7
- export function sharedLockStatus({ brainHome, alive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } } } = {}) {
8
+ const pidAlive = pid => { try { process.kill(pid, 0); return true; } catch (e) { return e.code === 'EPERM'; } };
9
+ // A dead or reclaimable owner must have been gone this long; an unreadable owner is judged by the directory's age.
10
+ export const STALE_RECLAIM_MS = 10 * 60 * 1000;
11
+ // An owner-less lock younger than this is another process between creating the lock and writing its owner.
12
+ export const OWNERLESS_GRACE_MS = 5 * 1000;
13
+ /** The OS start time of `pid` (`ps -o lstart=`, as kb/refresh-run.mjs records it), or null when it cannot be read (Windows). */
14
+ export function processStartOf(pid, { platform = process.platform } = {}) {
15
+ if (platform === 'win32' || !Number.isSafeInteger(pid) || pid < 1) return null;
16
+ try {
17
+ const r = spawnSync('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', timeout: 5000, env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
18
+ const start = String(r.stdout || '').trim();
19
+ return r.status === 0 && start ? start : null;
20
+ } catch { return null; }
21
+ }
22
+ const removal = directory => process.platform === 'win32' ? `Remove-Item -Recurse -Force "${directory}"` : `rm -rf "${directory}"`;
23
+ // ── takeover is serialized (4.7.21 review, Codex #1) ─────────────────────────────────────────────
24
+ // Two contenders could judge the same abandoned lock: A moved it aside and acquired, then B moved A's FRESH lock
25
+ // aside on its stale judgement and both ran. A takeover now (1) creates an exclusive claim file inside the judged
26
+ // directory ('wx': one winner per directory instance), (2) re-checks, while holding the claim, that it is the same
27
+ // directory (dev/ino) with byte-identical owner.json, and only then (3) moves it aside. A claim left by a crashed
28
+ // reclaimer is superseded by the next numbered claim once CLAIM_STALE_MS old.
29
+ // Round 4 (Codex #1): a lock that existed before its owner record (mkdir, then write) could be judged owner-less and old,
30
+ // claimed, re-checked (still owner-less, so "the same") and moved aside together with the owner published in between:
31
+ // two owners. The lock is now built complete in a staging sibling and published by ONE rename, so it never exists
32
+ // without its owner; a rename onto an existing lock fails (ENOTEMPTY/EEXIST, EPERM/EACCES on Windows) and is re-judged.
33
+ export const CLAIM_STALE_MS = 5 * 60 * 1000;
34
+ const CLAIM_FILE = /^takeover(?:-(\d+))?\.json$/;
35
+ function observeDirectory(directory) {
36
+ const stat = fs.lstatSync(directory, { bigint: true });
37
+ if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error('not an owned directory');
38
+ let raw = null;
39
+ try { raw = fs.readFileSync(path.join(directory, 'owner.json'), 'utf8'); } catch (error) { if (error.code !== 'ENOENT') throw error; }
40
+ return { identity: `${stat.dev}:${stat.ino}`, raw };
41
+ }
42
+ const readRaw = file => { try { return fs.readFileSync(file, 'utf8'); } catch { return null; } };
43
+ // A claim file must not restart the age an owner-less lock is judged by (Opus r2 #6): writing or removing one bumps the
44
+ // directory's mtime, so the directory's own times are put back.
45
+ function keepDirectoryTimes(directory, change) {
46
+ let before = null;
47
+ try { before = fs.lstatSync(directory); } catch { /* judged below */ }
48
+ try { return change(); } finally { if (before) try { fs.lutimesSync(directory, before.atime, before.mtime); } catch { /* gone */ } }
49
+ }
50
+ const GONE = Symbol('judged lock vanished');
51
+ function claimTakeover(directory) {
52
+ let highest = -1, highestName = null, names;
53
+ // The judged lock vanished (another reclaimer moved it): re-judge rather than throw (Fable r2 P3-1).
54
+ try { names = fs.readdirSync(directory); } catch (error) { if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return GONE; throw error; }
55
+ for (const name of names) {
56
+ const match = CLAIM_FILE.exec(name);
57
+ if (match && (match[1] ? Number(match[1]) : 0) > highest) { highest = match[1] ? Number(match[1]) : 0; highestName = name; }
58
+ }
59
+ if (highestName) {
60
+ let ageMs = 0;
61
+ try { ageMs = Date.now() - fs.lstatSync(path.join(directory, highestName)).mtimeMs; } catch { return null; }
62
+ if (!(ageMs > CLAIM_STALE_MS)) return null; // another reclaimer is mid-takeover
63
+ }
64
+ const level = highest + 1;
65
+ const file = path.join(directory, level ? `takeover-${level}.json` : 'takeover.json');
66
+ const token = crypto.randomUUID();
67
+ try { keepDirectoryTimes(directory, () => fs.writeFileSync(file, JSON.stringify({ token, pid: process.pid, claimedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 })); }
68
+ catch (error) { if (error.code === 'EEXIST') return null; if (error.code === 'ENOENT') return GONE; throw error; }
69
+ return { file, token };
70
+ }
71
+ const dropClaim = claim => { try { if (readRaw(claim.file)?.includes(claim.token)) keepDirectoryTimes(path.dirname(claim.file), () => fs.unlinkSync(claim.file)); } catch { /* gone with its directory */ } };
72
+ /** Move `directory` aside only if it is still exactly the one observed: 'removed' | 'changed' | 'busy'. */
73
+ function removeObservedLock(directory, observed, { afterClaim = null } = {}) {
74
+ const claim = claimTakeover(directory);
75
+ if (claim === GONE) return 'changed';
76
+ if (!claim) return 'busy';
77
+ afterClaim?.({ directory, claim });
78
+ let same = false;
79
+ try {
80
+ const now = observeDirectory(directory);
81
+ same = now.identity === observed.identity && now.raw === observed.raw && Boolean(readRaw(claim.file)?.includes(claim.token));
82
+ } catch { same = false; }
83
+ if (!same) { dropClaim(claim); return 'changed'; }
84
+ const tomb = `${directory}.stale-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
85
+ try { fs.renameSync(directory, tomb); } catch (error) { if (error.code === 'ENOENT') return 'changed'; dropClaim(claim); throw error; }
86
+ if (!readRaw(path.join(tomb, path.basename(claim.file)))?.includes(claim.token)) {
87
+ try { if (!fs.existsSync(directory)) fs.renameSync(tomb, directory); } catch { /* keep it as evidence */ }
88
+ throw Error(`developer update lock takeover moved a directory it did not claim; ${directory}`);
89
+ }
90
+ fs.rmSync(tomb, { recursive: true, force: true });
91
+ return 'removed';
92
+ }
93
+ /** Publish the lock with its owner already inside: one rename, which fails when any populated lock is there. */
94
+ const CONTENDED = new Set(['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES']);
95
+ function publishLock(directory, content) {
96
+ const staged = `${directory}.acquire-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
97
+ const dropStaged = () => { try { fs.rmSync(staged, { recursive: true, force: true }); } catch { /* best effort */ } };
98
+ try {
99
+ fs.mkdirSync(staged, { mode: 0o700 });
100
+ fs.writeFileSync(path.join(staged, 'owner.json'), content, { flag: 'wx', mode: 0o600 });
101
+ } catch (error) { dropStaged(); throw error; } // staging failures are real errors, never contention
102
+ try { fs.renameSync(staged, directory); return true; } catch (error) {
103
+ dropStaged();
104
+ // A refused publication is contention even if the lock is gone a moment later: its owner can release in between,
105
+ // and re-checking existsSync here threw a raw ENOTEMPTY at the caller (Opus r3 P2-1). Re-judge (bounded by the caller).
106
+ if (CONTENDED.has(error.code)) return false;
107
+ throw error;
108
+ }
109
+ }
110
+ export function sharedLockStatus({ brainHome, alive = pidAlive, startOf = processStartOf } = {}) {
8
111
  const directory = path.join(brainRoot(brainHome), 'developer-update.lock');
112
+ let status = judgeLock(directory, alive, startOf);
113
+ // An owner-less reading can be the instant between one owner's release and the next owner's publication: the lock was
114
+ // gone (or moved aside before owner.json was read) and a new owner's lock was there when existence was checked. That
115
+ // live lock was judged "unknown" and the refusal offered rm -rf (Opus r3 P2-2). Look again before calling it unknown.
116
+ for (let look = 0; look < 2 && status.state === 'unknown' && (status.observed === null || status.observed.raw === null); look++) {
117
+ let again = null;
118
+ try { again = observeDirectory(directory); } catch { /* vanished or unjudgeable: judged afresh */ }
119
+ if (again && status.observed && again.identity === status.observed.identity && again.raw === null) break; // the same owner-less directory
120
+ status = judgeLock(directory, alive, startOf);
121
+ }
122
+ return status;
123
+ }
124
+ function judgeLock(directory, alive, startOf) {
125
+ let observed = null;
9
126
  try {
10
- if (!fs.lstatSync(directory).isDirectory() || fs.lstatSync(directory).isSymbolicLink()) throw Error('not an owned directory');
11
- const owner = JSON.parse(fs.readFileSync(path.join(directory, 'owner.json'), 'utf8'));
127
+ observed = observeDirectory(directory);
128
+ if (observed.raw === null) throw Object.assign(Error('owner.json absent'), { code: 'ENOENT' });
129
+ const owner = JSON.parse(observed.raw);
12
130
  if (!Number.isInteger(owner.pid) || owner.pid < 1 || typeof owner.token !== 'string' || owner.token.length < 16) throw Error('invalid owner');
13
- return { state: alive(owner.pid) ? 'running' : 'stale', directory, owner };
14
- } catch (error) { return { state: error.code === 'ENOENT' && !fs.existsSync(directory) ? 'idle' : 'unknown', directory, error: error.message }; }
15
- }
16
- export function acquireDeveloperLock({ brainHome, token = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN, pid = process.pid, alive } = {}) {
17
- const status = sharedLockStatus({ brainHome, alive });
18
- if (token && status.state === 'running' && status.owner.token === token) return { token, ownerPid: status.owner.pid, inherited: true, release() {} };
19
- // A dead owner's lock (dead for 10+ minutes) must not wedge every future update (measured 2026-10-15: a coordinator killed mid-run
20
- // left `stale` behind and the automatic updater refused with "developer update lock stale" until a person
21
- // removed the directory). Reclaim it: move the directory aside atomically (so two reclaimers cannot both
22
- // win), then fall through to the normal atomic mkdir claim. Only a PROVABLY dead pid qualifies; a live,
23
- // unknown or malformed lock still refuses.
24
- const STALE_RECLAIM_MS = 10 * 60 * 1000;
25
- const ageMs = status.state === 'stale' ? Date.now() - Date.parse(status.owner?.startedAt || '') : NaN;
26
- if (status.state === 'stale' && Number.isFinite(ageMs) && ageMs >= STALE_RECLAIM_MS) {
27
- const tomb = `${status.directory}.stale-${process.pid}-${Date.now()}`;
28
- try { fs.renameSync(status.directory, tomb); fs.rmSync(tomb, { recursive: true, force: true }); }
29
- catch { /* another reclaimer won, or the owner came back: re-read below */ }
30
- const after = sharedLockStatus({ brainHome, alive });
31
- if (after.state !== 'idle') throw Error(`developer update lock ${after.state}; ${after.directory}`);
32
- } else if (status.state !== 'idle') throw Error(`developer update lock ${status.state}; ${status.directory}`);
33
- fs.mkdirSync(path.dirname(status.directory), { recursive: true });
34
- fs.mkdirSync(status.directory, { mode: 0o700 }); // atomic claim; racing owners get EEXIST
35
- const owner = { pid, token: crypto.randomUUID(), startedAt: new Date().toISOString() };
36
- try { fs.writeFileSync(path.join(status.directory, 'owner.json'), JSON.stringify(owner), { flag: 'wx', mode: 0o600 }); }
37
- catch (error) { fs.rmdirSync(status.directory); throw error; }
38
- return { token: owner.token, ownerPid: pid, inherited: false, release() {
39
- const current = sharedLockStatus({ brainHome, alive });
40
- if (current.owner?.token !== owner.token || current.owner?.pid !== pid) throw Error('developer update lock ownership changed');
41
- fs.unlinkSync(path.join(status.directory, 'owner.json')); fs.rmdirSync(status.directory);
42
- } };
131
+ if (!alive(owner.pid)) return { state: 'stale', directory, owner, observed };
132
+ // A live pid is the owner only when the recorded process start matches: a reused pid is another process.
133
+ const seen = typeof owner.processStart === 'string' && owner.processStart ? startOf(owner.pid) : null;
134
+ if (seen && seen !== owner.processStart) return { state: 'stale', directory, owner, reusedPid: true, observed };
135
+ return { state: 'running', directory, owner, observed };
136
+ } catch (error) { return { state: error.code === 'ENOENT' && !fs.existsSync(directory) ? 'idle' : 'unknown', directory, error: error.message, observed }; }
137
+ }
138
+ export function acquireDeveloperLock({ brainHome, token = process.env.RUVNET_DEVELOPER_UPDATE_TOKEN, pid = process.pid, alive, startOf = processStartOf, now = Date.now(), afterTakeoverClaim = null } = {}) {
139
+ for (let attempt = 0; attempt < 3; attempt++) {
140
+ const status = sharedLockStatus({ brainHome, alive, startOf });
141
+ if (token && status.state === 'running' && status.owner.token === token) return { token, ownerPid: status.owner.pid, inherited: true, release() {} };
142
+ // A dead owner's lock must not wedge every future update (measured 2026-10-15: a coordinator killed mid-run left `stale`
143
+ // behind and the automatic updater refused until a person removed the directory). Reclaimable: a provably dead or reused-pid
144
+ // owner, or a directory with no readable owner (a kill between mkdir and the owner write), once STALE_RECLAIM_MS old by its
145
+ // recorded start or, failing that, the directory's own mtime. The takeover is claimed and re-checked (above), then the
146
+ // normal atomic mkdir claim follows. A live owner, or anything younger, still refuses.
147
+ let ageMs = NaN;
148
+ if (status.state === 'stale' || status.state === 'unknown') {
149
+ ageMs = now - Date.parse(status.owner?.startedAt || '');
150
+ if (!Number.isFinite(ageMs)) try { ageMs = now - fs.lstatSync(status.directory).mtimeMs; } catch { ageMs = NaN; }
151
+ }
152
+ const refuse = state => Error(`developer update lock ${state}; ${status.directory}. If no update is running on this machine, remove it: ${removal(status.directory)}`);
153
+ // A lock with no owner yet that is seconds old is another process mid-creation (agentic-kit writes its owner after its
154
+ // mkdir), not debris: never offer to remove it (Opus r3 P2-2).
155
+ if (status.state === 'unknown' && status.observed?.raw === null && Number.isFinite(ageMs) && ageMs < OWNERLESS_GRACE_MS) {
156
+ throw Error(`developer update lock is being created by another process; ${status.directory}. Retry shortly.`);
157
+ }
158
+ if ((status.state === 'stale' || status.state === 'unknown') && Number.isFinite(ageMs) && ageMs >= STALE_RECLAIM_MS) {
159
+ if (!status.observed) throw refuse(status.state); // not a lock directory this protocol can judge (a symlink or a file)
160
+ const outcome = removeObservedLock(status.directory, status.observed, { afterClaim: afterTakeoverClaim });
161
+ if (outcome === 'busy') throw refuse('is being reclaimed by another process');
162
+ continue; // 'removed' or 'changed': judge the directory afresh
163
+ } else if (status.state !== 'idle') throw refuse(status.state);
164
+ fs.mkdirSync(path.dirname(status.directory), { recursive: true });
165
+ const owner = { pid, token: crypto.randomUUID(), startedAt: new Date().toISOString(), processStart: startOf(pid) };
166
+ if (!publishLock(status.directory, JSON.stringify(owner))) continue; // judge the lock that won
167
+ return { token: owner.token, ownerPid: pid, inherited: false, release() {
168
+ const current = sharedLockStatus({ brainHome, alive, startOf });
169
+ if (current.owner?.token !== owner.token || current.owner?.pid !== pid) throw Error('developer update lock ownership changed');
170
+ // Move the whole lock aside in one step, then confirm it was this owner's: removing owner.json first left an empty
171
+ // directory that a contender's publication rename could replace, and the follow-up removal deleted that live lock.
172
+ const tomb = `${status.directory}.released-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
173
+ fs.renameSync(status.directory, tomb);
174
+ if (!readRaw(path.join(tomb, 'owner.json'))?.includes(owner.token)) {
175
+ try { if (!fs.existsSync(status.directory)) fs.renameSync(tomb, status.directory); } catch { /* keep it as evidence */ }
176
+ throw Error('developer update lock ownership changed');
177
+ }
178
+ fs.rmSync(tomb, { recursive: true, force: true });
179
+ } };
180
+ }
181
+ throw Error(`developer update lock is contended; ${path.join(brainRoot(brainHome), 'developer-update.lock')}. Retry shortly.`);
43
182
  }
@@ -3,20 +3,23 @@ import fs from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import crypto from 'node:crypto';
6
- import { EXECUTION_MODULES } from './developer-update-policy.mjs';
6
+ import { EXECUTION_MODULES, OPTIONAL_EXECUTION_MODULES } from './developer-update-policy.mjs';
7
7
  const hash = file => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
8
8
  export function developerCoordinatorOwner({ home = os.homedir(), brainHome = process.env.RUVNET_BRAIN_HOME || path.join(home, '.cache/ruvnet-brain') } = {}) {
9
9
  const config = path.join(brainHome, 'developer-update-config.json'), registration = path.join(brainHome, 'scheduler/registration.json');
10
- let record;
11
- try { record = JSON.parse(fs.readFileSync(registration, 'utf8')); } catch { /* handled below */ }
12
- const configured = fs.existsSync(config) || record?.mode === 'developer-suite';
10
+ let record, registered = true;
11
+ try { record = JSON.parse(fs.readFileSync(registration, 'utf8')); } catch (error) { registered = error.code !== 'ENOENT'; }
12
+ // A config left behind by --disable-nightly (no registration at all) owns nothing: refusing every automatic update
13
+ // as "not ready" forever would wedge the machine (AuditC #11). An unreadable registration still refuses.
14
+ const configured = (fs.existsSync(config) && registered) || record?.mode === 'developer-suite';
13
15
  if (!configured) return { active: false, ready: false };
14
16
  const entry = record?.updateModules?.['developer-update.mjs'];
15
17
  try {
16
18
  if (record?.mode !== 'developer-suite' || !path.isAbsolute(entry?.path || '') || !/^[a-f0-9]{64}$/.test(entry?.sha256 || '')
17
19
  || hash(entry.path) !== entry.sha256 || hash(record.runnerPath) !== record.runnerSha256) throw Error('registered source is absent or changed');
18
20
  if (!record.updateModules?.['plugin-artifact-proof.mjs'] && fs.readFileSync(entry.path, 'utf8').includes('./plugin-artifact-proof.mjs')) throw Error('new coordinator artifact closure is incomplete');
19
- const generation = EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || record.updateModules?.[name]);
21
+ if (!record.updateModules?.['entrypoint.mjs'] && fs.readFileSync(entry.path, 'utf8').includes('./entrypoint.mjs')) throw Error('coordinator closure is missing entrypoint.mjs');
22
+ const generation = EXECUTION_MODULES.filter(name => !OPTIONAL_EXECUTION_MODULES.includes(name) || record.updateModules?.[name]);
20
23
  if (generation.some(name => !record.updateModules?.[name])) throw Error('coordinator execution closure is incomplete');
21
24
  for (const module of Object.values(record.updateModules)) if (hash(module.path) !== module.sha256) throw Error('registered module closure changed');
22
25
  return { active: true, ready: true, entry: entry.path, node: record.nodePath, registration, sourceSha256: entry.sha256 };
@@ -1,4 +1,7 @@
1
- export const EXECUTION_MODULES = Object.freeze(['developer-update.mjs', 'developer-update-policy.mjs', 'developer-update-lock.mjs', 'developer-update-maintenance.mjs', 'developer-update-cleanup.mjs', 'plugin-artifact-proof.mjs']);
1
+ export const EXECUTION_MODULES = Object.freeze(['developer-update.mjs', 'developer-update-policy.mjs', 'developer-update-lock.mjs', 'developer-update-maintenance.mjs', 'developer-update-cleanup.mjs', 'plugin-artifact-proof.mjs', 'entrypoint.mjs']);
2
+ // Members a coordinator generation may lack: each is required exactly when the staged developer-update.mjs imports it
3
+ // (plugin-artifact-proof since 4.6, entrypoint since the 4.7.21 entry-point sweep), so older registrations stay valid.
4
+ export const OPTIONAL_EXECUTION_MODULES = Object.freeze(['plugin-artifact-proof.mjs', 'entrypoint.mjs']);
2
5
  // Shared stack classification and ordering: installations are selected, never manufactured.
3
6
  export const FAMILY = /^(ruflo|ruvnet-brain|ruvector|ruvector-extensions|ruvi|ruvbot|qudag|flow-nexus|agent-browser|agent-browser-mcp|agentic-flow|agentic-qe|agentic-robotics|agentic-payments|agentdb|ruv-swarm|@pacphi\/agentic-kit$|@ruvector\/|@claude-flow\/|@metaharness\/|@agentic-robotics\/)/;
4
7
  export const PLUGIN_MARKETPLACES = new Set(['ruflo', 'ruview', 'ruvnet-brain', 'cognitum']);
@@ -1,5 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // One existing-install owner. Does not install schedulers or remove plugin generations.
3
+ // Over the 500-line guideline in 4.7.21 on purpose: a split needs a new module in EXECUTION_MODULES, which older registrations
4
+ // cannot stage. The split into a new closure module is planned for 4.7.22, with the runner-changed re-registration work.
3
5
  import fs from 'node:fs';
4
6
  import path from 'node:path';
5
7
  import os from 'node:os';
@@ -11,6 +13,7 @@ import { acquireDeveloperLock, sharedLockStatus } from './developer-update-lock.
11
13
  import { maintenance } from './developer-update-maintenance.mjs';
12
14
  import { cleanupNpxDuplicates } from './developer-update-cleanup.mjs';
13
15
  import { pinnedClaudeTarget, verifyClaudeArtifact } from './plugin-artifact-proof.mjs';
16
+ import { isDirectInvocation } from './entrypoint.mjs';
14
17
  export { compare, selectTag, sharedLockStatus };
15
18
  const HOME = os.homedir();
16
19
  const PREFIX = path.join(HOME, '.npm-global');
@@ -236,13 +239,16 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
236
239
  const writable = before.filter(p => p.scope !== 'managed' && !(unattended && ['project', 'local'].includes(p.scope))), flags = pluginFlags(writable, home);
237
240
  const provider = writable.length ? claudeProviderCommand(locate('claude'), { prefix, run }) : { command: locate('claude') };
238
241
  const claude = provider.command;
239
- if (writable.length && !claude) throw Error('installed Claude plugins have no existing Claude command');
242
+ // No Claude CLI on this PATH (desktop-app, VS Code, nvm or ~/.claude/local installs): nothing can update these plugins
243
+ // here, so each is preserved and reported UNSUPPORTED instead of ending the night (AuditC #1).
244
+ const noClaude = writable.length > 0 && !claude;
240
245
  const steps = [], artifactCache = new Map();
241
- const markets = [...new Set(writable.map(p => p.id.split('@').at(-1)))];
246
+ const markets = noClaude ? [] : [...new Set(writable.map(p => p.id.split('@').at(-1)))];
242
247
  if (!dryRun) for (const name of markets) run(claude, ['plugin', 'marketplace', 'update', name]);
243
248
  for (const p of before) {
244
- if (!writable.includes(p)) {
245
- const reason = p.scope === 'managed' ? 'managed scope belongs to its administrator' : 'unattended project settings unverified; original scope preserved';
249
+ if (!writable.includes(p) || noClaude) {
250
+ const reason = noClaude && writable.includes(p) ? 'no existing Claude command on this PATH; installed plugin preserved'
251
+ : p.scope === 'managed' ? 'managed scope belongs to its administrator' : 'unattended project settings unverified; original scope preserved';
246
252
  steps.push({ id: p.id, scope: p.scope, projectPath: p.projectPath, before: p, after: p, state: 'UNSUPPORTED', reason });
247
253
  notes.push(`plugin target unsupported; preserved: ${p.id} (${p.scope}): ${reason}`);
248
254
  continue;
@@ -250,9 +256,17 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
250
256
  const target = resolvePluginTarget(p, { home, run });
251
257
  let artifactTarget = null, artifactProof = null;
252
258
  if (target.supported && target.authority === 'commit' && target.proof === 'catalog-pinned-commit' && p.gitCommitSha?.toLowerCase() !== target.commit) {
253
- artifactTarget = await artifactProvider(target.source, target.commit, { cache: artifactCache });
254
- artifactProof = verifyClaudeArtifact(artifactTarget, p.installPath);
255
- target.version = artifactTarget.manifest.version || null;
259
+ try {
260
+ artifactTarget = await artifactProvider(target.source, target.commit, { cache: artifactCache });
261
+ artifactProof = verifyClaudeArtifact(artifactTarget, p.installPath);
262
+ target.version = artifactTarget.manifest.version || null;
263
+ } catch (error) {
264
+ // A rate-limited or oversized pinned-artifact proof leaves this plugin unproven, not the night failed (AuditC #16).
265
+ const reason = `pinned artifact unproven: ${error.message}`;
266
+ steps.push({ id: p.id, scope: p.scope, projectPath: p.projectPath, before: p, after: p, target, state: 'UNSUPPORTED', reason });
267
+ notes.push(`plugin target unsupported; preserved: ${p.id}: ${reason}`);
268
+ continue;
269
+ }
256
270
  }
257
271
  let decision = p.scope === 'managed' ? { state: 'UNSUPPORTED', reason: 'managed scope belongs to its administrator' } : pluginUpdateDecision(artifactProof?.actualVersion ? { ...p, version: artifactProof.actualVersion } : p, target);
258
272
  if (artifactProof?.ok && decision.state !== 'AHEAD') decision = { state: 'CURRENT', proof: 'pinned-claude-artifact', sourceCommitMatched: false, actualClaudeSourceMatched: true, providerMetadataDiscrepancy: { recordedCommit: p.gitCommitSha, pinnedCommit: target.commit } };
@@ -280,36 +294,92 @@ export async function synchronizePlugins(run, dryRun, notes, { home, prefix, sco
280
294
  const git = (result.marketplaces || []).filter(m => m.marketplaceSource?.sourceType === 'git' && (scope === 'all' || PLUGIN_MARKETPLACES.has(m.name)));
281
295
  if (!dryRun) for (const m of git) run(codex, ['plugin', 'marketplace', 'upgrade', m.name, '--json']);
282
296
  const help = run(codex, ['plugin', '--help'], { timeout: 30_000 });
283
- if (/^\s+update\s/m.test(help)) throw Error('Codex plugin update now exists; review scope-aware policy before enabling');
284
- notes.push('Codex has no plugin update command; configured Git catalogues refreshed, installed generations preserved');
297
+ // Tripwire, reported not thrown: a new Codex command must not end the night before the knowledge is checked (AuditC #17).
298
+ if (/^\s+update\s/m.test(help)) notes.push('Codex plugin update now exists and is unsupported until its scope-aware policy is reviewed; installed generations preserved');
299
+ else notes.push('Codex has no plugin update command; configured Git catalogues refreshed, installed generations preserved');
285
300
  }
286
301
  if (JSON.stringify(flags) !== JSON.stringify(pluginFlags(writable, home))) throw Error('Codex plugin enablement changed');
287
302
  return { before, after, steps, provider };
288
303
  }
289
- export function knowledge(run, dryRun, runId, { brainHome, root, node }) {
304
+ // The bundle download may take its 90-minute deadline (kb/download-retry.mjs) plus the apply; host-update.mjs's own
305
+ // knowledge limit and the SessionStart TTL sit above this, so a kill never lands mid-transaction one level up.
306
+ export const INSTALL_UPDATE_TIMEOUT_MS = 135 * 60_000;
307
+ export const TRANSIENT_RETRY_DELAYS_MS = Object.freeze([15_000, 30_000]);
308
+ const sleepSync = ms => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
309
+ const exitOf = result => typeof result === 'string' ? 0 : (result?.exitCode ?? result?.status ?? result?.code ?? null);
310
+ export const TRANSIENT_NETWORK = /ETIMEDOUT|ECONNRESET|ECONNREFUSED|EAI_AGAIN|ENOTFOUND|ENETUNREACH|socket hang up|network|timed out|\bE5\d\d\b|\b50[234]\b/i;
311
+ /** Three tries of `attempt` (15 s, then 30 s apart) while `transient(error)` says the failure was the network's. */
312
+ export function withTransientRetry(attempt, { transient = () => true, sleep = sleepSync, delays = TRANSIENT_RETRY_DELAYS_MS } = {}) {
313
+ for (let i = 0; ; i++) {
314
+ try { return attempt(i); } catch (error) { if (i >= delays.length || !transient(error)) throw error; sleep(delays[i]); }
315
+ }
316
+ }
317
+ export function knowledge(run, dryRun, runId, { brainHome, root, node, sleep = sleepSync }) {
290
318
  const kb = path.join(brainHome, 'kb'), updater = path.join(kb, 'forge-update.mjs');
291
319
  if (!exists(updater)) throw Error('existing Brain KB updater absent; fresh install refused');
292
320
  const file = path.join(brainHome, `nightly-suite-corpus-${runId}.json`);
321
+ // Exit codes are forge-update's: 0 current, 10 newer, 2 network (retried), 5 incompatible with this runtime.
322
+ const exec = (args, options) => {
323
+ if (!run.receipt) { run(node, args, options); return 0; }
324
+ const result = run.receipt(node, args, options);
325
+ if (result?.error) throw Error(`${path.basename(args[0])} ${args[1]}: ${result.error}`);
326
+ return exitOf(result);
327
+ };
293
328
  function check() {
294
329
  const started = Date.now();
295
- run(node, [updater, '--check', '--result-file', file], { timeout: 180_000, allowed: [0, 10] });
296
- const receipt = read(file);
330
+ const code = withTransientRetry(() => {
331
+ const status = exec([updater, '--check', '--result-file', file], { timeout: 180_000, allowed: [0, 10, 2, 5] });
332
+ if (status === 2) throw Object.assign(Error('corpus check could not reach the release (exit 2)'), { transient: true });
333
+ return status;
334
+ }, { transient: error => error.transient === true, sleep });
335
+ if (code === 5) return { currencyVerdict: 'INCOMPATIBLE', exitCode: 5 };
336
+ if (code !== 0 && code !== 10) throw Error(`corpus check exited ${code}`);
337
+ let receipt;
338
+ try { receipt = read(file); } finally { fs.rmSync(file, { force: true }); }
297
339
  if (receipt.kind !== 'ruvnet-brain-check-result' || Date.parse(receipt.recordedAt) < started - 1000) throw Error('fresh corpus check receipt absent');
298
340
  return receipt;
299
341
  }
342
+ const installer = path.join(root, 'ruvnet-brain/bin/install.mjs');
343
+ const apply = () => {
344
+ if (!exists(installer)) throw Error('existing Brain installer absent');
345
+ // 12 is cleanup-pending: the knowledge was applied and the next run finishes the cleanup (shared exit contract).
346
+ const code = exec([installer, '--update', '--no-nightly-prompt', '--no-stack'], { timeout: INSTALL_UPDATE_TIMEOUT_MS, allowed: [0, 12] });
347
+ if (code !== 0 && code !== 12) throw Error(`Brain installer --update exited ${code}`);
348
+ return code;
349
+ };
300
350
  const before = check();
351
+ if (before.currencyVerdict === 'INCOMPATIBLE') {
352
+ // A remembered rejection keyed to the KB's runtime stamp blocks every --check; only the installer re-stamps it. When the
353
+ // installed package is a different version than the stamp, the installer is what clears it (AuditC #5).
354
+ let installed = null, stamped = null;
355
+ try { installed = read(path.join(root, 'ruvnet-brain/package.json')).version || null; } catch { /* no installed package */ }
356
+ try { stamped = read(path.join(kb, 'RUNTIME-IDENTITY.json')).brainVersion || null; } catch { /* no stamp */ }
357
+ if (!installed || installed === stamped) return { before, state: 'incompatible', installed, stamped };
358
+ if (dryRun) return { before, state: 'update-available', reason: 'runtime stamp differs from the installed package', installed, stamped };
359
+ const installerExit = apply(), after = check();
360
+ if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error(`corpus update failed to converge after re-stamping ${stamped} -> ${installed}`);
361
+ return { before, after, state: 'updated', installerExit, restamped: { from: stamped, to: installed } };
362
+ }
301
363
  if (before.currencyVerdict === 'CURRENT' || before.currencyVerdict === 'REFUSED') return { before, after: before, state: before.currencyVerdict === 'CURRENT' ? 'current' : 'ahead-preserved' };
302
364
  // UNKNOWN is what a tree installed from a code release reports (no corpus generation stamp): forge-update
303
365
  // documents it as apply-allowed, exactly like UPDATE_AVAILABLE, and the check after the apply must reach
304
366
  // CURRENT or REFUSED. Throwing here failed every scheduled night until someone ran --update by hand.
305
367
  if (before.currencyVerdict !== 'UPDATE_AVAILABLE' && before.currencyVerdict !== 'UNKNOWN') throw Error(`corpus currency unverified: ${before.currencyVerdict}`);
306
368
  if (dryRun) return { before, state: 'update-available' };
307
- const installer = path.join(root, 'ruvnet-brain/bin/install.mjs');
308
- if (!exists(installer)) throw Error('existing Brain installer absent');
309
- run(node, [installer, '--update', '--no-nightly-prompt', '--no-stack'], { timeout: 1_800_000 });
369
+ const installerExit = apply();
310
370
  const after = check();
311
371
  if (!['CURRENT', 'REFUSED'].includes(after.currencyVerdict)) throw Error('corpus update failed to converge');
312
- return { before, after, state: 'updated' };
372
+ return { before, after, state: 'updated', installerExit };
373
+ }
374
+ /** Corpus check results this coordinator left behind (a run killed between check and delete) older than 48 h. */
375
+ export function pruneCorpusResults(brainHome, now = Date.now()) {
376
+ let removed = 0;
377
+ for (const name of (() => { try { return fs.readdirSync(brainHome); } catch { return []; } })()) {
378
+ if (!/^nightly-suite-corpus-[\w-]+\.json$/.test(name)) continue;
379
+ const file = path.join(brainHome, name);
380
+ try { if (fs.lstatSync(file).isFile() && now - fs.lstatSync(file).mtimeMs > 48 * 3_600_000) { fs.rmSync(file); removed++; } } catch { /* raced */ }
381
+ }
382
+ return removed;
313
383
  }
314
384
  export function developerUpdatePaths({ home = os.homedir(), brainHome = process.env.RUVNET_BRAIN_HOME || path.join(home, '.cache/ruvnet-brain') } = {}) {
315
385
  return { home, brainHome, config: path.join(brainHome, 'developer-update-config.json'),
@@ -349,7 +419,7 @@ export function locateExecutable(name, { env = process.env, platform = process.p
349
419
  }
350
420
  return null;
351
421
  }
352
- export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env } = {}) {
422
+ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(), brainHome, config: override, runner = invoke, env = process.env, knowledgeOnly = false, sleep = sleepSync } = {}) {
353
423
  if (!['check', 'apply'].includes(mode)) throw Error('mode must be check or apply');
354
424
  const paths = developerUpdatePaths({ home, brainHome });
355
425
  const config = validateDeveloperUpdateConfig(override || readDeveloperUpdateConfig(paths));
@@ -374,7 +444,7 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
374
444
  run.receipt = (command, args, options = {}) => dispatch(command, args, { ...options, capture: true });
375
445
  const locate = name => locateExecutable(name, { env: childEnv });
376
446
  const receipt = { schemaVersion: 1, kind: 'nightly-suite-update', runId: crypto.randomUUID(), pid: process.pid,
377
- startedAt: new Date().toISOString(), mode, scheduled: mode === 'apply' && env.RUVNET_NIGHTLY === '1' && !!env.RUVNET_NIGHTLY_IDENTITY, state: 'running', ok: false, config, steps: [], notes: [],
447
+ startedAt: new Date().toISOString(), mode, knowledgeOnly, scheduled: mode === 'apply' && env.RUVNET_NIGHTLY === '1' && !!env.RUVNET_NIGHTLY_IDENTITY, state: 'running', ok: false, config, steps: [], notes: [],
378
448
  sourceSha256: null, ownerToken: lock.token, ownerPid: lock.ownerPid, schedulerIdentity: env.RUVNET_NIGHTLY_IDENTITY || null };
379
449
  const scheduledAttempt = value => value?.mode === 'apply' && value.scheduled !== false && !!value.schedulerIdentity;
380
450
  const readAttempt = file => {
@@ -418,42 +488,71 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
418
488
  // Bind every subsequent install to this exact prefix instead of inherited npm config.
419
489
  childEnv.npm_config_prefix = prefix;
420
490
  receipt.npmIdentity = { npm, prefix, root, node: process.execPath };
421
- receipt.before = discover(root, prefix, config.scope);
422
- persistReceipt();
423
- for (const before of receipt.before) {
424
- const tags = before.localSource ? {} : JSON.parse(run(npm, ['view', before.name, 'dist-tags', '--json'], { timeout: 90_000 }));
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 }));
491
+ // Each stage is isolated: a failure is recorded on its stage and the run goes on, so one npm view, one plugin or a missing
492
+ // CLI can no longer cost the night its knowledge update (AuditC #1). Packages run before knowledge so a new code package's
493
+ // installer applies its matching corpus; knowledge is always attempted when the KB exists.
494
+ const failures = [];
495
+ const stage = async (name, body, onError = () => {}) => {
496
+ try { return await body(); } catch (error) { failures.push({ stage: name, error: error.message }); onError(error); persistReceipt(); return undefined; }
497
+ };
498
+ const viewRetry = attempt => withTransientRetry(attempt, { transient: error => TRANSIENT_NETWORK.test(error.message), sleep });
499
+ if (!knowledgeOnly) {
500
+ // An inventory failure is recorded in failures; no package is then touched, and knowledge still runs.
501
+ receipt.before = (await stage('inventory', () => discover(root, prefix, config.scope))) ?? [];
438
502
  persistReceipt();
503
+ for (const before of receipt.before) {
504
+ await stage(`package ${before.name}`, () => {
505
+ const tags = before.localSource ? {} : JSON.parse(viewRetry(() => run(npm, ['view', before.name, 'dist-tags', '--json'], { timeout: 90_000 })));
506
+ // A tracked non-latest tag (alpha, Kit's next) needs publish times to tell a newer pre-release from an abandoned
507
+ // one (developer-update-policy pickTargetTag); fetched only when that tag and latest name different versions. A
508
+ // failed lookup is {} — required but unknown — so the pre-release is skipped, never installed blind.
509
+ let published = null;
510
+ if (!before.localSource) {
511
+ let needed = false;
512
+ try { needed = needsPublishTimes(normalizeNpmDistTags(tags), wantedTag(before.name, config.channel)); } catch { needed = false; }
513
+ if (needed) {
514
+ try { published = JSON.parse(viewRetry(() => run(npm, ['view', before.name, 'time', '--json'], { timeout: 90_000 }))); }
515
+ catch { published = {}; }
516
+ }
517
+ }
518
+ receipt.steps.push(upgradePackage(before, tags, REVIEWED_INSTALL_SCRIPTS, { run, dryRun: mode === 'check', channel: config.channel, npm, preservePackages: config.preservePackages, published }));
519
+ }, error => receipt.steps.push({ name: before.name, state: 'failed', error: error.message, before }));
520
+ persistReceipt();
521
+ }
439
522
  }
440
- receipt.plugins = await synchronizePlugins(run, mode === 'check', receipt.notes, { home, prefix, scope: config.scope, locate, unattended: !!env.RUVNET_NIGHTLY_IDENTITY });
441
- if (exists(path.join(paths.brainHome, 'kb/forge-update.mjs'))) receipt.knowledge = knowledge(run, mode === 'check', receipt.runId, { brainHome: paths.brainHome, root, node: process.execPath });
442
- else receipt.notes.push('Brain corpus absent; fresh knowledge install excluded');
443
- receipt.maintenance = await maintenance(config, run, mode === 'check', { home, brainHome: paths.brainHome, locate, node: process.execPath, progress: result => { receipt.maintenance = result; persistReceipt(); } });
444
- receipt.cleanup = cleanupNpxDuplicates({ home, globalRoot: root, enabled: config.cleanup && mode === 'apply', run });
445
- receipt.after = discover(root, prefix, config.scope);
446
- receipt.state = mode === 'check' ? 'checked' : 'completed'; receipt.ok = true;
447
- receipt.coverage = { scope: config.scope, excluded: receipt.steps.filter(s => /preserved$/.test(s.state)).map(s => ({ name: s.name, reason: s.state })),
523
+ if (exists(path.join(paths.brainHome, 'kb/forge-update.mjs'))) {
524
+ receipt.knowledge = await stage('knowledge', () => knowledge(run, mode === 'check', receipt.runId, { brainHome: paths.brainHome, root, node: process.execPath, sleep }),
525
+ error => { receipt.knowledge = { state: 'failed', error: error.message }; }) ?? receipt.knowledge;
526
+ pruneCorpusResults(paths.brainHome);
527
+ } else receipt.notes.push('Brain corpus absent; fresh knowledge install excluded');
528
+ persistReceipt();
529
+ if (!knowledgeOnly) {
530
+ receipt.plugins = await stage('plugins', () => synchronizePlugins(run, mode === 'check', receipt.notes, { home, prefix, scope: config.scope, locate, unattended: !!env.RUVNET_NIGHTLY_IDENTITY }));
531
+ receipt.maintenance = await stage('maintenance', () => maintenance(config, run, mode === 'check', { home, brainHome: paths.brainHome, locate, node: process.execPath, progress: result => { receipt.maintenance = result; persistReceipt(); } }),
532
+ error => { for (const item of receipt.maintenance?.stages || []) if (item.state === 'running') { item.state = 'failed'; item.error = error.message; } }) ?? receipt.maintenance;
533
+ receipt.cleanup = await stage('cleanup', () => cleanupNpxDuplicates({ home, globalRoot: root, enabled: config.cleanup && mode === 'apply', run }));
534
+ receipt.after = await stage('inventory-after', () => discover(root, prefix, config.scope));
535
+ } else receipt.notes.push('knowledge-only run: packages, plugins and maintenance are left to the scheduled nightly');
536
+ receipt.failures = failures; receipt.ok = failures.length === 0;
537
+ receipt.state = failures.length ? 'completed-with-failures' : mode === 'check' ? 'checked' : 'completed';
538
+ receipt.coverage = { scope: knowledgeOnly ? 'knowledge-only' : config.scope, excluded: receipt.steps.filter(s => /preserved$/.test(s.state)).map(s => ({ name: s.name, reason: s.state })),
448
539
  ahead: receipt.steps.filter(s => s.state === 'ahead-preserved').map(s => s.name),
449
540
  preservedLocalModification: receipt.steps.filter(s => s.reason === 'preservedLocalModification').map(s => s.name),
450
- unverified: [...new Set([...receipt.notes.filter(note => /unverified|unsupported|no plugin update command/i.test(note)), ...(receipt.plugins?.steps || []).filter(step => step.state === 'UNSUPPORTED').map(step => `${step.id} (${step.scope}): ${step.reason}`)])], notes: [...receipt.notes, ...(receipt.maintenance?.exclusions || [])] };
541
+ unverified: [...new Set([...failures.map(f => `${f.stage}: ${f.error}`), ...receipt.notes.filter(note => /unverified|unsupported|no plugin update command/i.test(note)), ...(receipt.plugins?.steps || []).filter(step => step.state === 'UNSUPPORTED').map(step => `${step.id} (${step.scope}): ${step.reason}`)])], notes: [...receipt.notes, ...(receipt.maintenance?.exclusions || [])] };
542
+ if (failures.length) {
543
+ // Throw only after the receipt says which stages failed: the runner still exits 1 exactly as before.
544
+ receipt.error = failures.map(f => `${f.stage}: ${f.error}`).join('; ');
545
+ persistReceipt();
546
+ throw Object.assign(Error(`developer update completed with failures: ${receipt.error}`), { completedWithFailures: true });
547
+ }
451
548
  return receipt;
452
549
  } catch (error) {
453
- receipt.state = 'failed'; receipt.error = error.message;
454
- for (const stage of receipt.maintenance?.stages || []) if (stage.state === 'running') { stage.state = 'failed'; stage.error = error.message; }
455
- receipt.coverage = { scope: config.scope, unverified: [error.message], notes: receipt.notes };
456
- try { if (receipt.npmIdentity) receipt.after = discover(receipt.npmIdentity.root, receipt.npmIdentity.prefix, config.scope); } catch (e) { receipt.snapshotError = e.message; }
550
+ if (!error.completedWithFailures) {
551
+ receipt.state = 'failed'; receipt.error = error.message;
552
+ for (const item of receipt.maintenance?.stages || []) if (item.state === 'running') { item.state = 'failed'; item.error = error.message; }
553
+ receipt.coverage = { scope: config.scope, unverified: [error.message], notes: receipt.notes };
554
+ try { if (receipt.npmIdentity) receipt.after = discover(receipt.npmIdentity.root, receipt.npmIdentity.prefix, config.scope); } catch (e) { receipt.snapshotError = e.message; }
555
+ }
457
556
  throw error;
458
557
  } finally {
459
558
  receipt.finishedAt = new Date().toISOString();
@@ -462,19 +561,22 @@ export async function runDeveloperUpdate({ mode = 'check', home = os.homedir(),
462
561
  }
463
562
  export async function developerUpdateCli(args = process.argv.slice(2)) {
464
563
  if (args.includes('--help') || args.includes('-h')) {
465
- console.log('Usage: ruvnet-brain-update [--check|--apply] [--channel latest|alpha] [--scope ruvnet|all]');
564
+ console.log('Usage: ruvnet-brain-update [--check|--apply] [--knowledge-only] [--channel latest|alpha] [--scope ruvnet|all]');
466
565
  return;
467
566
  }
468
- let mode = 'apply'; const override = readDeveloperUpdateConfig();
567
+ // --knowledge-only: npm identity plus the knowledge step under the same lock (the SessionStart self-heal); the full suite
568
+ // runs only from the scheduled runner.
569
+ let mode = 'apply', knowledgeOnly = false; const override = readDeveloperUpdateConfig();
469
570
  for (let i = 0; i < args.length; i++) {
470
- if (args[i] === '--check' || args[i] === '--dry-run') mode = 'check';
571
+ if (args[i] === '--knowledge-only') knowledgeOnly = true;
572
+ else if (args[i] === '--check' || args[i] === '--dry-run') mode = 'check';
471
573
  else if (args[i] === '--apply') mode = 'apply';
472
574
  else if (args[i] === '--channel') override.channel = args[++i];
473
575
  else if (args[i] === '--scope') override.scope = args[++i];
474
576
  else throw Error(`unsupported developer update argument: ${args[i]}`);
475
577
  }
476
- const result = await runDeveloperUpdate({ mode, config: override });
578
+ const result = await runDeveloperUpdate({ mode, config: override, knowledgeOnly });
477
579
  console.log(JSON.stringify(result));
478
580
  return result;
479
581
  }
480
- if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) developerUpdateCli().catch(error => { console.error(error.message); process.exitCode = 1; });
582
+ if (isDirectInvocation(import.meta.url)) developerUpdateCli().catch(error => { console.error(error.message); process.exitCode = 1; });