ruvnet-brain 4.7.20 → 4.7.21

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 (250) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +494 -97
  3. package/kb/download-retry.mjs +93 -21
  4. package/kb/forge-guard-injection.mjs +277 -0
  5. package/kb/forge-update.mjs +166 -38
  6. package/kb/lifecycle-evidence-retention.mjs +151 -14
  7. package/kb/model-requirements.mjs +4 -1
  8. package/kb/refresh-run.mjs +175 -27
  9. package/kb/verify-citation.mjs +15 -2
  10. package/package.json +3 -1
  11. package/plugin/.claude-plugin/plugin.json +1 -1
  12. package/plugin/.codex-plugin/plugin.json +1 -1
  13. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  14. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  15. package/plugin/scripts/advisory-contract.mjs +21 -0
  16. package/plugin/scripts/advocacy-route.mjs +5 -4
  17. package/plugin/scripts/agentdb-recall.mjs +15 -5
  18. package/plugin/scripts/automatic-update.mjs +9 -1
  19. package/plugin/scripts/brain-confirmation.mjs +2 -1
  20. package/plugin/scripts/brain-footprint.mjs +72 -11
  21. package/plugin/scripts/capability-registry.mjs +1 -1
  22. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  23. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  24. package/plugin/scripts/completion-claim-evidence.mjs +56 -12
  25. package/plugin/scripts/continuation-gate.mjs +28 -9
  26. package/plugin/scripts/decision-gate.mjs +13 -63
  27. package/plugin/scripts/decision-outcomes.mjs +36 -0
  28. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  29. package/plugin/scripts/decision-record.mjs +8 -3
  30. package/plugin/scripts/degradation-watch.mjs +2 -1
  31. package/plugin/scripts/developer-update-lock.mjs +172 -33
  32. package/plugin/scripts/developer-update-owner.mjs +8 -5
  33. package/plugin/scripts/developer-update-policy.mjs +4 -1
  34. package/plugin/scripts/developer-update.mjs +155 -53
  35. package/plugin/scripts/doc-currency.mjs +2 -16
  36. package/plugin/scripts/duplicate-gate.mjs +2 -1
  37. package/plugin/scripts/entrypoint.mjs +34 -0
  38. package/plugin/scripts/footprint-io.mjs +135 -8
  39. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  40. package/plugin/scripts/ground-ruvnet.sh +6 -3
  41. package/plugin/scripts/grounding-answer.mjs +2 -1
  42. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  43. package/plugin/scripts/grounding-substance.mjs +2 -1
  44. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  45. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  46. package/plugin/scripts/hook-context-budget.mjs +2 -1
  47. package/plugin/scripts/hook-input.mjs +2 -2
  48. package/plugin/scripts/hook-shim.mjs +9 -2
  49. package/plugin/scripts/host-update.mjs +29 -5
  50. package/plugin/scripts/identifier-preflight.mjs +2 -1
  51. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  52. package/plugin/scripts/learn-capture.mjs +2 -1
  53. package/plugin/scripts/learning-store.mjs +62 -16
  54. package/plugin/scripts/lesson-bridge.mjs +2 -2
  55. package/plugin/scripts/lesson-promote.mjs +83 -22
  56. package/plugin/scripts/md-stamp.mjs +2 -1
  57. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  58. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  59. package/plugin/scripts/package-recommender.mjs +2 -2
  60. package/plugin/scripts/project-store-resolver.mjs +19 -0
  61. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  62. package/plugin/scripts/session-snapshot-hook.mjs +24 -4
  63. package/plugin/scripts/session-start-core.mjs +5 -15
  64. package/plugin/scripts/session-start-health.mjs +97 -10
  65. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  66. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  67. package/plugin/scripts/signal-watch.mjs +2 -1
  68. package/plugin/scripts/spend-guard.mjs +2 -1
  69. package/plugin/scripts/substitution-wall.mjs +2 -1
  70. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  71. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  72. package/plugin/scripts/update-apply.mjs +186 -15
  73. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  74. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  75. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  76. package/scripts/adr-072-completion.mjs +2 -1
  77. package/scripts/agentdb-context.mjs +2 -1
  78. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  79. package/scripts/approved-runtime.mjs +2 -1
  80. package/scripts/architecture-review-lock.mjs +2 -1
  81. package/scripts/ascii-drift.mjs +2 -1
  82. package/scripts/brain-latency-50.mjs +2 -1
  83. package/scripts/brain-novice-50.mjs +2 -16
  84. package/scripts/brain-score.mjs +13 -2
  85. package/scripts/build-bundle.mjs +2 -1
  86. package/scripts/build-concepts.mjs +2 -1
  87. package/scripts/candidate-ci-receipt.mjs +2 -1
  88. package/scripts/candidate-host-evidence.mjs +2 -1
  89. package/scripts/card-from-source.mjs +2 -1
  90. package/scripts/ci/warm-brain-models.mjs +2 -1
  91. package/scripts/claims-verify.mjs +7 -3
  92. package/scripts/claude-controlled-terminal.mjs +2 -1
  93. package/scripts/claude-terminal-mod.mjs +2 -1
  94. package/scripts/code-release-corpus.mjs +2 -1
  95. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  96. package/scripts/codex-hook-trust.mjs +40 -1
  97. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  98. package/scripts/codex-host-execution-proof.mjs +2 -1
  99. package/scripts/completion-claim-replay.mjs +2 -1
  100. package/scripts/console-engine.mjs +8 -3
  101. package/scripts/corpus-canary.mjs +2 -1
  102. package/scripts/corpus-candidate.mjs +2 -2
  103. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  104. package/scripts/corpus-dispatch-decision.mjs +2 -1
  105. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  106. package/scripts/corpus-next-seed.mjs +2 -1
  107. package/scripts/corpus-qa.mjs +2 -1
  108. package/scripts/corpus-reconcile.mjs +2 -2
  109. package/scripts/corpus-watchdog.mjs +2 -1
  110. package/scripts/count-chunks.mjs +2 -1
  111. package/scripts/customer-state-matrix.mjs +2 -1
  112. package/scripts/derive-passage-content-map.mjs +2 -1
  113. package/scripts/development-push-check.mjs +2 -1
  114. package/scripts/dispatch-receipt.mjs +2 -1
  115. package/scripts/doc-currency.mjs +2 -1
  116. package/scripts/dream-issue-gate.mjs +2 -1
  117. package/scripts/dual-host-deliberation.mjs +2 -16
  118. package/scripts/dual-host-suggest.mjs +2 -16
  119. package/scripts/eval-brain.mjs +7 -3
  120. package/scripts/execution-policy.mjs +2 -1
  121. package/scripts/execution-preflight.mjs +2 -1
  122. package/scripts/falsify.mjs +2 -1
  123. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  124. package/scripts/fix-workstream.mjs +2 -1
  125. package/scripts/full-hints.mjs +2 -1
  126. package/scripts/full-suite-gate.mjs +2 -1
  127. package/scripts/grok-host.mjs +68 -0
  128. package/scripts/hook-qualify-core.mjs +3 -1
  129. package/scripts/hook-qualify.mjs +2 -1
  130. package/scripts/hook-retirement-check.mjs +2 -1
  131. package/scripts/host-registry.mjs +2 -1
  132. package/scripts/independent-review-receipt.mjs +2 -1
  133. package/scripts/ingest-new-repos.mjs +2 -1
  134. package/scripts/ingest-repo.mjs +12 -2
  135. package/scripts/integration-evidence.mjs +2 -1
  136. package/scripts/issue-fix.mjs +7 -24
  137. package/scripts/issue-watch.mjs +116 -40
  138. package/scripts/job-heartbeat.sh +54 -19
  139. package/scripts/key-canary.mjs +9 -3
  140. package/scripts/latency-to-surface.mjs +2 -1
  141. package/scripts/learning-replay-cli.mjs +97 -19
  142. package/scripts/learning-replay-execution.mjs +2 -0
  143. package/scripts/learning-replay-fixture.mjs +3 -0
  144. package/scripts/learning-replay-proof.mjs +14 -13
  145. package/scripts/learning-replay.mjs +3 -3
  146. package/scripts/learnings.mjs +2 -1
  147. package/scripts/lesson-ratify.mjs +88 -40
  148. package/scripts/loop-checkpoint.mjs +2 -1
  149. package/scripts/metaharness-receipts.mjs +2 -1
  150. package/scripts/model-catalog.mjs +2 -1
  151. package/scripts/model-currency-evidence.mjs +6 -4
  152. package/scripts/model-currency.mjs +33 -3
  153. package/scripts/model-native-catalog.mjs +2 -1
  154. package/scripts/model-router-agent-hook.mjs +2 -1
  155. package/scripts/model-router-dispatch.mjs +2 -1
  156. package/scripts/model-router-engine.mjs +2 -1
  157. package/scripts/model-router-outcome.mjs +2 -1
  158. package/scripts/model-router-setup.mjs +2 -1
  159. package/scripts/model-routing-eval.mjs +2 -1
  160. package/scripts/model-routing-gateway.mjs +2 -1
  161. package/scripts/model-routing-launchers.mjs +4 -1
  162. package/scripts/model-terminal-gateway.mjs +2 -1
  163. package/scripts/model-terminal-launchers.mjs +2 -1
  164. package/scripts/model-weekly-analyst.mjs +2 -1
  165. package/scripts/model-weekly-cycle.mjs +2 -1
  166. package/scripts/model-weekly-qualification.mjs +2 -1
  167. package/scripts/nightly-gists.sh +26 -20
  168. package/scripts/nightly-two-run-proof.mjs +2 -1
  169. package/scripts/nightly-watchdog.mjs +158 -47
  170. package/scripts/no-silent-substitution.mjs +2 -1
  171. package/scripts/notify.sh +11 -6
  172. package/scripts/ntfy-inbox.mjs +41 -18
  173. package/scripts/onboarding-console.mjs +47 -8
  174. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  175. package/scripts/oracle/abstain-trace.mjs +2 -1
  176. package/scripts/oracle/doc2query-generate.mjs +2 -1
  177. package/scripts/oracle/doc2query-reach.mjs +2 -1
  178. package/scripts/oracle/judge-train.mjs +2 -1
  179. package/scripts/oracle/need-set-split.mjs +2 -1
  180. package/scripts/oracle/produce-questions.mjs +2 -16
  181. package/scripts/oracle/repo-recall.mjs +2 -2
  182. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  183. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  184. package/scripts/oracle/source-tree.mjs +2 -15
  185. package/scripts/oracle/source-units.mjs +2 -16
  186. package/scripts/oracle/spike-run.mjs +2 -16
  187. package/scripts/oracle/validate-labels.mjs +2 -16
  188. package/scripts/org-repo-count.mjs +2 -1
  189. package/scripts/package-cards.mjs +2 -1
  190. package/scripts/prepublication-evidence.mjs +2 -1
  191. package/scripts/private-overlay.mjs +2 -1
  192. package/scripts/product-integrity-contract.mjs +2 -1
  193. package/scripts/protected-release-invocation.mjs +2 -1
  194. package/scripts/public-verification-abandon.mjs +2 -1
  195. package/scripts/public-verification-aggregate.mjs +2 -1
  196. package/scripts/public-verification-finalizer.mjs +2 -1
  197. package/scripts/public-verification-inputs.mjs +2 -1
  198. package/scripts/public-verification-lane.mjs +2 -1
  199. package/scripts/publication-receipt.mjs +2 -1
  200. package/scripts/published-surface-probe.mjs +2 -1
  201. package/scripts/qe/card-lane-gate.mjs +2 -16
  202. package/scripts/qe/session-start-gate.mjs +2 -16
  203. package/scripts/qe/ux-suite.mjs +2 -1
  204. package/scripts/qualified-candidate-check.mjs +2 -1
  205. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  206. package/scripts/recommendation-eval.mjs +2 -1
  207. package/scripts/recommendation-floor.mjs +2 -1
  208. package/scripts/recommendation-judge-score.mjs +2 -1
  209. package/scripts/recommendation-real-host-score.mjs +2 -1
  210. package/scripts/recommendation-real-host.mjs +2 -1
  211. package/scripts/refresh-capability-only-store.mjs +2 -1
  212. package/scripts/refresh-model-catalog.mjs +2 -1
  213. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  214. package/scripts/release-authority.mjs +2 -1
  215. package/scripts/release-evidence-aggregate.mjs +2 -1
  216. package/scripts/release-payload.mjs +2 -1
  217. package/scripts/release-projection.mjs +2 -1
  218. package/scripts/release-proof.mjs +2 -1
  219. package/scripts/release-qualification-contract.mjs +64 -28
  220. package/scripts/release-qualification.mjs +8 -1
  221. package/scripts/release-vector.mjs +2 -1
  222. package/scripts/restore-local-ingests.mjs +2 -1
  223. package/scripts/retrieval-canary.mjs +2 -1
  224. package/scripts/route-cheap.mjs +2 -1
  225. package/scripts/route-gold-rank.mjs +2 -1
  226. package/scripts/route-index-memory.mjs +2 -1
  227. package/scripts/route-latency-warm.mjs +2 -1
  228. package/scripts/router-utilization.mjs +2 -1
  229. package/scripts/routing-flywheel.mjs +2 -1
  230. package/scripts/run-operational-benchmark.mjs +2 -1
  231. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  232. package/scripts/rvf-index-audit.mjs +2 -16
  233. package/scripts/seal-gist-receipt.mjs +2 -1
  234. package/scripts/security-guidance-codex-compat.mjs +2 -1
  235. package/scripts/selfcheck.mjs +2 -1
  236. package/scripts/signal-watch.mjs +2 -1
  237. package/scripts/source-coverage.mjs +2 -1
  238. package/scripts/stabilization-receipt.mjs +2 -1
  239. package/scripts/stamp-sweep.mjs +2 -1
  240. package/scripts/status-honesty.mjs +2 -1
  241. package/scripts/sync-commands.mjs +2 -2
  242. package/scripts/top100-benchmark.mjs +2 -1
  243. package/scripts/top100-corpus.mjs +2 -1
  244. package/scripts/user-model-hook-install.mjs +2 -1
  245. package/scripts/user-model-prompt-hook.mjs +2 -1
  246. package/scripts/verify-bundle.mjs +3 -1
  247. package/scripts/verify-model-catalog.mjs +2 -1
  248. package/scripts/version.mjs +2 -1
  249. package/scripts/wired-check.mjs +11 -5
  250. package/scripts/worktree-integrity.mjs +2 -2
