ruvnet-brain 4.7.20 → 4.7.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (260) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +618 -104
  3. package/console/activity.js +122 -38
  4. package/kb/download-retry.mjs +93 -21
  5. package/kb/forge-guard-injection.mjs +277 -0
  6. package/kb/forge-update.mjs +166 -38
  7. package/kb/lifecycle-evidence-retention.mjs +151 -14
  8. package/kb/model-requirements.mjs +4 -1
  9. package/kb/refresh-run.mjs +175 -27
  10. package/kb/verify-citation.mjs +15 -2
  11. package/package.json +3 -1
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  15. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  16. package/plugin/scripts/advisory-contract.mjs +21 -0
  17. package/plugin/scripts/advocacy-route.mjs +5 -4
  18. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  19. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  20. package/plugin/scripts/agentdb-recall.mjs +49 -17
  21. package/plugin/scripts/automatic-update.mjs +9 -1
  22. package/plugin/scripts/brain-confirmation.mjs +2 -1
  23. package/plugin/scripts/brain-footprint.mjs +72 -11
  24. package/plugin/scripts/capability-registry.mjs +1 -1
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  26. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  27. package/plugin/scripts/completion-claim-evidence.mjs +241 -23
  28. package/plugin/scripts/continuation-gate.mjs +75 -11
  29. package/plugin/scripts/decision-gate.mjs +13 -63
  30. package/plugin/scripts/decision-outcomes.mjs +36 -0
  31. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  32. package/plugin/scripts/decision-record.mjs +8 -3
  33. package/plugin/scripts/degradation-watch.mjs +2 -1
  34. package/plugin/scripts/developer-update-lock.mjs +172 -33
  35. package/plugin/scripts/developer-update-owner.mjs +8 -5
  36. package/plugin/scripts/developer-update-policy.mjs +4 -1
  37. package/plugin/scripts/developer-update.mjs +155 -53
  38. package/plugin/scripts/doc-currency.mjs +2 -16
  39. package/plugin/scripts/duplicate-gate.mjs +2 -1
  40. package/plugin/scripts/entrypoint.mjs +34 -0
  41. package/plugin/scripts/footprint-io.mjs +135 -8
  42. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  43. package/plugin/scripts/ground-ruvnet.sh +6 -3
  44. package/plugin/scripts/grounding-answer.mjs +2 -1
  45. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  46. package/plugin/scripts/grounding-substance.mjs +2 -1
  47. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  48. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  49. package/plugin/scripts/hook-context-budget.mjs +2 -1
  50. package/plugin/scripts/hook-input.mjs +2 -2
  51. package/plugin/scripts/hook-shim.mjs +9 -2
  52. package/plugin/scripts/host-update.mjs +29 -5
  53. package/plugin/scripts/identifier-preflight.mjs +2 -1
  54. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  55. package/plugin/scripts/learn-capture.mjs +2 -1
  56. package/plugin/scripts/learning-store.mjs +62 -16
  57. package/plugin/scripts/lesson-bridge.mjs +2 -2
  58. package/plugin/scripts/lesson-gate.mjs +7 -39
  59. package/plugin/scripts/lesson-promote.mjs +83 -22
  60. package/plugin/scripts/lesson-scope.mjs +51 -0
  61. package/plugin/scripts/lesson-store.mjs +1 -1
  62. package/plugin/scripts/md-stamp.mjs +2 -1
  63. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  64. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  65. package/plugin/scripts/package-recommender.mjs +2 -2
  66. package/plugin/scripts/project-store-resolver.mjs +19 -0
  67. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  68. package/plugin/scripts/ruflo-bin.mjs +14 -6
  69. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  70. package/plugin/scripts/session-snapshot-hook.mjs +25 -5
  71. package/plugin/scripts/session-start-core.mjs +7 -16
  72. package/plugin/scripts/session-start-health.mjs +98 -10
  73. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  74. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  75. package/plugin/scripts/signal-watch.mjs +2 -1
  76. package/plugin/scripts/spend-guard.mjs +2 -1
  77. package/plugin/scripts/substitution-wall.mjs +2 -1
  78. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  79. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  80. package/plugin/scripts/update-apply.mjs +186 -15
  81. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  82. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  83. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  84. package/scripts/adr-072-completion.mjs +2 -1
  85. package/scripts/agentdb-context.mjs +2 -1
  86. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  87. package/scripts/approved-runtime.mjs +2 -1
  88. package/scripts/architecture-review-lock.mjs +2 -1
  89. package/scripts/ascii-drift.mjs +2 -1
  90. package/scripts/brain-latency-50.mjs +2 -1
  91. package/scripts/brain-novice-50.mjs +2 -16
  92. package/scripts/brain-score.mjs +13 -2
  93. package/scripts/build-bundle.mjs +2 -1
  94. package/scripts/build-concepts.mjs +2 -1
  95. package/scripts/candidate-ci-receipt.mjs +2 -1
  96. package/scripts/candidate-host-evidence.mjs +2 -1
  97. package/scripts/card-from-source.mjs +2 -1
  98. package/scripts/ci/warm-brain-models.mjs +2 -1
  99. package/scripts/claims-verify.mjs +7 -3
  100. package/scripts/claude-controlled-terminal.mjs +2 -1
  101. package/scripts/claude-terminal-mod.mjs +2 -1
  102. package/scripts/code-release-corpus.mjs +2 -1
  103. package/scripts/codex-boot-surface-proof.mjs +208 -0
  104. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  105. package/scripts/codex-hook-trust.mjs +40 -1
  106. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  107. package/scripts/codex-host-execution-proof.mjs +2 -1
  108. package/scripts/completion-claim-replay.mjs +2 -1
  109. package/scripts/console-engine.mjs +8 -3
  110. package/scripts/corpus-canary.mjs +2 -1
  111. package/scripts/corpus-candidate.mjs +2 -2
  112. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  113. package/scripts/corpus-dispatch-decision.mjs +2 -1
  114. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  115. package/scripts/corpus-next-seed.mjs +2 -1
  116. package/scripts/corpus-qa.mjs +2 -1
  117. package/scripts/corpus-reconcile.mjs +2 -2
  118. package/scripts/corpus-watchdog.mjs +2 -1
  119. package/scripts/count-chunks.mjs +2 -1
  120. package/scripts/customer-state-matrix.mjs +2 -1
  121. package/scripts/derive-passage-content-map.mjs +2 -1
  122. package/scripts/development-push-check.mjs +2 -1
  123. package/scripts/dispatch-receipt.mjs +2 -1
  124. package/scripts/doc-currency.mjs +2 -1
  125. package/scripts/dream-issue-gate.mjs +2 -1
  126. package/scripts/dual-host-deliberation.mjs +2 -16
  127. package/scripts/dual-host-suggest.mjs +2 -16
  128. package/scripts/eval-brain.mjs +7 -3
  129. package/scripts/execution-policy.mjs +2 -1
  130. package/scripts/execution-preflight.mjs +2 -1
  131. package/scripts/falsify.mjs +2 -1
  132. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  133. package/scripts/fix-workstream.mjs +2 -1
  134. package/scripts/full-hints.mjs +2 -1
  135. package/scripts/full-suite-gate.mjs +2 -1
  136. package/scripts/grok-host.mjs +68 -0
  137. package/scripts/hook-qualify-core.mjs +3 -1
  138. package/scripts/hook-qualify.mjs +2 -1
  139. package/scripts/hook-retirement-check.mjs +2 -1
  140. package/scripts/host-registry.mjs +2 -1
  141. package/scripts/independent-review-receipt.mjs +2 -1
  142. package/scripts/ingest-new-repos.mjs +2 -1
  143. package/scripts/ingest-repo.mjs +12 -2
  144. package/scripts/integration-evidence.mjs +2 -1
  145. package/scripts/issue-fix.mjs +7 -24
  146. package/scripts/issue-watch.mjs +116 -40
  147. package/scripts/job-heartbeat.sh +54 -19
  148. package/scripts/key-canary.mjs +9 -3
  149. package/scripts/latency-to-surface.mjs +2 -1
  150. package/scripts/learning-replay-cli.mjs +97 -19
  151. package/scripts/learning-replay-execution.mjs +2 -0
  152. package/scripts/learning-replay-fixture.mjs +3 -0
  153. package/scripts/learning-replay-proof.mjs +14 -13
  154. package/scripts/learning-replay.mjs +3 -3
  155. package/scripts/learnings.mjs +2 -1
  156. package/scripts/lesson-ratify.mjs +88 -40
  157. package/scripts/loop-checkpoint.mjs +2 -1
  158. package/scripts/metaharness-receipts.mjs +2 -1
  159. package/scripts/model-catalog.mjs +2 -1
  160. package/scripts/model-currency-evidence.mjs +6 -4
  161. package/scripts/model-currency.mjs +33 -3
  162. package/scripts/model-native-catalog.mjs +2 -1
  163. package/scripts/model-router-agent-hook.mjs +2 -1
  164. package/scripts/model-router-dispatch.mjs +2 -1
  165. package/scripts/model-router-engine.mjs +2 -1
  166. package/scripts/model-router-outcome.mjs +2 -1
  167. package/scripts/model-router-setup.mjs +2 -1
  168. package/scripts/model-routing-eval.mjs +2 -1
  169. package/scripts/model-routing-gateway.mjs +2 -1
  170. package/scripts/model-routing-launchers.mjs +4 -1
  171. package/scripts/model-terminal-gateway.mjs +2 -1
  172. package/scripts/model-terminal-launchers.mjs +2 -1
  173. package/scripts/model-weekly-analyst.mjs +2 -1
  174. package/scripts/model-weekly-cycle.mjs +2 -1
  175. package/scripts/model-weekly-qualification.mjs +2 -1
  176. package/scripts/nightly-gists.sh +26 -20
  177. package/scripts/nightly-two-run-proof.mjs +2 -1
  178. package/scripts/nightly-watchdog.mjs +158 -47
  179. package/scripts/no-silent-substitution.mjs +2 -1
  180. package/scripts/notify.sh +11 -6
  181. package/scripts/ntfy-inbox.mjs +41 -18
  182. package/scripts/onboarding-console.mjs +123 -23
  183. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  184. package/scripts/oracle/abstain-trace.mjs +2 -1
  185. package/scripts/oracle/doc2query-generate.mjs +2 -1
  186. package/scripts/oracle/doc2query-reach.mjs +2 -1
  187. package/scripts/oracle/judge-train.mjs +2 -1
  188. package/scripts/oracle/need-set-split.mjs +2 -1
  189. package/scripts/oracle/produce-questions.mjs +2 -16
  190. package/scripts/oracle/repo-recall.mjs +2 -2
  191. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  192. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  193. package/scripts/oracle/source-tree.mjs +2 -15
  194. package/scripts/oracle/source-units.mjs +2 -16
  195. package/scripts/oracle/spike-run.mjs +2 -16
  196. package/scripts/oracle/validate-labels.mjs +2 -16
  197. package/scripts/org-repo-count.mjs +2 -1
  198. package/scripts/package-cards.mjs +2 -1
  199. package/scripts/prepublication-evidence.mjs +2 -1
  200. package/scripts/private-overlay.mjs +2 -1
  201. package/scripts/product-integrity-contract.mjs +2 -1
  202. package/scripts/protected-release-invocation.mjs +2 -1
  203. package/scripts/public-verification-abandon.mjs +2 -1
  204. package/scripts/public-verification-aggregate.mjs +2 -1
  205. package/scripts/public-verification-finalizer.mjs +2 -1
  206. package/scripts/public-verification-inputs.mjs +2 -1
  207. package/scripts/public-verification-lane.mjs +2 -1
  208. package/scripts/publication-receipt.mjs +2 -1
  209. package/scripts/published-surface-probe.mjs +2 -1
  210. package/scripts/qe/card-lane-gate.mjs +2 -16
  211. package/scripts/qe/session-start-gate.mjs +2 -16
  212. package/scripts/qe/ux-suite.mjs +2 -1
  213. package/scripts/qualified-candidate-check.mjs +2 -1
  214. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  215. package/scripts/recall-latency-probe.mjs +48 -0
  216. package/scripts/recommendation-eval.mjs +2 -1
  217. package/scripts/recommendation-floor.mjs +2 -1
  218. package/scripts/recommendation-judge-score.mjs +2 -1
  219. package/scripts/recommendation-real-host-score.mjs +2 -1
  220. package/scripts/recommendation-real-host.mjs +2 -1
  221. package/scripts/refresh-capability-only-store.mjs +2 -1
  222. package/scripts/refresh-model-catalog.mjs +2 -1
  223. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  224. package/scripts/release-authority.mjs +2 -1
  225. package/scripts/release-evidence-aggregate.mjs +2 -1
  226. package/scripts/release-payload.mjs +2 -1
  227. package/scripts/release-projection.mjs +2 -1
  228. package/scripts/release-proof.mjs +2 -1
  229. package/scripts/release-qualification-contract.mjs +82 -28
  230. package/scripts/release-qualification.mjs +8 -1
  231. package/scripts/release-vector.mjs +2 -1
  232. package/scripts/restore-local-ingests.mjs +2 -1
  233. package/scripts/retrieval-canary.mjs +2 -1
  234. package/scripts/route-cheap.mjs +2 -1
  235. package/scripts/route-gold-rank.mjs +2 -1
  236. package/scripts/route-index-memory.mjs +2 -1
  237. package/scripts/route-latency-warm.mjs +2 -1
  238. package/scripts/router-utilization.mjs +2 -1
  239. package/scripts/routing-flywheel.mjs +2 -1
  240. package/scripts/run-operational-benchmark.mjs +2 -1
  241. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  242. package/scripts/rvf-index-audit.mjs +2 -16
  243. package/scripts/seal-gist-receipt.mjs +2 -1
  244. package/scripts/security-guidance-codex-compat.mjs +2 -1
  245. package/scripts/selfcheck.mjs +2 -1
  246. package/scripts/signal-watch.mjs +2 -1
  247. package/scripts/source-coverage.mjs +2 -1
  248. package/scripts/stabilization-receipt.mjs +2 -1
  249. package/scripts/stamp-sweep.mjs +2 -1
  250. package/scripts/status-honesty.mjs +2 -1
  251. package/scripts/sync-commands.mjs +2 -2
  252. package/scripts/top100-benchmark.mjs +2 -1
  253. package/scripts/top100-corpus.mjs +2 -1
  254. package/scripts/user-model-hook-install.mjs +2 -1
  255. package/scripts/user-model-prompt-hook.mjs +2 -1
  256. package/scripts/verify-bundle.mjs +3 -1
  257. package/scripts/verify-model-catalog.mjs +2 -1
  258. package/scripts/version.mjs +2 -1
  259. package/scripts/wired-check.mjs +13 -5
  260. package/scripts/worktree-integrity.mjs +2 -2
