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
@@ -25,7 +25,7 @@ import os from 'node:os';
25
25
  import { execFileSync } from 'node:child_process';
26
26
  import { fileURLToPath, pathToFileURL } from 'node:url';
27
27
  import { createHash, createPublicKey, verify as verifySignature } from 'node:crypto';
28
- import { fetchJsonWithRetry, fetchBytesWithRetry } from './download-retry.mjs';
28
+ import { fetchJsonWithRetry, fetchBytesWithRetry, downloadFileWithRetry } from './download-retry.mjs';
29
29
  import { extractZip, zipDeclaredBytes } from './zip-extract.mjs';
30
30
  import { applyBrainProfile, discoverStoreFamilies, readBrainProfile } from './brain-profile.mjs';
31
31
  import { acquireRefreshLock, releaseRefreshLock } from './refresh-run.mjs';
@@ -80,11 +80,26 @@ const SIGNING_PUBKEY_PEM = `-----BEGIN PUBLIC KEY-----
80
80
  MCowBQYDK2VwAyEAgse9TAtehXUvUfTrJFY2CCHiCbmelR8yCgS//sen5/w=
81
81
  -----END PUBLIC KEY-----`;
82
82
 
83
+ // Hash a file in fixed-size chunks: the bundle is ~745 MB and must never be held in memory whole.
84
+ function digestFile(file, algorithms = ['sha256']) {
85
+ const hashes = algorithms.map((algorithm) => [algorithm, createHash(algorithm)]);
86
+ const chunk = Buffer.allocUnsafe(8 * 1024 * 1024);
87
+ const fd = fs.openSync(file, 'r');
88
+ let bytes = 0;
89
+ try {
90
+ for (let read; (read = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0;) {
91
+ bytes += read;
92
+ for (const [, hash] of hashes) hash.update(chunk.subarray(0, read));
93
+ }
94
+ } finally { fs.closeSync(fd); }
95
+ return { bytes, digests: Object.fromEntries(hashes.map(([algorithm, hash]) => [algorithm, hash.digest('hex')])) };
96
+ }
97
+
83
98
  export function verifyDownloadedBundle(bundlePath, signaturePath) {
84
99
  try {
85
100
  if (!fs.existsSync(bundlePath)) return { ok: false, reason: `bundle not found: ${bundlePath}` };
86
101
  if (!fs.existsSync(signaturePath)) return { ok: false, reason: 'signature missing (fail-closed)' };
87
- const digest = createHash('sha256').update(fs.readFileSync(bundlePath)).digest('hex');
102
+ const digest = digestFile(bundlePath).digests.sha256;
88
103
  const ok = verifySignature(null, Buffer.from(digest, 'hex'), createPublicKey(SIGNING_PUBKEY_PEM), fs.readFileSync(signaturePath));
89
104
  return ok ? { ok: true, reason: `signature valid (sha256 ${digest.slice(0, 12)}…)` }
90
105
  : { ok: false, reason: 'signature does NOT match — bundle may be tampered' };
@@ -103,11 +118,20 @@ let updateLock = null;
103
118
  let updateOutcomeWritten = false;
104
119
  let lifecycleRetention = null;
105
120
  let legacyBackupRetention = null;
121
+ // The bundle's temp directory (zip, signature, extraction). Removed on EVERY exit, including die() and a signal.
122
+ let downloadDir = null;
106
123
 
107
- function writeUpdateOutcome(outcome) {
124
+ /**
125
+ * Lifecycle-evidence retention is housekeeping of receipts ABOUT updates. It never changes the verdict of
126
+ * the update itself (4.7.21 RC2.3): a `.DS_Store` or a stranded quarantine used to turn every applied and
127
+ * no-op run into `recovery-required` exit 1, and the installer then re-downloaded 745 MB every night into
128
+ * the same wall. Over budget or unsafe evidence is recorded as `retentionAdvisory`, and the verdict and exit
129
+ * code stay exactly what the update produced.
130
+ */
131
+ function writeUpdateOutcome(outcome, { prune = true } = {}) {
108
132
  if (updateOutcomeWritten) return outcome;
109
133
  if (legacyBackupRetention) outcome = { ...outcome, legacyBackupRetention };
110
- if (!lifecycleRetention) {
134
+ if (!lifecycleRetention && prune) {
111
135
  try {
112
136
  lifecycleRetention = pruneLifecycleEvidence({ brainHome: path.dirname(KB_DIR), kbDir: KB_DIR,
113
137
  preserveRefreshRunIds: updateLock?.runId ? [updateLock.runId] : [],
@@ -117,10 +141,14 @@ function writeUpdateOutcome(outcome) {
117
141
  withinBudget: false, unsafe: [{ path: path.dirname(KB_DIR), reason: error.message }] };
118
142
  }
119
143
  }
120
- const finalOutcome = lifecycleRetention.withinBudget === true ? { ...outcome, lifecycleRetention }
121
- : { ...outcome, terminalVerdict: 'recovery-required', exitCode: 1,
122
- reason: `lifecycle evidence retention failed: ${lifecycleRetention.unsafe?.map(({ reason }) => reason).join('; ') || 'budget exceeded'}`,
123
- lifecycleRetention };
144
+ let finalOutcome = lifecycleRetention ? { ...outcome, lifecycleRetention } : outcome;
145
+ if (lifecycleRetention && lifecycleRetention.withinBudget !== true) {
146
+ const retentionAdvisory = { withinBudget: false,
147
+ reason: `lifecycle evidence retention is over budget or unsafe: ${lifecycleRetention.unsafe?.map(({ path: entry, reason }) => `${entry}: ${reason}`).join('; ') || 'budget exceeded'}`,
148
+ unsafe: lifecycleRetention.unsafe?.length || 0, foreign: lifecycleRetention.foreign?.length || 0 };
149
+ finalOutcome = { ...finalOutcome, retentionAdvisory };
150
+ console.error(`\n ⚠ advisory (the update verdict is unchanged): ${retentionAdvisory.reason}`);
151
+ }
124
152
  if (!RESULT_FILE) return finalOutcome;
125
153
  fs.mkdirSync(path.dirname(RESULT_FILE), { recursive: true });
126
154
  atomicJson(RESULT_FILE, { schemaVersion: 1, kind: 'ruvnet-brain-update-result',
@@ -158,7 +186,24 @@ export function releaseUpdateLock(lock = updateLock) {
158
186
  return released;
159
187
  }
160
188
 
161
- process.on('exit', () => { releaseUpdateLock(); });
189
+ process.on('exit', () => {
190
+ releaseUpdateLock();
191
+ if (downloadDir) { try { fs.rmSync(downloadDir, { recursive: true, force: true }); } catch { /* best effort at exit */ } }
192
+ });
193
+
194
+ // A time-limit kill (SessionStart TTL, the nightly runner) sends SIGTERM to the process group. Without a
195
+ // handler Node exits without running 'exit' listeners, so the update lock stayed behind and the result
196
+ // file never said why. Settle the same way die() does: record the outcome, keep any rollback copy, exit.
197
+ function interruptedBySignal(signal) {
198
+ const exitCode = signal === 'SIGINT' ? 130 : 143;
199
+ console.error(`\n[forge-update] interrupted by ${signal} — the live KB is left as the storage transaction left it.`);
200
+ // --check records only its currency verdict; an interrupted poll has none to record.
201
+ if (APPLY || STAGED_RELEASE_FILE) {
202
+ try { writeUpdateOutcome({ terminalVerdict: 'interrupted', exitCode, reason: `interrupted by ${signal}` }, { prune: false }); } catch { /* exit wins */ }
203
+ }
204
+ try { settleRollback({ reclaimable: false, keepReason: `the update was interrupted by ${signal}` }); } catch { /* exit wins */ }
205
+ process.exit(exitCode);
206
+ }
162
207
  function settleRollback({ reclaimable, keepReason = null, intentionallyRemovedStores = [] }) {
163
208
  if (rollbackSettled) return;
164
209
  rollbackSettled = true;
@@ -781,12 +826,28 @@ async function fetchJson(url) {
781
826
  die(`network failure fetching ${url}: ${error.message} — nothing changed locally.`, 2);
782
827
  }
783
828
  }
829
+ function downloadFailed(error, url, kind, failureCode) {
830
+ if (error.status) die(`${kind} download returned HTTP ${error.status} for ${url} — nothing changed.`, failureCode);
831
+ die(`network failure downloading ${kind} ${url}: ${error.message} — nothing changed locally.`, failureCode);
832
+ }
784
833
  async function fetchBuffer(url, { failureCode = 2, kind = 'bundle' } = {}) {
785
- try { return await fetchBytesWithRetry(url); }
786
- catch (error) {
787
- if (error.status) die(`${kind} download returned HTTP ${error.status} for ${url} — nothing changed.`, failureCode);
788
- die(`network failure downloading ${kind} ${url}: ${error.message} — nothing changed locally.`, failureCode);
789
- }
834
+ try { return await fetchBytesWithRetry(url, { deadlineMs: 5 * 60_000 }); }
835
+ catch (error) { downloadFailed(error, url, kind, failureCode); }
836
+ }
837
+ // The bundle streams to disk: no-progress timeout, Range resume across retries, and a deadline scaled to
838
+ // its size (kb/download-retry.mjs) — never the old whole-attempt 5-minute cap over a buffer in memory.
839
+ async function fetchFile(url, destination, { failureCode = 2, kind = 'bundle' } = {}) {
840
+ let lastShown = -1;
841
+ try {
842
+ await downloadFileWithRetry(url, destination, {
843
+ headers: { 'User-Agent': 'ruvnet-brain-updater', Accept: 'application/octet-stream' },
844
+ onRetry: ({ attempt, error }) => console.error(` download interrupted (${error.message}); retry ${attempt + 1}/3, resuming where it stopped when the server allows`),
845
+ onProgress: ({ received, total }) => {
846
+ const pct = total ? Math.floor(received / total * 10) * 10 : -1;
847
+ if (total && pct !== lastShown) { lastShown = pct; console.log(` …${pct}% (${(received / 1e6).toFixed(0)} MB / ${(total / 1e6).toFixed(0)} MB)`); }
848
+ },
849
+ });
850
+ } catch (error) { downloadFailed(error, url, kind, failureCode); }
790
851
  }
791
852
 
792
853
  // The canonical manifest can be ONE of three shapes — handle all three:
@@ -1053,9 +1114,48 @@ function dirSize(dir) {
1053
1114
  // A backup-looking name and matching RVF paths are not evidence that its other
1054
1115
  // bytes are disposable. Legacy backups have no authenticated complete ownership
1055
1116
  // receipt: reclaim only a tree whose every entry survives identically in live.
1056
- function assertRedundantBackup(backup, live) {
1057
- const compare = (prior, current) => {
1117
+ //
1118
+ // #335: `archives` are `--clean --archive` trees (<kb>.archive/<stamp>/<copy>/). A regular file whose exact
1119
+ // bytes sit at the same relative path in one of them is accounted for even when live lacks or changed it.
1120
+ function assertRedundantBackup(backup, live, archives = []) {
1121
+ const quiet = (file) => { try { return fs.lstatSync(file); } catch { return null; } };
1122
+ // A reference (live or archive) counts only as a regular file reached through real directories, no link at
1123
+ // the root or any ancestor, whose real path is outside the backup being judged: a link into the backup would
1124
+ // compare the backup with itself and approve deleting its only copy (cross-vendor review A).
1125
+ const realBackup = (() => { try { return fs.realpathSync(backup); } catch { return path.resolve(backup); } })();
1126
+ const insideBackup = (file) => {
1127
+ let real; try { real = fs.realpathSync(file); } catch { return true; }
1128
+ return real === realBackup || real.startsWith(`${realBackup}${path.sep}`);
1129
+ };
1130
+ const reference = (root, relative) => {
1131
+ let dir = root;
1132
+ for (const part of ['', ...relative.split(path.sep).slice(0, -1)]) {
1133
+ dir = part ? path.join(dir, part) : dir;
1134
+ const st = quiet(dir);
1135
+ if (!st || !st.isDirectory() || st.isSymbolicLink()) return null;
1136
+ }
1137
+ const file = path.join(root, relative); const st = quiet(file);
1138
+ return st && st.isFile() && !st.isSymbolicLink() && !insideBackup(file) ? { file, st } : null;
1139
+ };
1140
+ const archived = (relative, prior, size) => archives.some((root) => {
1141
+ const ref = reference(root, relative);
1142
+ return Boolean(ref && ref.st.size === size && sha256File(ref.file) === sha256File(prior));
1143
+ });
1144
+ const compare = (prior, current, relative = '') => {
1058
1145
  const a = fs.lstatSync(prior);
1146
+ if (archives.length && !a.isSymbolicLink() && (a.isFile() || a.isDirectory())) {
1147
+ if (a.isDirectory()) {
1148
+ for (const name of fs.readdirSync(prior)) {
1149
+ if (/^(?:\._|\.DS_Store$)/.test(name)) continue;
1150
+ compare(path.join(prior, name), path.join(current, name), path.join(relative, name));
1151
+ }
1152
+ return;
1153
+ }
1154
+ const ref = reference(live, relative);
1155
+ const same = ref && a.size === ref.st.size && sha256File(prior) === sha256File(ref.file);
1156
+ if (same || archived(relative, prior, a.size)) return;
1157
+ throw new Error(`different bytes: ${prior}`);
1158
+ }
1059
1159
  const b = fs.lstatSync(current);
1060
1160
  if (a.isSymbolicLink() || b.isSymbolicLink()) {
1061
1161
  // Compare links themselves, never dereference them. Store symlinks are
@@ -1068,7 +1168,7 @@ function assertRedundantBackup(backup, live) {
1068
1168
  // written by an exFAT/FAT round trip is not Brain data, so it is never what makes a copy unique.
1069
1169
  for (const name of fs.readdirSync(prior)) {
1070
1170
  if (/^(?:\._|\.DS_Store$)/.test(name)) continue;
1071
- compare(path.join(prior, name), path.join(current, name));
1171
+ compare(path.join(prior, name), path.join(current, name), path.join(relative, name));
1072
1172
  }
1073
1173
  } else if (a.isFile() && b.isFile()) {
1074
1174
  if (a.size !== b.size || sha256File(prior) !== sha256File(current)) {
@@ -1183,6 +1283,12 @@ export function reclaimBackups({
1183
1283
  const safePreserved = new Map();
1184
1284
  const retentionPolicy = rollbackRetentionPolicy(kbDir, env);
1185
1285
  const liveInventory = storeInventory(kbDir);
1286
+ // #335: trees written by the explicit `npx ruvnet-brain --clean --archive` (never automatically).
1287
+ const realDir = (dir) => { try { const st = fs.lstatSync(dir); return st.isDirectory() && !st.isSymbolicLink(); } catch { return false; } };
1288
+ const listDirs = (dir) => { try { return fs.readdirSync(dir).sort().map((n) => path.join(dir, n)).filter(realDir); } catch { return []; } };
1289
+ const archiveDir = path.join(parent, `${base}.archive`);
1290
+ // A dot-named entry is an archive still being written, or one a killed sweep left half-written: never a reference.
1291
+ const archives = realDir(archiveDir) ? listDirs(archiveDir).flatMap(listDirs).filter((dir) => !path.basename(dir).startsWith('.')) : [];
1186
1292
 
1187
1293
  // A store the live release's own COVERAGE.json marks ineligible is absent from live BY POLICY
1188
1294
  // (excluded-no-corpus, fork, archived…), not lost by an update; its presence in a backup must not
@@ -1234,6 +1340,19 @@ export function reclaimBackups({
1234
1340
  if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`not a real directory: ${dir}`);
1235
1341
  }
1236
1342
  } catch (error) { kept.push([b, `unsafe reclaim target: ${error.message}`]); continue; }
1343
+ // #335: a copy whose every byte survives in live or in an archive tree is accounted for, whatever
1344
+ // its store inventory says; anything still unproven falls through to the unchanged checks below.
1345
+ let archivedCopy = false;
1346
+ if (archives.length && liveInventory.complete) {
1347
+ try { assertRedundantBackup(b, kbDir, archives); archivedCopy = true; } catch { /* not fully accounted for */ }
1348
+ }
1349
+ if (archivedCopy) {
1350
+ const size = dirSize(b);
1351
+ if (dryRun) { wouldRemove.push(b); freed += size; continue; }
1352
+ try { fs.rmSync(b, { recursive: true, force: true }); removed.push(b); freed += size; }
1353
+ catch (e) { kept.push([b, `could not remove: ${e.message}`]); }
1354
+ continue;
1355
+ }
1237
1356
  const backupInventory = storeInventory(b);
1238
1357
  if (!liveInventory.complete || !backupInventory.complete) {
1239
1358
  kept.push([b, `inventory is incomplete; refusing destructive reclaim (${backupInventory.reason || liveInventory.reason || 'unknown'})`]);
@@ -1423,14 +1542,26 @@ function archiveVerifierFromDigests(actualDigests, bytes) {
1423
1542
 
1424
1543
  // Capture the closed transaction algorithm inventory synchronously. Never retain mutable bytes:
1425
1544
  // the capability attests only to this creation-time snapshot, not a later caller-owned Buffer.
1426
- export function createDownloadedArchiveVerifier(downloadedBuffer, expectedDigests = []) {
1427
- if (!Buffer.isBuffer(downloadedBuffer) || !Array.isArray(expectedDigests)) throw new TypeError('archive bytes and digest inventory required');
1545
+ function archiveAlgorithms(expectedDigests) {
1428
1546
  const algorithms = new Set(['sha256']);
1429
1547
  for (const expected of expectedDigests) {
1430
1548
  if (expected == null) continue;
1431
1549
  if (typeof expected !== 'string' || !expected) throw new TypeError('invalid archive digest');
1432
1550
  algorithms.add(expected.includes(':') ? expected.split(':')[0] : 'sha256');
1433
1551
  }
1552
+ return algorithms;
1553
+ }
1554
+
1555
+ /** The same capability, computed from the downloaded file in chunks (the bundle is never held in memory). */
1556
+ export function createDownloadedArchiveVerifierFromFile(file, expectedDigests = []) {
1557
+ if (typeof file !== 'string' || !Array.isArray(expectedDigests)) throw new TypeError('archive path and digest inventory required');
1558
+ const { bytes, digests } = digestFile(file, [...archiveAlgorithms(expectedDigests)]);
1559
+ return archiveVerifierFromDigests(Object.freeze(digests), bytes);
1560
+ }
1561
+
1562
+ export function createDownloadedArchiveVerifier(downloadedBuffer, expectedDigests = []) {
1563
+ if (!Buffer.isBuffer(downloadedBuffer) || !Array.isArray(expectedDigests)) throw new TypeError('archive bytes and digest inventory required');
1564
+ const algorithms = archiveAlgorithms(expectedDigests);
1434
1565
  const actualDigests = Object.freeze(Object.fromEntries([...algorithms].map(algorithm =>
1435
1566
  [algorithm, createHash(algorithm).update(downloadedBuffer).digest('hex')])));
1436
1567
  return archiveVerifierFromDigests(actualDigests, downloadedBuffer.length);
@@ -1658,13 +1789,11 @@ async function main() {
1658
1789
  if (verdict.verdict === 'REFUSED') {
1659
1790
  console.log(`REFUSED — ${verdict.reason}`);
1660
1791
  console.log('Nothing was downloaded; the live brain is untouched.');
1661
- const refusedOutcome = APPLY
1662
- ? writeUpdateOutcome({ terminalVerdict: 'refused', reason: verdict.reason,
1792
+ if (APPLY) writeUpdateOutcome({ terminalVerdict: 'refused', reason: verdict.reason,
1663
1793
  currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
1664
- storeCount: targets.length })
1665
- : writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
1666
- candidateKind: candidateIdentity.kind, candidateTag: candidateIdentity.tag, storeCount: targets.length });
1667
- if (refusedOutcome?.terminalVerdict === 'recovery-required') die(refusedOutcome.reason);
1794
+ storeCount: targets.length });
1795
+ else writeCheckOutcome({ currencyVerdict: verdict.verdict, currencyReason: verdict.reason,
1796
+ candidateKind: candidateIdentity.kind, candidateTag: candidateIdentity.tag, storeCount: targets.length });
1668
1797
  process.exit(0);
1669
1798
  }
1670
1799
 
@@ -1703,11 +1832,10 @@ async function main() {
1703
1832
  if (!installed.valid) die(`already-current KB failed integrity: ${installed.failures.join('; ')}`);
1704
1833
  // No transaction paths are created: the inventory still counts every retained managed copy.
1705
1834
  const measuredDelta = storageDelta({ live: KB_DIR }, { prior: inventoryBefore.active, inventoryBefore });
1706
- const noopOutcome = writeUpdateOutcome({ terminalVerdict: 'noop', reason: 'already-current', storeCount: targets.length,
1835
+ writeUpdateOutcome({ terminalVerdict: 'noop', reason: 'already-current', storeCount: targets.length,
1707
1836
  currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
1708
1837
  storageDelta: measuredDelta,
1709
1838
  phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'noop', storageDelta: measuredDelta }) });
1710
- if (noopOutcome?.terminalVerdict === 'recovery-required') die(noopOutcome.reason);
1711
1839
  console.log(`\nNothing to apply — already current.`); process.exit(0);
1712
1840
  }
1713
1841
 
@@ -1741,27 +1869,26 @@ async function main() {
1741
1869
  const originLabel = resolved.origin === 'latest-release-asset' ? `live release asset "${resolved.assetName}"`
1742
1870
  : resolved.origin === 'live-manifest' ? 'live manifest' : 'PINNED FALLBACK (see warning above)';
1743
1871
  console.log(`\n[${behindStores.length} store(s)] downloading ${resolved.url}\n (source: ${originLabel}) ...`);
1744
- let buf = await fetchBuffer(resolved.url);
1745
- const sigBuf = await fetchBuffer(`${resolved.url}.sig`, { failureCode: 3, kind: 'signature' });
1746
1872
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-update-release-'));
1873
+ downloadDir = tmp;
1747
1874
  const zipPath = path.join(tmp, 'bundle.zip');
1748
1875
  const sigPath = path.join(tmp, 'bundle.zip.sig');
1749
1876
  const extractDir = path.join(tmp, 'extracted');
1750
- fs.writeFileSync(zipPath, buf);
1877
+ await fetchFile(resolved.url, zipPath);
1878
+ const sigBuf = await fetchBuffer(`${resolved.url}.sig`, { failureCode: 3, kind: 'signature' });
1751
1879
  fs.writeFileSync(sigPath, sigBuf);
1752
1880
  fs.mkdirSync(extractDir);
1753
- console.log(` downloaded ${(buf.length / 1e6).toFixed(1)} MB.`);
1881
+ console.log(` downloaded ${(fs.statSync(zipPath).size / 1e6).toFixed(1)} MB.`);
1754
1882
  const signature = verifyDownloadedBundle(zipPath, sigPath);
1755
1883
  if (!signature.ok) { fs.rmSync(tmp, { recursive: true, force: true }); die(`✗ SIGNATURE VERIFICATION FAILED: ${signature.reason}`, 4); }
1756
1884
  console.log(` ✓ signature verified — ${signature.reason}`);
1757
1885
  let archiveVerifier;
1758
- try { archiveVerifier = createDownloadedArchiveVerifier(buf, resolvedTargets.map(({ resolved: target }) => target.digest)); }
1886
+ try { archiveVerifier = createDownloadedArchiveVerifierFromFile(zipPath, resolvedTargets.map(({ resolved: target }) => target.digest)); }
1759
1887
  catch (error) {
1760
1888
  fs.rmSync(tmp, { recursive: true, force: true });
1761
1889
  die(`archive digest snapshot failed: ${error.message} — local files untouched.`, 4);
1762
1890
  }
1763
1891
  const bundleSha256 = archiveVerifier.digest();
1764
- buf = null; // Snapshot owns only immutable digest strings; release the downloaded bytes before validation.
1765
1892
  // DISK-SPACE PREFLIGHT, before a single byte is unpacked: the bundle unpacks in temp, then a whole
1766
1893
  // candidate generation is built beside the live one: the bundle, plus what prepareCandidate carries into
1767
1894
  // it from live — node_modules, and every private/local-ingest store file (restorePrivateFilesIntoCandidate
@@ -1916,12 +2043,11 @@ async function main() {
1916
2043
  fs.rmSync(tmp, { recursive: true, force: true });
1917
2044
  console.log(` storage transaction: ${transaction.terminalVerdict}`);
1918
2045
  if (transaction.terminalVerdict === 'cleanup-pending') {
1919
- const cleanupOutcome = writeUpdateOutcome({ terminalVerdict: 'cleanup-pending', storageDelta: transaction.storageDelta,
2046
+ writeUpdateOutcome({ terminalVerdict: 'cleanup-pending', storageDelta: transaction.storageDelta,
1920
2047
  transactionReceipts: transaction.paths.receipts, bundleSha256,
1921
2048
  phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'cleanup-pending', bundleSha256,
1922
2049
  transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
1923
2050
  storageDelta: transaction.storageDelta }) });
1924
- if (cleanupOutcome?.terminalVerdict === 'recovery-required') die(cleanupOutcome.reason);
1925
2051
  console.error('\nVerified live generation is active, but redundant rollback cleanup is pending.');
1926
2052
  process.exitCode = 12;
1927
2053
  return;
@@ -1986,7 +2112,7 @@ async function main() {
1986
2112
  console.log(`\n${unchangedStores.length} of ${behindStores.length} store(s) were already at the canonical build and did not change: ${unchangedStores.join(', ')}`);
1987
2113
  console.log(` (stores are forged independently — an unchanged store means its upstream repo did not move, not a failed update.)`);
1988
2114
  }
1989
- const finalOutcome = writeUpdateOutcome({ terminalVerdict: transaction.terminalVerdict, storeCount: behindStores.length, retiredStores,
2115
+ writeUpdateOutcome({ terminalVerdict: transaction.terminalVerdict, storeCount: behindStores.length, retiredStores,
1990
2116
  currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
1991
2117
  storageDelta: transaction.storageDelta,
1992
2118
  transactionReceipts: transaction.paths.receipts,
@@ -1996,7 +2122,6 @@ async function main() {
1996
2122
  bundleSha256,
1997
2123
  transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
1998
2124
  storageDelta: transaction.storageDelta }) });
1999
- if (finalOutcome?.terminalVerdict === 'recovery-required') die(finalOutcome.reason);
2000
2125
  console.log(`\n(the above is read back from disk, verified — not a tag lookup)`);
2001
2126
  process.exit(0);
2002
2127
  }
@@ -2005,7 +2130,10 @@ async function main() {
2005
2130
  // guard merely importing this file would start a live update — a network fetch, a directory swap, and
2006
2131
  // a process.exit() inside whatever imported it. (Found exactly that way: the reclaim test's import
2007
2132
  // began racing a real update against the test run.)
2008
- const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
2133
+ // Ships in the brain bundle, where only kb/ modules exist, so it cannot import
2134
+ // plugin/scripts/entrypoint.mjs; this is that helper's comparison, inline: real paths on both sides.
2135
+ const invokedDirectly = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
2136
+ if (invokedDirectly) for (const signal of ['SIGTERM', 'SIGINT']) process.once(signal, () => interruptedBySignal(signal));
2009
2137
  if (invokedDirectly && STAGED_RELEASE_FILE) {
2010
2138
  (async () => {
2011
2139
  const input = JSON.parse(fs.readFileSync(STAGED_RELEASE_FILE, 'utf8'));
@@ -8,6 +8,15 @@ const TERMINAL_TRANSACTION = new Set(['NOOP', 'COMMITTED', 'ROLLED_BACK']);
8
8
  const SAFE_ID = /^[A-Za-z0-9._-]+$/;
9
9
  const PHASE_FILE = /^\d{3}-[A-Z_]+\.json$/;
10
10
  const QUARANTINE = /^\.prune-[A-Za-z0-9._-]+-[a-f0-9]{16}$/;
11
+ // refresh-run.mjs atomicJson(): `<runId>.json.tmp-<pid>-<8 hex>`, written, fsynced, then renamed (#434).
12
+ const ATOMIC_TEMP = /^([A-Za-z0-9._-]+)\.json\.tmp-(\d+)-([a-f0-9]{8})$/;
13
+ // The write+rename takes milliseconds; a temp this old was abandoned by a crash or a failed write,
14
+ // whatever its PID (PIDs are reused, so the age is the evidence, not process liveness).
15
+ export const ABANDONED_TEMP_AGE_MS = 60 * 60 * 1000;
16
+ // Files an operating system or a file browser drops into any folder it shows (Finder, exFAT/SMB shadows,
17
+ // Explorer). They are never evidence and never ours to judge: ignored, and removed when a plain file.
18
+ const VOLUME_METADATA = /^(?:\.DS_Store|\._.*|Thumbs\.db|desktop\.ini)$/i;
19
+ export const isVolumeMetadata = (name) => VOLUME_METADATA.test(String(name));
11
20
 
12
21
  export const LIFECYCLE_EVIDENCE_RETENTION_POLICY = Object.freeze({
13
22
  schemaVersion: 1,
@@ -49,17 +58,85 @@ function stamp(value, label) {
49
58
  return parsed;
50
59
  }
51
60
 
52
- function scanRefresh(root, unsafe) {
61
+ /**
62
+ * An abandoned atomic-write temporary (#434) is classified, not treated as foreign evidence. It is
63
+ * reclaimable only when it is an exclusive regular file older than ABANDONED_TEMP_AGE_MS and either
64
+ * empty or shadowed by a valid canonical receipt of the same run; a young one is an in-flight write
65
+ * (left alone), and a non-empty orphan with no canonical receipt is preserved as unsafe.
66
+ */
67
+ function classifyAtomicTemp(root, file, match, nowMs, unsafe, temporaries) {
68
+ const stat = fs.lstatSync(file);
69
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('atomic-write temporary is not an exclusive regular file');
70
+ const identity = { dev: stat.dev, ino: stat.ino, size: stat.size, mtimeMs: stat.mtimeMs };
71
+ const row = { path: file, runId: match[1], writerPid: Number(match[2]), bytes: stat.size, identity,
72
+ sha256: crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') };
73
+ if (!(nowMs - stat.mtimeMs >= ABANDONED_TEMP_AGE_MS)) { temporaries.push({ ...row, disposition: 'in-flight' }); return; }
74
+ let canonical = false;
75
+ try {
76
+ const { value } = regularJson(path.join(root, `${match[1]}.json`));
77
+ canonical = value?.kind === 'ruvnet-brain-refresh-run' && value.runId === match[1];
78
+ } catch { /* no valid canonical receipt */ }
79
+ if (stat.size !== 0 && !canonical) {
80
+ unsafe.push({ path: file, reason: 'abandoned atomic-write temporary has content and no canonical receipt; preserved' });
81
+ return;
82
+ }
83
+ temporaries.push({ ...row, disposition: 'abandoned', canonicalReceipt: canonical });
84
+ }
85
+
86
+ /**
87
+ * A `.prune-<id>-<16 hex>` entry is the rename quarantineAndRemove() makes immediately before it deletes.
88
+ * Only an interruption between the two leaves one behind (a time-limit kill of the updater is the realistic
89
+ * cause, 4.7.21 RC2.3). It is reclaimed when it is older than ABANDONED_TEMP_AGE_MS (no prune is still
90
+ * between rename and delete), has the exact shape this module quarantines (a plain file under refresh-runs;
91
+ * a directory of phase files under the transactions root), and the run it came from is not live again
92
+ * (its canonical entry is absent or terminal). Anything else stays unsafe and is preserved, as before.
93
+ */
94
+ function classifyQuarantine(root, entry, name, kind, nowMs, unsafe, quarantines) {
95
+ const preserve = (reason) => unsafe.push({ path: entry, reason });
96
+ try {
97
+ const stat = fs.lstatSync(entry);
98
+ const ageMs = nowMs - Math.max(stat.mtimeMs, stat.ctimeMs);
99
+ if (!(ageMs >= ABANDONED_TEMP_AGE_MS)) return preserve('recognized pruning quarantine remains');
100
+ const id = /^\.prune-(.+)-[a-f0-9]{16}$/.exec(name)[1];
101
+ if (kind === 'refresh') {
102
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return preserve('recognized pruning quarantine remains (not a quarantined receipt)');
103
+ try {
104
+ const { value } = regularJson(path.join(root, `${id}.json`));
105
+ if (!TERMINAL_REFRESH.has(value?.status)) return preserve('recognized pruning quarantine remains (its run is active)');
106
+ } catch (error) { if (error?.code !== 'ENOENT') return preserve('recognized pruning quarantine remains (its run receipt is unreadable)'); }
107
+ } else {
108
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return preserve('recognized pruning quarantine remains (not a quarantined transaction)');
109
+ const names = fs.readdirSync(entry).filter((file) => !isVolumeMetadata(file));
110
+ if (names.some((file) => !PHASE_FILE.test(file))) return preserve('recognized pruning quarantine remains (not a quarantined transaction)');
111
+ if (fs.existsSync(path.join(root, id))) return preserve('recognized pruning quarantine remains (its transaction is present)');
112
+ }
113
+ quarantines.push({ path: entry, kind, id, bytes: treeBytes(entry), identity: { dev: stat.dev, ino: stat.ino } });
114
+ } catch (error) { preserve(`recognized pruning quarantine remains (${error.message})`); }
115
+ }
116
+
117
+ function scanRefresh(root, unsafe, temporaries = [], nowMs = Date.now(), extras = { quarantines: [], foreign: [], volumeMetadata: [] }) {
53
118
  if (!fs.existsSync(root)) return [];
54
119
  const rows = [];
55
120
  for (const name of fs.readdirSync(root).sort()) {
56
121
  const file = path.join(root, name);
122
+ if (isVolumeMetadata(name)) { extras.volumeMetadata.push(file); continue; }
57
123
  if (QUARANTINE.test(name)) {
58
- unsafe.push({ path: file, reason: 'recognized pruning quarantine remains' });
124
+ classifyQuarantine(root, file, name, 'refresh', nowMs, unsafe, extras.quarantines);
125
+ continue;
126
+ }
127
+ const temp = ATOMIC_TEMP.exec(name);
128
+ if (temp) {
129
+ try { classifyAtomicTemp(root, file, temp, nowMs, unsafe, temporaries); } catch (error) { unsafe.push({ path: file, reason: error.message }); }
59
130
  continue;
60
131
  }
61
132
  try {
62
- if (!name.endsWith('.json')) throw new Error('unexpected refresh evidence entry');
133
+ if (!name.endsWith('.json')) {
134
+ // Not evidence and not ours (a note, a copy someone made): reported and kept, never a reason to
135
+ // fail an update. Only a non-file (directory, link, device) stays unsafe.
136
+ const stat = fs.lstatSync(file);
137
+ if (stat.isFile() && !stat.isSymbolicLink()) { extras.foreign.push({ path: file, bytes: stat.size }); continue; }
138
+ throw new Error('unexpected refresh evidence entry');
139
+ }
63
140
  const { value: receipt, bytes } = regularJson(file);
64
141
  const runId = name.slice(0, -5);
65
142
  if (!SAFE_ID.test(runId) || receipt?.schemaVersion !== 3 || receipt?.kind !== 'ruvnet-brain-refresh-run'
@@ -73,21 +150,25 @@ function scanRefresh(root, unsafe) {
73
150
  return rows;
74
151
  }
75
152
 
76
- function scanTransactions(root, unsafe) {
153
+ function scanTransactions(root, unsafe, nowMs = Date.now(), extras = { quarantines: [], foreign: [], volumeMetadata: [] }) {
77
154
  if (!fs.existsSync(root)) return [];
78
155
  const rows = [];
79
156
  for (const name of fs.readdirSync(root).sort()) {
80
157
  const dir = path.join(root, name);
158
+ if (isVolumeMetadata(name)) { extras.volumeMetadata.push(dir); continue; }
81
159
  if (QUARANTINE.test(name)) {
82
- unsafe.push({ path: dir, reason: 'recognized pruning quarantine remains' });
160
+ classifyQuarantine(root, dir, name, 'transaction', nowMs, unsafe, extras.quarantines);
83
161
  continue;
84
162
  }
85
163
  try {
86
164
  const stat = fs.lstatSync(dir);
165
+ if (stat.isFile() && !stat.isSymbolicLink()) { extras.foreign.push({ path: dir, bytes: stat.size }); continue; }
87
166
  if (!SAFE_ID.test(name) || !stat.isDirectory() || stat.isSymbolicLink()) {
88
167
  throw new Error('malformed transaction evidence entry');
89
168
  }
90
- const names = fs.readdirSync(dir).sort();
169
+ const all = fs.readdirSync(dir).sort();
170
+ for (const file of all) if (isVolumeMetadata(file)) extras.volumeMetadata.push(path.join(dir, file));
171
+ const names = all.filter((file) => !isVolumeMetadata(file));
91
172
  if (!names.length || names.some((file) => !PHASE_FILE.test(file))) throw new Error('transaction phase inventory is malformed');
92
173
  const phases = names.map((file) => regularJson(path.join(dir, file)).value);
93
174
  const latest = phases.at(-1);
@@ -137,8 +218,11 @@ function trustedEvidenceRoot(root, unsafe) {
137
218
  function snapshot(options) {
138
219
  const roots = rootsFor(options);
139
220
  const unsafe = [];
140
- const refresh = trustedEvidenceRoot(roots.refresh, unsafe) ? scanRefresh(roots.refresh, unsafe) : [];
141
- const transactions = trustedEvidenceRoot(roots.transactions, unsafe) ? scanTransactions(roots.transactions, unsafe) : [];
221
+ const temporaries = [];
222
+ const extras = { quarantines: [], foreign: [], volumeMetadata: [] };
223
+ const nowMs = Date.parse((options.now || (() => new Date().toISOString()))());
224
+ const refresh = trustedEvidenceRoot(roots.refresh, unsafe) ? scanRefresh(roots.refresh, unsafe, temporaries, nowMs, extras) : [];
225
+ const transactions = trustedEvidenceRoot(roots.transactions, unsafe) ? scanTransactions(roots.transactions, unsafe, nowMs, extras) : [];
142
226
  const preserveRefresh = new Set((options.preserveRefreshRunIds || []).map(String));
143
227
  const preserveTransactions = new Set((options.preserveTransactionPaths || []).map((entry) => physicalPath(entry)));
144
228
  const protectedRefresh = new Map();
@@ -170,11 +254,11 @@ function snapshot(options) {
170
254
  if (preserveTransactions.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'explicitly preserved transaction');
171
255
  if (retainedTransactionPaths.has(physicalPath(row.path))) protectedTransactions.set(row.path, 'referenced by retained refresh receipt');
172
256
  }
173
- const bytes = [...refresh, ...transactions].reduce((sum, row) => sum + row.bytes, 0)
257
+ const bytes = [...refresh, ...transactions, ...temporaries, ...extras.quarantines].reduce((sum, row) => sum + row.bytes, 0)
174
258
  + unsafe.filter(({ reason }) => /quarantine remains/.test(reason)).reduce((sum, { path: entry }) => {
175
259
  try { return sum + treeBytes(entry); } catch { return sum; }
176
260
  }, 0);
177
- return { roots, unsafe, refresh, transactions, bytes, protectedRefresh, protectedTransactions };
261
+ return { roots, unsafe, refresh, transactions, temporaries, bytes, protectedRefresh, protectedTransactions, ...extras };
178
262
  }
179
263
 
180
264
  function counts(state) {
@@ -202,6 +286,10 @@ export function assessLifecycleEvidence(options = {}) {
202
286
  && !state.protectedTransactions.has(row.path)).sort((a, b) => a.timestamp - b.timestamp)
203
287
  .map(({ path: entryPath }) => entryPath),
204
288
  },
289
+ abandonedTemporaries: state.temporaries.filter((row) => row.disposition === 'abandoned')
290
+ .map(({ path: entryPath, runId, writerPid, bytes, sha256, canonicalReceipt }) => ({ path: entryPath, runId, writerPid, bytes, sha256, canonicalReceipt })),
291
+ abandonedQuarantines: state.quarantines.map(({ path: entryPath, kind, id, bytes }) => ({ path: entryPath, kind, id, bytes })),
292
+ foreign: state.foreign, volumeMetadata: state.volumeMetadata,
205
293
  removed: { refresh: [], transactions: [] }, after: counts(state), unsafe: state.unsafe,
206
294
  withinBudget: isWithin(state, policy) };
207
295
  }
@@ -213,9 +301,11 @@ function quarantineAndRemove(entry, root, removeEntry, afterQuarantine) {
213
301
  throw new Error(`refusing unsafe lifecycle evidence removal: ${entry}`);
214
302
  }
215
303
  const quarantine = path.join(root, `.prune-${name.replace(/\.json$/, '')}-${crypto.randomBytes(8).toString('hex')}`);
216
- fs.renameSync(resolved, quarantine);
304
+ // Vanished since the snapshot (another sweep, the writer itself): already gone, not a failed prune.
305
+ try { fs.renameSync(resolved, quarantine); } catch (error) { if (error?.code === 'ENOENT') return false; throw error; }
217
306
  afterQuarantine?.({ entry: resolved, quarantine });
218
307
  removeEntry(quarantine);
308
+ return true;
219
309
  }
220
310
 
221
311
  // Keep every JSON token (including numeric precision and escaped string spelling).
@@ -223,6 +313,7 @@ function quarantineAndRemove(entry, root, removeEntry, afterQuarantine) {
223
313
  function compactTerminalPhases(row) {
224
314
  const compacted = [];
225
315
  for (const name of fs.readdirSync(row.path).sort()) {
316
+ if (isVolumeMetadata(name)) continue;
226
317
  const file = path.join(row.path, name);
227
318
  const stat = fs.lstatSync(file);
228
319
  if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('phase is not an exclusive regular file');
@@ -257,6 +348,7 @@ function compactTerminalPhases(row) {
257
348
  return compacted;
258
349
  }
259
350
 
351
+ export const MAX_VANISHED_RETRIES = 64;
260
352
  export function pruneLifecycleEvidence(options = {}) {
261
353
  const policy = options.policy || LIFECYCLE_EVIDENCE_RETENTION_POLICY;
262
354
  const removeEntry = options.removeEntry || ((entry) => fs.rmSync(entry, { recursive: true, force: true }));
@@ -265,6 +357,40 @@ export function pruneLifecycleEvidence(options = {}) {
265
357
  // the exact entry it just quarantined. Recovery must not guess away private data.
266
358
  const initial = assessLifecycleEvidence({ ...options, policy });
267
359
  const removed = { refresh: [], transactions: [] };
360
+ // #434: reclaim abandoned atomic-write temporaries first (reported with size and sha256, never
361
+ // silent). The file identity is rechecked immediately before it is quarantined.
362
+ const reclaimedTemporaries = [];
363
+ for (const row of snapshot(options).temporaries.filter((entry) => entry.disposition === 'abandoned')) {
364
+ let current;
365
+ // A temp that vanished between the snapshot and this re-check is already gone; it must not throw the whole prune.
366
+ try { current = fs.lstatSync(row.path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
367
+ if (!current.isFile() || current.isSymbolicLink() || current.nlink !== 1 || current.dev !== row.identity.dev
368
+ || current.ino !== row.identity.ino || current.size !== row.identity.size || current.mtimeMs !== row.identity.mtimeMs) continue;
369
+ if (!quarantineAndRemove(row.path, path.dirname(row.path), removeEntry, options.afterQuarantine)) continue;
370
+ reclaimedTemporaries.push({ path: row.path, runId: row.runId, writerPid: row.writerPid, bytes: row.bytes,
371
+ sha256: row.sha256, canonicalReceipt: row.canonicalReceipt });
372
+ }
373
+ // Volume metadata (.DS_Store and friends) is removed when it is a plain file; anything else by that
374
+ // name is left alone (it was never counted as evidence either way).
375
+ const removedVolumeMetadata = [];
376
+ for (const entry of snapshot(options).volumeMetadata) {
377
+ try {
378
+ const stat = fs.lstatSync(entry);
379
+ if (!stat.isFile() || stat.isSymbolicLink()) continue;
380
+ fs.rmSync(entry, { force: true });
381
+ removedVolumeMetadata.push(entry);
382
+ } catch (error) { if (error?.code !== 'ENOENT') throw error; }
383
+ }
384
+ // Abandoned quarantines of this module's own interrupted prunes (classifyQuarantine), identity re-checked
385
+ // immediately before removal and reported, never silent.
386
+ const reclaimedQuarantines = [];
387
+ for (const row of snapshot(options).quarantines) {
388
+ let current;
389
+ try { current = fs.lstatSync(row.path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
390
+ if (current.isSymbolicLink() || current.dev !== row.identity.dev || current.ino !== row.identity.ino) continue;
391
+ removeEntry(row.path);
392
+ reclaimedQuarantines.push({ path: row.path, kind: row.kind, id: row.id, bytes: row.bytes });
393
+ }
268
394
  const compacted = [];
269
395
  const compactState = snapshot(options);
270
396
  if (!compactState.unsafe.length && compactState.bytes > policy.maxEvidenceBytes) {
@@ -272,6 +398,10 @@ export function pruneLifecycleEvidence(options = {}) {
272
398
  compacted.push(...compactTerminalPhases(row));
273
399
  }
274
400
  }
401
+ // Review r2 (4.7.21): an entry that "vanishes" on every rename (a filesystem that keeps listing it, a writer that
402
+ // keeps recreating it) must not spin this loop forever. Bounded; when the bound trips, pruning stops (fails closed:
403
+ // nothing more is removed) and the reason is returned.
404
+ let vanishedRetries = 0; let stopped = null;
275
405
  for (;;) {
276
406
  const state = snapshot(options);
277
407
  if (isWithin(state, policy)) break;
@@ -285,10 +415,17 @@ export function pruneLifecycleEvidence(options = {}) {
285
415
  : [...refreshCandidates, ...transactionCandidates].sort((a, b) => a.timestamp - b.timestamp)[0];
286
416
  if (!candidate) break;
287
417
  const root = candidate.kind === 'refresh' ? state.roots.refresh : state.roots.transactions;
288
- quarantineAndRemove(candidate.path, root, removeEntry, options.afterQuarantine);
418
+ if (!quarantineAndRemove(candidate.path, root, removeEntry, options.afterQuarantine)) { // gone: re-snapshot
419
+ vanishedRetries += 1;
420
+ if (vanishedRetries > MAX_VANISHED_RETRIES) {
421
+ stopped = `pruning stopped: lifecycle evidence vanished ${vanishedRetries} times while being removed (last: ${candidate.path}); nothing further was removed`;
422
+ break;
423
+ }
424
+ continue;
425
+ }
289
426
  removed[candidate.kind === 'refresh' ? 'refresh' : 'transactions'].push(candidate.path);
290
427
  }
291
428
  const final = assessLifecycleEvidence({ ...options, policy });
292
- return { ...final, before: initial.before, removed, compacted,
293
- after: final.after, withinBudget: final.withinBudget };
429
+ return { ...final, before: initial.before, removed, reclaimedTemporaries, reclaimedQuarantines, removedVolumeMetadata, compacted,
430
+ after: final.after, withinBudget: final.withinBudget, ...(stopped ? { stopped } : {}) };
294
431
  }