@@ -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; });
@@ -58,6 +58,7 @@ import path from 'node:path';
58
58
  import crypto from 'node:crypto';
59
59
  import { spawnSync } from 'node:child_process';
60
60
  import { fileURLToPath } from 'node:url';
61
+ import { isDirectInvocation } from './entrypoint.mjs';
61
62
 
62
63
  const HERE = path.dirname(fileURLToPath(import.meta.url));
63
64
  export const REPO_ROOT = path.resolve(HERE, '..', '..');
@@ -974,20 +975,5 @@ export function main(argv = process.argv.slice(2)) {
974
975
  return 1;
975
976
  }
976
977
 
977
- // Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
978
- // NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
979
- // shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
980
- // never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
981
- // from "ran, found nothing", which is how prepareCorpusCandidate once reported SUCCESS with no
982
- // archive on disk. Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
983
- function isDirectInvocation() {
984
- try {
985
- if (!process.argv[1]) return false;
986
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
987
- } catch {
988
- return false;
989
- }
990
- }
991
-
992
- const invokedDirectly = isDirectInvocation();
978
+ const invokedDirectly = isDirectInvocation(import.meta.url);
993
979
  if (invokedDirectly) process.exit(main());
@@ -45,6 +45,7 @@ import os from 'node:os';
45
45
  import path from 'node:path';