@@ -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
  }
@@ -90,8 +90,11 @@ export function requiredEmbedderModels(kbDir) {
90
90
  return models.size ? [...models].sort() : [MINILM_MODEL];
91
91
  }
92
92
 
93
+ // A model is present only when the files the strict offline reader loads are there (#301): a
94
+ // folder left by a download that did not finish (no onnx/model_quantized.onnx) counts as missing,
95
+ // or the doctor calls a cache warm that cannot answer a query.
93
96
  export function missingEmbedderModels(modelCache, models) {
94
- return models.filter((model) => !fs.existsSync(modelPath(modelCache, model)));
97
+ return models.filter((model) => !modelCacheReady(modelCache, model));
95
98
  }
96
99
 
97
100
  export function configureTransformersModel(T, modelCache, model) {
@@ -58,15 +58,19 @@ function sameExecutable(left, right) {
58
58
  try { return fs.realpathSync(left) === fs.realpathSync(right); } catch { return path.resolve(left || '') === path.resolve(right || ''); }
59
59
  }
60
60
 
61
- function ownerState(owner, { hostname, inspectProcess, isAlive }) {
62
- if (!owner || owner.host !== hostname || !owner.processStart || !owner.executable) return 'unknown';
63
- const observed = inspectProcess
61
+ function observeOwnerProcess(owner, { inspectProcess, isAlive }) {
62
+ return inspectProcess
64
63
  ? inspectProcess(owner.pid)
65
64
  : (isAlive && isAlive !== alive)
66
65
  ? (isAlive(owner.pid)
67
66
  ? { state: 'live', processStart: owner.processStart, executable: owner.executable }
68
67
  : { state: 'dead' })
69
68
  : observedProcessIdentity(owner.pid);
69
+ }
70
+
71
+ function ownerState(owner, { hostname, inspectProcess, isAlive }) {
72
+ if (!owner || owner.host !== hostname || !owner.processStart || !owner.executable) return 'unknown';
73
+ const observed = observeOwnerProcess(owner, { inspectProcess, isAlive });
70
74
  if (observed?.state !== 'live') return observed?.state === 'dead' ? 'dead' : 'unknown';
71
75
  return observed.processStart === owner.processStart && sameExecutable(observed.executable, owner.executable)
72
76
  ? 'live'
@@ -78,6 +82,135 @@ export function inspectRefreshOwner(owner, { hostname = os.hostname(), inspectPr
78
82
  return ownerState(owner, { hostname, inspectProcess, isAlive });
79
83
  }
80
84
 
85
+ // A lock nobody can prove alive must not wedge every later run (4.7.21, audit C). Liveness cannot be proven
86
+ // across hosts (and on macOS os.hostname() follows the network, so "another host" is often this Mac on
87
+ // another Wi-Fi: ownerVerdict checks the pid here first), but a refresh run cannot outlive the updater's own deadlines. An owner.json that cannot be
88
+ // read or lacks its identity is debris once no writer could still be publishing it.
89
+ export const REMOTE_OWNER_DEAD_MS = 3 * 3600_000;
90
+ export const INCOMPLETE_OWNER_RECLAIM_MS = 3600_000;
91
+ const ownerComplete = (owner) => owner?.schemaVersion === 3 && Boolean(owner.runId && owner.token && owner.host
92
+ && Number.isSafeInteger(owner.pid) && owner.receiptPath && owner.brainHome);
93
+ const removalCommand = (lockPath) => (process.platform === 'win32'
94
+ ? `Remove-Item -Recurse -Force "${lockPath}"` : `rm -rf "${lockPath}"`);
95
+ function newestMtimeMs(...files) {
96
+ let newest = Number.NaN;
97
+ for (const file of files) {
98
+ try { const ms = fs.lstatSync(file).mtimeMs; if (!(newest >= ms)) newest = ms; } catch { /* absent */ }
99
+ }
100
+ return newest;
101
+ }
102
+
103
+ /**
104
+ * 'dead' also for a remote owner past REMOTE_OWNER_DEAD_MS; every other answer is ownerState's.
105
+ * 4.7.21 review (Opus updater #2, reproduced): on macOS the hostname follows the network, so an owner recorded
106
+ * under "another host" is often THIS Mac, asleep mid-update on the old Wi-Fi. Its pid is inspected here first: the
107
+ * same pid alive with the same process start and executable IS the owner, whatever the hostname says. Only an
108
+ * absent or different local process may be aged out.
109
+ */
110
+ function ownerVerdict(owner, { hostname, inspectProcess, isAlive, nowMs }) {
111
+ const state = ownerState(owner, { hostname, inspectProcess, isAlive });
112
+ if (state !== 'unknown' || !ownerComplete(owner) || owner.host === hostname) return { state, reason: 'exact-dead-owner' };
113
+ if (owner.processStart && owner.executable) {
114
+ const local = observeOwnerProcess(owner, { inspectProcess, isAlive });
115
+ if (local?.state === 'live' && local.processStart === owner.processStart
116
+ && sameExecutable(local.executable, owner.executable)) return { state: 'live', reason: 'same-process-other-hostname' };
117
+ }
118
+ const startedMs = Date.parse(owner.startedAt || '');
119
+ return Number.isFinite(startedMs) && nowMs - startedMs > REMOTE_OWNER_DEAD_MS
120
+ ? { state: 'dead', reason: 'stale-remote-owner' }
121
+ : { state, reason: null };
122
+ }
123
+
124
+ // ── takeover is serialized (4.7.21 review, Codex #1) ─────────────────────────────────────────────
125
+ // Two contenders can judge the same abandoned lock. Without a claim, A reclaims and acquires, then B removes
126
+ // A's FRESH lock on its stale judgement and both run. A takeover now (1) creates an exclusive claim file
127
+ // inside the judged lock directory ('wx': one winner per directory instance), (2) re-checks, while holding
128
+ // the claim, that the directory is the same one (dev/ino) and owner.json is byte-identical to what was
129
+ // judged, and only then (3) moves it aside. A claim left by a crashed reclaimer is superseded by the next
130
+ // numbered claim once CLAIM_STALE_MS old, so a crash mid-takeover cannot wedge the lock either.
131
+ export const CLAIM_STALE_MS = 5 * 60_000;
132
+ const CLAIM_FILE = /^takeover(?:-(\d+))?\.json$/;
133
+ function dirIdentity(dir) {
134
+ const stat = fs.lstatSync(dir, { bigint: true });
135
+ if (!stat.isDirectory()) throw Object.assign(new Error(`${dir} is not a lock directory`), { code: 'ENOTDIR' });
136
+ return `${stat.dev}:${stat.ino}`;
137
+ }
138
+ function readRaw(file) {
139
+ try { return fs.readFileSync(file, 'utf8'); } catch (error) { if (error?.code === 'ENOENT') return null; throw error; }
140
+ }
141
+ // A claim file must not restart the age an incomplete owner is judged by (Opus r2 #6): writing or removing one bumps
142
+ // the lock directory's mtime, which newestMtimeMs reads, so the directory's own times are put back.
143
+ function keepDirectoryTimes(dir, change) {
144
+ let before = null;
145
+ try { before = fs.lstatSync(dir); } catch { /* judged below */ }
146
+ try { return change(); } finally { if (before) try { fs.lutimesSync(dir, before.atime, before.mtime); } catch { /* gone */ } }
147
+ }
148
+ const GONE = Symbol('judged lock vanished');
149
+ function claimTakeover(lockPath, hostname) {
150
+ let highest = -1;
151
+ let highestName = null;
152
+ let names;
153
+ // The judged lock vanished (another reclaimer moved it): re-judge rather than throw (Fable r2 P3-1).
154
+ try { names = fs.readdirSync(lockPath); } catch (error) { if (['ENOENT', 'ENOTDIR'].includes(error?.code)) return GONE; throw error; }
155
+ for (const name of names) {
156
+ const match = CLAIM_FILE.exec(name);
157
+ if (!match) continue;
158
+ const level = match[1] ? Number(match[1]) : 0;
159
+ if (level > highest) { highest = level; highestName = name; }
160
+ }
161
+ if (highestName) {
162
+ let ageMs = 0;
163
+ try { ageMs = Date.now() - fs.lstatSync(path.join(lockPath, highestName)).mtimeMs; } catch { return null; }
164
+ if (!(ageMs > CLAIM_STALE_MS)) return null; // another reclaimer is mid-takeover
165
+ }
166
+ const level = highest + 1;
167
+ const file = path.join(lockPath, level ? `takeover-${level}.json` : 'takeover.json');
168
+ const claim = { schemaVersion: 1, token: crypto.randomBytes(24).toString('hex'), host: hostname,
169
+ pid: process.pid, claimedAt: new Date().toISOString(), file };
170
+ let fd;
171
+ try { fd = keepDirectoryTimes(lockPath, () => fs.openSync(file, 'wx', 0o600)); } catch (error) {
172
+ if (error?.code === 'EEXIST') return null;
173
+ if (error?.code === 'ENOENT') return GONE;
174
+ throw error;
175
+ }
176
+ try { fs.writeFileSync(fd, `${JSON.stringify({ ...claim, file: undefined }, null, 2)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
177
+ return claim;
178
+ }
179
+ function dropClaim(claim) {
180
+ try { if (readRaw(claim.file)?.includes(claim.token)) keepDirectoryTimes(path.dirname(claim.file), () => fs.unlinkSync(claim.file)); } catch { /* gone with its directory */ }
181
+ }
182
+ /**
183
+ * Remove the lock directory at `lockPath` only if it is still exactly the one observed. Returns 'removed',
184
+ * 'changed' (it is not the judged directory any more: re-judge it) or 'busy' (another reclaimer holds the claim).
185
+ */
186
+ function removeObservedLock(lockPath, observed, { hostname, beforeRemove = null, afterClaim = null } = {}) {
187
+ const claim = claimTakeover(lockPath, hostname);
188
+ if (claim === GONE) return 'changed';
189
+ if (!claim) return 'busy';
190
+ afterClaim?.({ lockPath, claim });
191
+ let same = false;
192
+ try {
193
+ same = dirIdentity(lockPath) === observed.identity
194
+ && readRaw(path.join(lockPath, 'owner.json')) === observed.raw
195
+ && Boolean(readRaw(claim.file)?.includes(claim.token));
196
+ } catch { same = false; }
197
+ if (!same) { dropClaim(claim); return 'changed'; }
198
+ try { beforeRemove?.(claim); } catch (error) { dropClaim(claim); throw error; }
199
+ const tomb = `${lockPath}.stale-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
200
+ try { fs.renameSync(lockPath, tomb); } catch (error) {
201
+ if (error?.code === 'ENOENT') return 'changed';
202
+ dropClaim(claim);
203
+ throw error;
204
+ }
205
+ // Only the claim holder can move the judged directory, so the moved directory must carry this claim.
206
+ if (!readRaw(path.join(tomb, path.basename(claim.file)))?.includes(claim.token)) {
207
+ try { if (!fs.existsSync(lockPath)) fs.renameSync(tomb, lockPath); } catch { /* keep it as evidence */ }
208
+ throw new Error(`refresh lock takeover moved a directory it did not claim (${lockPath}); refusing`);
209
+ }
210
+ fs.rmSync(tomb, { recursive: true, force: true });
211
+ return 'removed';
212
+ }
213
+
81
214
  /**
82
215
  * The ONE physical-path rule for brain directories. A user may reach the brain through a symlink
83
216
  * (`~/.cache -> /Volumes/<disk>`) while the updater knows it by its real path (Node resolves
@@ -103,15 +236,12 @@ export function refreshReceiptPath(brainHome, runId) {
103
236
  return file;
104
237
  }
105
238
 
106
- function abandonDeadOwner({ lockPath, owner, hostname, inspectProcess, isAlive, now }) {
107
- const claimFile = path.join(lockPath, 'takeover.json');
108
- const claim = { schemaVersion: 1, token: crypto.randomBytes(24).toString('hex'), host: hostname,
109
- pid: process.pid, claimedAt: now() };
110
- const fd = fs.openSync(claimFile, 'wx', 0o600);
111
- try { fs.writeFileSync(fd, `${JSON.stringify(claim, null, 2)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
239
+ /** Records the dead owner's run ABANDONED. Runs only while this process holds the takeover claim on its lock. */
240
+ function abandonDeadOwner({ lockPath, owner, hostname, inspectProcess, isAlive, now, claim }) {
112
241
  const observed = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8'));
242
+ const verdict = ownerVerdict(observed, { hostname, inspectProcess, isAlive, nowMs: Date.parse(now()) });
113
243
  if (observed.schemaVersion !== 3 || observed.runId !== owner.runId || observed.token !== owner.token
114
- || ownerState(observed, { hostname, inspectProcess, isAlive }) !== 'dead') {
244
+ || verdict.state !== 'dead') {
115
245
  throw new Error('refresh owner changed or is no longer exactly dead during takeover');
116
246
  }
117
247
  const expectedReceipt = refreshReceiptPath(observed.brainHome, observed.runId);
@@ -136,7 +266,7 @@ function abandonDeadOwner({ lockPath, owner, hostname, inspectProcess, isAlive,
136
266
  receipt.status = 'ABANDONED';
137
267
  receipt.terminalVerdict = 'abandoned';
138
268
  receipt.finishedAt = now();
139
- receipt.detail = { reason: 'exact-dead-owner', abandonedOwner: { host: observed.host, pid: observed.pid,
269
+ receipt.detail = { reason: verdict.reason, abandonedOwner: { host: observed.host, pid: observed.pid,
140
270
  processStart: observed.processStart, executable: observed.executable }, takeoverToken: claim.token };
141
271
  delete receipt.intendedStatus;
142
272
  delete receipt.intendedTerminalVerdict;
@@ -163,6 +293,7 @@ export function acquireRefreshLock({
163
293
  processStart = null,
164
294
  inspectProcess = null,
165
295
  afterStage = null,
296
+ afterTakeoverClaim = null,
166
297
  } = {}) {
167
298
  const root = path.resolve(kbDir || '');
168
299
  const lockPath = refreshLockPath(root);
@@ -204,23 +335,34 @@ export function acquireRefreshLock({
204
335
  // Windows refuses rename-over-existing with EPERM/EACCES rather than EEXIST. Treat those
205
336
  // codes as contention, then inspect the owner through the same liveness fence.
206
337
  if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EACCES'].includes(error?.code)) throw error;
338
+ // Judge one exact directory: its identity and owner.json bytes are what the takeover re-checks later.
339
+ let observed;
340
+ try { observed = { identity: dirIdentity(lockPath), raw: readRaw(path.join(lockPath, 'owner.json')) }; }
341
+ catch (observeError) { if (['ENOENT', 'ENOTDIR'].includes(observeError?.code)) continue; throw observeError; }
207
342
  let owner = null;
208
- try { owner = JSON.parse(fs.readFileSync(path.join(lockPath, 'owner.json'), 'utf8')); } catch {
209
- throw new Error(`refresh lock owner is incomplete or unreadable; refusing stale takeover (${lockPath})`);
210
- }
211
- const state = ownerState(owner, { hostname, inspectProcess, isAlive });
212
- if (state !== 'dead') {
213
- throw new Error(`${state === 'live' ? 'another refresh run is active' : 'refresh lock owner is ambiguous'} `
214
- + `(pid ${owner.pid || '?'}, lock ${lockPath})`);
343
+ try { owner = JSON.parse(observed.raw); } catch { /* judged below */ }
344
+ const nowMs = Date.parse(now());
345
+ let beforeRemove = null;
346
+ if (!ownerComplete(owner)) {
347
+ const ageMs = nowMs - newestMtimeMs(lockPath, path.join(lockPath, 'owner.json'));
348
+ if (!(ageMs > INCOMPLETE_OWNER_RECLAIM_MS)) {
349
+ throw new Error(`refresh lock owner is incomplete or unreadable; refusing takeover until it is 1 h old `
350
+ + `(lock ${lockPath}). If no update is running, remove it: ${removalCommand(lockPath)}`);
351
+ }
352
+ } else {
353
+ const verdict = ownerVerdict(owner, { hostname, inspectProcess, isAlive, nowMs });
354
+ if (verdict.state !== 'dead') {
355
+ throw new Error(`${verdict.state === 'live' ? 'another refresh run is active' : 'refresh lock owner is ambiguous'} `
356
+ + `(pid ${owner.pid || '?'} on ${owner.host || '?'}, started ${owner.startedAt || '?'}, lock ${lockPath}). `
357
+ + `If no update is running${owner.host && owner.host !== hostname ? ` on ${owner.host}` : ''}, remove it: ${removalCommand(lockPath)}`);
358
+ }
359
+ beforeRemove = (claim) => abandonDeadOwner({ lockPath, owner, hostname, inspectProcess, isAlive, now, claim });
215
360
  }
216
- abandonDeadOwner({ lockPath, owner, hostname, inspectProcess, isAlive, now });
217
- const stale = `${lockPath}.stale-${pid}-${Date.now()}`;
218
- try {
219
- fs.renameSync(lockPath, stale);
220
- fs.rmSync(stale, { recursive: true, force: true });
221
- } catch (renameError) {
222
- if (renameError?.code !== 'ENOENT') throw renameError;
361
+ const outcome = removeObservedLock(lockPath, observed, { hostname, beforeRemove, afterClaim: afterTakeoverClaim });
362
+ if (outcome === 'busy') {
363
+ throw new Error(`another process is taking over the refresh lock ${lockPath}; refusing to race it (retry shortly)`);
223
364
  }
365
+ // 'removed' or 'changed': acquire again; a changed lock is judged afresh.
224
366
  }
225
367
  }
226
368
  throw new Error(`could not acquire refresh lock ${lockPath}`);
@@ -260,8 +402,14 @@ function atomicJson(file, value) {
260
402
  const temporary = `${file}.tmp-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
261
403
  fs.mkdirSync(path.dirname(file), { recursive: true });
262
404
  const fd = fs.openSync(temporary, 'wx', 0o600);
263
- try { fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
264
- fs.renameSync(temporary, file);
405
+ try {
406
+ try { fs.writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
407
+ fs.renameSync(temporary, file);
408
+ } catch (error) {
409
+ // #434: a failed write/rename must not leave a temp behind to block lifecycle retention.
410
+ try { fs.rmSync(temporary, { force: true }); } catch { /* the original error is the one to report */ }
411
+ throw error;
412
+ }
265
413
  }
266
414
 
267
415
  export function openRefreshReceipt({ brainHome, lock, action, desiredVersion = null,
@@ -127,7 +127,20 @@ function samePath(stored, docPath) {
127
127
  * takes to reach the hit. A malformed JSON line is skipped, never fatal.
128
128
  */
129
129
  export async function citationResolves(citation, kbDir) {
130
- const files = passagesFilesFor(citation.repo, kbDir);
130
+ const own = await resolveIn(passagesFilesFor(citation.repo, kbDir), citation.docPath);
131
+ if (own.resolved) return own;
132
+ // A capability card is cited under its OWN repo (`repo=ruvector`, `path : ruvector/CARD/ruvector-card`,
133
+ // kb/card-lane.mjs renderCardHit) but is stored only in the derived `concepts` aggregate, under
134
+ // `<repo>/CARD/<repo>-card` (scripts/corpus-aggregates.mjs). Look exactly that path up there. Narrow
135
+ // on purpose: only a `CARD/` path, only in `concepts`, and only the exact stored path.
136
+ if (/^CARD\/[^/#]+-card$/.test(String(citation.docPath)) && citation.repo !== 'concepts') {
137
+ const card = await resolveIn(passagesFilesFor('concepts', kbDir), `${citation.repo}/${citation.docPath}`);
138
+ if (card.resolved) return card;
139
+ }
140
+ return own;
141
+ }
142
+
143
+ async function resolveIn(files, docPath) {
131
144
  if (!files.length) return { resolved: false, reason: 'no-store', file: null, storedPath: null };
132
145
  for (const file of files) {
133
146
  const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
@@ -136,7 +149,7 @@ export async function citationResolves(citation, kbDir) {
136
149
  if (!line) continue;
137
150
  let rec;
138
151
  try { rec = JSON.parse(line); } catch { continue; }
139
- if (typeof rec?.path === 'string' && samePath(rec.path, citation.docPath)) {
152
+ if (typeof rec?.path === 'string' && samePath(rec.path, docPath)) {
140
153
  return { resolved: true, reason: 'ok', file: path.basename(file), storedPath: rec.path };
141
154
  }
142
155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.20",
3
+ "version": "4.7.22",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -66,6 +66,7 @@
66
66
  "learning:replay:check": "node scripts/learning-replay.mjs --check-portfolio",
67
67
  "learning:replay:mutants:check": "node scripts/learning-replay.mjs --check-mutants",
68
68
  "learning:replay:dry": "node scripts/learning-replay.mjs --dry-run",
69
+ "learning:replay:wire": "node scripts/learning-replay.mjs --check-wire",
69
70
  "wired:check": "node scripts/wired-check.mjs --check",
70
71
  "doc:currency": "node scripts/doc-currency.mjs --check",
71
72
  "status:check": "node scripts/status-honesty.mjs",
@@ -107,6 +108,7 @@
107
108
  "kb/retrieval-result.mjs",
108
109
  "kb/forge-update.mjs",
109
110
  "kb/download-retry.mjs",
111
+ "kb/forge-guard-injection.mjs",
110
112
  "kb/corpus-release-identity.mjs",
111
113
  "kb/capability-only.mjs",
112
114
  "kb/capability-summaries/",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.7.20",
4
+ "version": "4.7.22",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.20",
3
+ "version": "4.7.22",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"