46
46
  import { spawnSync } from 'node:child_process';
47
47
  import { fileURLToPath } from 'node:url';
48
+ import { isDirectInvocation } from './entrypoint.mjs';
48
49
 
49
50
  export const SCOPE = ['scripts/', 'kb/', 'plugin/scripts/', 'tests/', 'bin/'];
50
51
  export const THRESHOLD = 0.5;
@@ -485,7 +486,7 @@ export function evaluate(payload, { env = process.env, now = Date.now(), deadlin
485
486
  }
486
487
 
487
488
  const isMain = (() => {
488
- try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
489
+ try { return isDirectInvocation(import.meta.url); }
489
490
  catch { return false; }
490
491
  })();
491
492
 
@@ -0,0 +1,34 @@
1
+ // plugin/scripts/entrypoint.mjs — the ONE answer to "was this module run as the program, or imported?"
2
+ //
3
+ // Every dual-purpose file in this repo (a library when imported, a CLI when run) asks that question.
4
+ // The copy-pasted answer compared `process.argv[1]` with `import.meta.url` directly, e.g.
5
+ // path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
6
+ // import.meta.url === pathToFileURL(process.argv[1]).href
7
+ // argv[1] is the path as the caller typed it, symlinks included; Node resolves symlinks before it sets
8
+ // import.meta.url. Run through a symlink (npm bin shims, wrapper scripts, a symlinked checkout, every
9
+ // os.tmpdir() path on macOS) the two sides differ, main() never runs, and the process exits 0 having
10
+ // done nothing. A release gate that exits 0 without checking anything passes. Dream Cycle PRs #295,
11
+ // #317, #333, #367, #412 and #484 each found one more copy by hand.
12
+ //
13
+ // Usage:
14
+ // import { isDirectInvocation } from './entrypoint.mjs'; // path relative to this file
15
+ // if (isDirectInvocation(import.meta.url)) main();
16
+ //
17
+ // tests/unit/entrypoint-guard-sweep.test.mjs fails when a raw comparison reappears outside this file,
18
+ // and runs a script through a symlink to prove this helper makes it execute.
19
+ import fs from 'node:fs';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ /**
23
+ * True when `moduleUrl` (pass `import.meta.url`) is the file Node was asked to run. Both sides are
24
+ * compared as real paths. Never throws: `realpathSync` throws on a path that does not resolve (under
25
+ * a test runner argv[1] need not be a real file), and an imported module must not crash its importer.
26
+ */
27
+ export function isDirectInvocation(moduleUrl, argv1 = process.argv[1]) {
28
+ if (!argv1 || !moduleUrl) return false;
29
+ try {
30
+ return fs.realpathSync(argv1) === fs.realpathSync(fileURLToPath(moduleUrl));
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -1,5 +1,6 @@
1
1
  // footprint-io.mjs — the footprint sweep's disk operations (ADR-0098), kept apart from the classifier in
2
- // brain-footprint.mjs: byte counts, log rotation, the guarded removal, and the KEPT-proof cache.
2
+ // brain-footprint.mjs: byte counts, log rotation, the guarded removal, the rescue of a released copy's
3
+ // operational files, and the KEPT-proof cache.
3
4
  //
4
5
  // THE KEPT-PROOF CACHE (independent review S7).
5
6
  // kbCopyProof hashes every file of a GB-sized copy. A copy kept because it holds data the live brain lacks
@@ -7,6 +8,7 @@
7
8
  // which can only keep it again. Only KEPT results are cached, keyed by a cheap stat fingerprint of the copy
8
9
  // and of the live brain (no hashing, no tree walk); a stale entry can only KEEP a copy, never remove one, and
9
10
  // any change to the live brain's identity files (a restore, an update) invalidates it.
11
+ import crypto from 'node:crypto';
10
12
  import fs from 'node:fs';
11
13
  import path from 'node:path';
12
14
 
@@ -157,7 +159,43 @@ export function removeWithin(target, expectedParent, owned) {
157
159
  return size;
158
160
  }
159
161
 
162
+ /**
163
+ * RESCUE BEFORE RELEASE. kbCopyProof lists a disposable copy's operational files (logs, ruflo scratch) as
164
+ * `rescue`, each with the bytes it was proven with. They are copied to <brainHome>/kb-copy-rescued/<copy
165
+ * name>/<path> and verified there BEFORE the copy is removed. Any mismatch (the source changed since the
166
+ * proof, a different file already at the destination, a link in the way) throws, and the caller keeps the
167
+ * copy. Retention of the rescued directories is brain-footprint.mjs's (FOOTPRINT_POLICY.rescuedCopiesKept).
168
+ */
169
+ export const RESCUE_DIR = 'kb-copy-rescued';
170
+ const sha256Of = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
171
+ const regular = (file) => { const st = lstat(file); return Boolean(st && st.isFile() && !st.isSymbolicLink()); };
172
+ export function rescueOperationalFiles(copyDir, rescue, brainHome, name = path.basename(copyDir)) {
173
+ if (!rescue?.length) return null;
174
+ const root = path.join(brainHome, RESCUE_DIR); const dest = path.join(root, name);
175
+ for (const dir of [brainHome, root, dest]) {
176
+ if (lstat(dir)?.isSymbolicLink()) throw new Error(`refusing to rescue into ${dir}: it is a link`);
177
+ }
178
+ for (const { file, sha256 } of rescue) {
179
+ const from = path.join(copyDir, file); const to = path.join(dest, file);
180
+ if (!regular(from) || sha256Of(from) !== sha256) throw new Error(`${file} changed after the copy was proven; the copy is kept`);
181
+ fs.mkdirSync(path.dirname(to), { recursive: true });
182
+ if (!lstat(to)) fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
183
+ if (!regular(to) || sha256Of(to) !== sha256) throw new Error(`could not rescue ${file} to ${to} (a different file is there); the copy is kept`);
184
+ }
185
+ // A reused rescue folder (an earlier sweep rescued, then could not release, this copy) holds the only copy of these
186
+ // logs once the copy goes: make it the newest, so retention keeps it among the newest five (review r2). A failure
187
+ // throws, and the caller keeps the copy.
188
+ const touched = new Date(); fs.utimesSync(dest, touched, touched);
189
+ return dest;
190
+ }
191
+
160
192
  const PROOF_CACHE = '.footprint-proof-cache.json';
193
+ // #335: schema 1 KEPT proofs predate kept-sibling accounting (two identical preserved copies each kept the
194
+ // other forever); they are proven once more. A dropped cache entry can only cost a re-hash, never a byte.
195
+ const PROOF_CACHE_SCHEMA = 2;
196
+ // The proof rule a cached KEPT verdict was reached under. A verdict from an older rule (one that kept copies
197
+ // the current rule releases, e.g. for a log file before rescue existed) is not reused; that copy is proven again.
198
+ const PROOF_RULE = 2;
161
199
  const IDENTITY_FILES = ['SOURCE.json', 'PRIVATE-STORES.json', 'COVERAGE.json', 'RVF-GENERATIONS.json'];
162
200
  const statKey = (file) => { try { const st = fs.lstatSync(file); return `${st.size}:${Math.floor(st.mtimeMs)}`; } catch { return '-'; } };
163
201
  const fingerprint = (dir) => [statKey(dir), ...IDENTITY_FILES.map((f) => statKey(path.join(dir, f)))].join('|');
@@ -165,21 +203,21 @@ const fingerprint = (dir) => [statKey(dir), ...IDENTITY_FILES.map((f) => statKey
165
203
  export function readProofCache(brainHome) {
166
204
  try {
167
205
  const doc = JSON.parse(fs.readFileSync(path.join(brainHome, PROOF_CACHE), 'utf8'));
168
- if (doc && typeof doc.entries === 'object' && doc.entries) return doc;
206
+ if (doc && doc.schemaVersion === PROOF_CACHE_SCHEMA && typeof doc.entries === 'object' && doc.entries) return doc;
169
207
  } catch { /* none yet */ }
170
- return { schemaVersion: 1, entries: {} };
208
+ return { schemaVersion: PROOF_CACHE_SCHEMA, entries: {} };
171
209
  }
172
210
 
173
211
  /** The cached KEPT proof for this copy, if neither the copy nor the live brain changed since. */
174
212
  export function cachedKept(cache, copyDir, liveDir) {
175
213
  const hit = cache.entries[copyDir];
176
- return hit && hit.copy === fingerprint(copyDir) && hit.live === fingerprint(liveDir) ? hit : null;
214
+ return hit && hit.rule === PROOF_RULE && hit.copy === fingerprint(copyDir) && hit.live === fingerprint(liveDir) ? hit : null;
177
215
  }
178
216
 
179
217
  export function rememberKept(brainHome, copyDir, liveDir, proof) {
180
218
  try {
181
219
  const cache = readProofCache(brainHome);
182
- cache.entries[copyDir] = { copy: fingerprint(copyDir), live: fingerprint(liveDir), reason: proof.reason,
220
+ cache.entries[copyDir] = { rule: PROOF_RULE, copy: fingerprint(copyDir), live: fingerprint(liveDir), reason: proof.reason,
183
221
  unique: (proof.unique || []).slice(0, 10), at: new Date().toISOString() };
184
222
  const file = path.join(brainHome, PROOF_CACHE);
185
223
  fs.writeFileSync(`${file}.tmp-${process.pid}`, JSON.stringify(cache));
@@ -187,7 +225,96 @@ export function rememberKept(brainHome, copyDir, liveDir, proof) {
187
225
  } catch { /* uncached: the next sweep proves it again, which is only slower */ }
188
226
  }
189
227
 
190
- /** The honest remedy for a copy no command can remove: what it holds, and that the owner decides. */
191
- export const keptCopyFix = (copyDir, hit) => `nothing to run: ${copyDir} is kept because it holds ${
228
+ /** The honest remedy for a kept copy: what it holds, and the explicit archive command that releases it (#335). */
229
+ export const keptCopyFix = (copyDir, hit) => `npx ruvnet-brain --clean --archive — ${copyDir} is kept because it holds ${
192
230
  (hit.unique || []).some((u) => /private/i.test(u.why || '')) ? 'private ' : ''}data the live brain lacks (${
193
- (hit.unique || []).slice(0, 3).map((u) => u.file).join(', ') || hit.reason}); restore that into the live brain, or delete the copy yourself once inspected`;
231
+ (hit.unique || []).slice(0, 3).map((u) => u.file).join(', ') || hit.reason}); that copies only those files into kb.archive/ and releases the copy (or restore them into the live brain yourself)`;
232
+
233
+ const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
234
+ /**
235
+ * Every `<archiveDir>/<stamp>/<label>` tree `--clean --archive` wrote: real directories only, links never entered.
236
+ * A dot-named entry is an archive still being written (or one a killed sweep left half-written); it is never
237
+ * a reference, so a partial archive can never account for a copy's bytes.
238
+ */
239
+ export function archiveRoots(archiveDir) {
240
+ const real = (p) => { const st = lstat(p); return Boolean(st && st.isDirectory() && !st.isSymbolicLink()); };
241
+ if (!real(archiveDir)) return [];
242
+ return names(archiveDir).map((s) => path.join(archiveDir, s)).filter(real)
243
+ .flatMap((stampDir) => names(stampDir).filter((n) => !n.startsWith('.')).map((n) => path.join(stampDir, n)).filter(real));
244
+ }
245
+
246
+ /** Undo an archive this sweep wrote (the copy it came from is being kept, so the archive holds nothing unique). */
247
+ export function discardArchive(root) {
248
+ fs.rmSync(root, { recursive: true, force: true });
249
+ fs.rmSync(`${root}.json`, { force: true });
250
+ }
251
+
252
+ /**
253
+ * Discard an archive ONLY while the copy it came from still holds every archived byte (review 4.7.21 r2). Between
254
+ * the archive and the re-proof another process (an updater's reclaimBackups, which counts this archive as
255
+ * evidence) may have removed the copy; a re-proof of a missing directory still says "keep", so an unconditional
256
+ * discard would lose the bytes in both places. The copy must still be a real directory and each archived file a
257
+ * regular file there with the same SHA-256; otherwise the archive is kept and the reason is returned.
258
+ */
259
+ export function discardArchiveIfMirrored({ root, copyDir, files }) {
260
+ const dir = lstat(copyDir);
261
+ if (!dir || dir.isSymbolicLink() || !dir.isDirectory()) return { discarded: false, reason: `${copyDir} is no longer a real directory` };
262
+ for (const { file, sha256 } of files || []) {
263
+ const held = path.join(copyDir, file);
264
+ let same = false;
265
+ try { same = regular(held) && sha256File(held) === sha256; } catch { same = false; }
266
+ if (!same) return { discarded: false, reason: `${file} is no longer identical in ${copyDir}` };
267
+ }
268
+ discardArchive(root);
269
+ return { discarded: true };
270
+ }
271
+
272
+ /**
273
+ * #335 `--clean --archive` (explicit, never automatic): COPY exactly the files a KEPT proof named unique into
274
+ * `<stampDir>/<label>/<same relative path>`, verify each byte-for-byte, and write `<stampDir>/<label>.json`.
275
+ * Moves and deletes nothing: the caller re-proves the copy against this archive before releasing it. A link,
276
+ * an unsafe ancestor, a non-regular file, too little free space or any copy error archives nothing usable.
277
+ * ALL OR NOTHING: the files go to a dot-named temp sibling and are renamed to `<label>` only after every one
278
+ * verifies (the receipt likewise); a failure removes the temp, so no half-written `<label>` is ever left for a
279
+ * later sweep to compare against.
280
+ */
281
+ export function archiveUniqueFiles({ copyDir, unique, stampDir, label, now = Date.now() }) {
282
+ if (!Array.isArray(unique) || !unique.length) return { ok: false, reason: 'no file is named unique; the copy is kept for another reason' };
283
+ const root = path.join(stampDir, label);
284
+ if (lstat(root)) return { ok: false, reason: `${root} already exists` };
285
+ let need = 0;
286
+ for (const { file, why } of unique) {
287
+ const st = lstat(path.join(copyDir, file));
288
+ if (/link|ancestor/i.test(String(why)) || !st || !st.isFile() || st.isSymbolicLink()) return { ok: false, reason: `${file} is not a regular file that can be archived (${why})` };
289
+ need += st.size;
290
+ }
291
+ const temp = path.join(stampDir, `.${label}.partial-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
292
+ const receipt = path.join(stampDir, `${label}.json`); const receiptTemp = `${temp}.json`;
293
+ let renamed = false;
294
+ try {
295
+ fs.mkdirSync(stampDir, { recursive: true });
296
+ const free = typeof fs.statfsSync === 'function' ? fs.statfsSync(stampDir) : null;
297
+ if (free && Number(free.bavail) * Number(free.bsize) < need + 64 * 1024 * 1024) {
298
+ return { ok: false, reason: `not enough free space to archive ${need} bytes` };
299
+ }
300
+ const files = [];
301
+ for (const { file } of unique) {
302
+ const src = path.join(copyDir, file); const dst = path.join(temp, file);
303
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
304
+ fs.copyFileSync(src, dst, fs.constants.COPYFILE_EXCL);
305
+ const sha256 = sha256File(src);
306
+ if (sha256File(dst) !== sha256) throw new Error(`archived bytes of ${file} differ from the source`);
307
+ files.push({ file, bytes: fs.lstatSync(dst).size, sha256 });
308
+ }
309
+ fs.writeFileSync(receiptTemp, `${JSON.stringify({ schemaVersion: 1, kind: 'ruvnet-brain-kb-archive',
310
+ source: copyDir, archivedAt: new Date(now).toISOString(), files }, null, 2)}\n`, { flag: 'wx' });
311
+ if (lstat(root) || lstat(receipt)) throw new Error(`${root} appeared while archiving`);
312
+ fs.renameSync(temp, root); renamed = true;
313
+ fs.renameSync(receiptTemp, receipt);
314
+ return { ok: true, root, files, bytes: need };
315
+ } catch (error) {
316
+ fs.rmSync(temp, { recursive: true, force: true }); fs.rmSync(receiptTemp, { force: true });
317
+ if (renamed) fs.rmSync(root, { recursive: true, force: true });
318
+ return { ok: false, reason: `archive failed: ${error.message}` };
319
+ }
320
+ }
@@ -36,6 +36,7 @@ import { spawnSync } from 'node:child_process';
36
36
  import { fileURLToPath, pathToFileURL } from 'node:url';
37
37
  import { normalizeHostEvent } from './hook-input.mjs';
38
38
  import { developmentHooksSuspended } from './development-maintenance.mjs';
39
+ import { isDirectInvocation } from './entrypoint.mjs';
39
40
 
40
41
  const HERE = path.dirname(fileURLToPath(import.meta.url));
41
42
  const PLUGIN_ROOT = path.resolve(HERE, '..');
@@ -155,5 +156,5 @@ async function main() {
155
156
  return out.code;
156
157
  }
157
158
 
158
- const isMain = (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
159
+ const isMain = isDirectInvocation(import.meta.url);
159
160
  if (isMain) process.exit(await main().catch(() => 0));