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
@@ -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.21",
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.21",
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.21",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -5,6 +5,7 @@ import path from 'node:path';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { managedGenerationIdentity } from './managed-cli-generation.mjs';
7
7
  import { loadRuntimePreferences, runtimeChildEnv } from '../scripts/runtime-preferences.mjs';
8
+ import { resolveRuflo } from '../scripts/ruflo-bin.mjs';
8
9
  import { projectDirectory } from '../scripts/project-identity.mjs';
9
10
  import { recordManagedCliObservation, recordRegistryLatestObservation } from '../scripts/capability-claim-evidence.mjs';
10
11
 
@@ -180,12 +181,19 @@ function writeStamps(executable, argv, env, binding) {
180
181
 
181
182
  export function resolveManagedExecutable(executable, env = process.env) {
182
183
  if (executable !== 'ruflo') return executable;
183
- const home = env.HOME || os.homedir();
184
- const candidates = [path.join(home, '.npm-global', 'bin', 'ruflo')];
185
- if (process.platform === 'win32') candidates.push(`${candidates[0]}.cmd`);
186
- return candidates.find((candidate) => {
187
- try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; }
188
- }) || executable;
184
+ // #420 item 5: same rules as every other Brain caller (RUFLO_BIN is authoritative, then ~/.npm-global/bin, then PATH); this used to check ~/.npm-global only and ignored RUFLO_BIN.
185
+ return resolveRuflo({ env, home: env.HOME || os.homedir() }) || executable;
186
+ }
187
+
188
+ /**
189
+ * ruflo (and claude-flow, the same @claude-flow/cli) self-updates at startup by running `npm install` in its
190
+ * working directory, which here is the user's project: measured 2026-10-22, a 1.3 GB install landed there.
191
+ * ruflo-bin.mjs promises every Brain spawn sets CLAUDE_FLOW_AUTO_UPDATE=false; this boundary is a Brain spawn
192
+ * too. RUFLO_DAEMON_AUTOSTART=0 keeps a background daemon from being left behind in that project.
193
+ */
194
+ export function managedChildEnv(executable, env) {
195
+ if (executable !== 'ruflo' && executable !== 'claude-flow') return env;
196
+ return { ...env, CLAUDE_FLOW_AUTO_UPDATE: 'false', RUFLO_DAEMON_AUTOSTART: '0' };
189
197
  }
190
198
 
191
199
  function quoteWindowsCommandArg(value) {
@@ -323,7 +331,7 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
323
331
  if (toolName === 'ruvnet_cli_help') {
324
332
  stampKeysForHelp(executable, argv);
325
333
  const commandArgv = [...argv, '--help'];
326
- const execution = await execute(executable, commandArgv, env);
334
+ const execution = await execute(executable, commandArgv, managedChildEnv(executable, env));
327
335
  if (normalizeManagedExecution(execution).outcome === 'success') writeStamps(executable, argv, env, binding);
328
336
  recordManagedCliObservation({ toolName, executable, argv: commandArgv, execution, env });
329
337
  return resultOf(executable, commandArgv, execution);
@@ -368,7 +376,7 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
368
376
  // host by supplying an arbitrary `host` property.
369
377
  const childEnv = (executable === 'agentic-flow' || executable === 'agentic-qe')
370
378
  ? runtimeChildEnv({ env, cwd: projectRoot })
371
- : env;
379
+ : managedChildEnv(executable, env);
372
380
  if (lifecycle && typeof lifecycle.beforeExecute === 'function') {
373
381
  await lifecycle.beforeExecute({ executable, argv, projectRoot, env: childEnv });
374
382
  }
@@ -33,6 +33,7 @@ import fs from 'node:fs';
33
33
  import { execFileSync, spawn } from 'node:child_process';
34
34
  import path from 'node:path';
35
35
  import { fileURLToPath, pathToFileURL } from 'node:url';
36
+ import { isDirectInvocation } from './entrypoint.mjs';
36
37
 
37
38
  const HERE = path.dirname(fileURLToPath(import.meta.url));
38
39
  const REPO = path.resolve(HERE, '..', '..');
@@ -128,7 +129,7 @@ An ADR describing a world the code left is worse than no ADR: the next reader tr
128
129
  }
129
130
 
130
131
  const isMain = (() => {
131
- try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
132
+ try { return isDirectInvocation(import.meta.url); }
132
133
  catch { return false; }
133
134
  })();
134
135
 
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The delivery contract for OPTIONAL Brain advice (#489).
3
+ *
4
+ * A capability recommendation, a package recommendation or a routine stack-update notice is
5
+ * information, not a decision the user must make now. Phrased as a question ("want me to update
6
+ * it?"), it was promoted by Codex into a structured question dialog that suspended an already
7
+ * authorized task. So optional advice is worded as statements, and the model-visible context
8
+ * carries this one line saying so. Real permission gates and genuinely missing information keep
9
+ * their normal question behaviour; this applies only to optional advisory channels.
10
+ *
11
+ * ground-ruvnet.sh cannot import this module and carries the same sentence verbatim;
12
+ * tests/unit/advisory-contract.test.mjs fails if the two drift.
13
+ */
14
+ export const ADVISORY_CONTRACT = 'Optional, informational advice: mention it at most once as a statement and continue the current task. '
15
+ + 'Do not open a question or approval dialog because of it; it grants no authority to install or update anything.';
16
+
17
+ /**
18
+ * Question-shaped wording that invites a decision dialog, including the "say X to proceed, or ignore
19
+ * this" choice prompt the advocacy copy used to carry. Optional advice must match none of it.
20
+ */
21
+ export const QUESTION_SHAPED = /\?|\b(want me to|would you like|should i|shall i|do you want|can i|to proceed|or ignore this)\b/i;
@@ -61,6 +61,7 @@ import {
61
61
  ACTIONS, record, shouldStillOffer, stateHashOf, precision, pendingOffers, reconcileIgnored, loadOutcomes,
62
62
  } from './advocacy-outcomes.mjs';
63
63
  import { packageRecommenderEnabled, offerNames } from './package-recommender-flag.mjs';
64
+ import { isDirectInvocation } from './entrypoint.mjs';
64
65
  // Matcher + socket client load only when the flag is on at process start: default-off pays nothing.
65
66
  const PKG = packageRecommenderEnabled() ? { ...(await import('./package-recommender.mjs')), ...(await import('./package-recommender-client.mjs')) } : null;
66
67
 
@@ -341,9 +342,9 @@ export function buildCandidate({ prompt, match, availability }) {
341
342
  ? `It is already installed here.`
342
343
  : `Install state unknown from here — say so rather than claiming it is available.`;
343
344
  const copy = [
344
- `[RuvNet Brain — capability advocacy] If it genuinely fits, tell the user in ONE sentence: `
345
- + `"Consider ${cap.id} — ${cap.benefit}. Say 'use ${cap.id}' to proceed, or ignore this." ${avail}`,
346
- `If they accept, the safe first step is \`${cap.nextAction}\` (undo: ${cap.undo}); confirm it with `
345
+ `[RuvNet Brain — capability advocacy] If it genuinely fits, state it in ONE sentence: `
346
+ + `"${cap.id} fits here — ${cap.benefit}; 'use ${cap.id}' adopts it." ${avail}`,
347
+ `If the user later says to use it, the safe first step is \`${cap.nextAction}\` (undo: ${cap.undo}); confirm it with `
347
348
  + `search_ruvnet before you build. Say it once, do not expand it, then carry on with the actual work.`,
348
349
  ].join('\n');
349
350
  return {
@@ -429,7 +430,7 @@ export function decide({ prompt, sessionId, file, state, now = Date.now(), start
429
430
  }
430
431
 
431
432
  // ── CLI ───────────────────────────────────────────────────────────────────────────────────────────
432
- const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
433
+ const isMain = isDirectInvocation(import.meta.url);
433
434
 
434
435
  async function main() {
435
436
  const startedAt = Date.now();
@@ -26,6 +26,7 @@ import { searchOnce, nativeRead, exactReads, searchJsonResult } from './agentdb-
26
26
  import { makeLesson } from './lesson-store.mjs';
27
27
  import { supersededKeys, tombstonedKeys } from './decision-record.mjs';
28
28
  import { checkpointMs, checkpointNamespaces, newestCheckpoint } from './checkpoint-keys.mjs';
29
+ import { isDirectInvocation } from './entrypoint.mjs';
29
30
  export const STORE_FILES = Object.freeze(['memory.db']);
30
31
  export const DEFAULT_DEADLINE_MS = 1900;
31
32
  export const BLOCK_MAX_BYTES = 600;
@@ -115,7 +116,9 @@ function clean(value, limit) {
115
116
  return out;
116
117
  }
117
118
 
118
- const NOISE_KEY = /^(?:verify[-_]|probe[-_]|test[-_]|rnb-quality-probe|session[-_]|turn[-_]|project-progress[-_]|cevt[-_])/i;
119
+ const MISSING_KNOWLEDGE = /\b(?:cannot\s+(?:be\s+determined|determine)|can(?:'|’)?t\s+(?:be\s+)?determined?|do\s+not\s+know|don(?:'|’)?t\s+know|cannot\s+answer|no\s+(?:verified\s+|supplied\s+|historical\s+)?(?:record|memory|evidence)\s+(?:is\s+)?(?:available|found))\b/i;
120
+ const MISSING_KNOWLEDGE_QUERY = /\b(?:fail(?:ed|ing|ures?)?|missing|unavailable|unknown)\b/i;
121
+ const NOISE_KEY =/^(?:verify[-_]|probe[-_]|test[-_]|rnb-quality-probe|session[-_]|turn[-_]|project-progress[-_]|cevt[-_])/i;
119
122
  const SIGNAL_NAMESPACES = new Set(['lessons', 'patterns', 'pattern']);
120
123
  /** Turn knowledge is the actual redacted outcome, never its session/transcript wrapper. */
121
124
  function turnOutcomeClauses(value, prompt) {
@@ -393,9 +396,10 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
393
396
  ...await searchOnce({ ruflo: bin, store: jobStore, deadline: searchDeadline, env, scratch: scratchFor, args, signal }) })));
394
397
  let status = results.every((r) => r.state === 'ok') ? 'ok'
395
398
  : results.some((r) => r.state === 'timed out') ? 'timed out' : 'unavailable';
396
- // Overfetch a bounded six exact values so rejected turn metadata cannot hide
397
- // the next useful outcome; at most three verified excerpts are delivered.
398
- const candidates = pickRows(results, 6);
399
+ // Overfetch a bounded twelve exact values (the one search already returns twelve; the exact reads are
400
+ // one batched reader, so this adds no Ruflo startup) so rejected turn metadata and repeated "cannot
401
+ // determine" answers cannot hide the next useful outcome (#429); at most three excerpts are delivered.
402
+ const candidates = pickRows(results, 12);
399
403
  // Ruflo previews are ~60 characters. Read the actual selected values within
400
404
  // the same deadline so the block contains useful evidence rather than titles.
401
405
  const exactResults = await exactReads({ candidates, bin, deadline, env, scratch: scratchFor, signal });
@@ -417,6 +421,12 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
417
421
  }
418
422
  return { pick: null, state: r.state === 'ok' ? 'unavailable' : r.state };
419
423
  }));
424
+ // A turn that admitted it could not answer is evidence of a failed recall, not the missing fact (#429).
425
+ // It stays available, after concrete outcomes; a query about failures keeps the ranked order.
426
+ if (!MISSING_KNOWLEDGE_QUERY.test(prompt)) {
427
+ const rank = ({ pick }) => Number(pick?.namespace === 'turns' && MISSING_KNOWLEDGE.test(pick.preview));
428
+ retrieved.sort((a, b) => rank(a) - rank(b));
429
+ }
420
430
  const seenClauses = new Set();
421
431
  const picks = retrieved.flatMap(({ pick }) => {
422
432
  if (!pick) return [];
@@ -470,7 +480,7 @@ async function main() {
470
480
  }
471
481
 
472
482
  function isMain() {
473
- try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
483
+ try { return isDirectInvocation(import.meta.url); }
474
484
  catch { return false; }
475
485
  }
476
486
  if (isMain()) main().catch(() => process.exit(0));
@@ -98,7 +98,15 @@ export function automaticInvocation(args, { source, home = os.homedir(), nodePat
98
98
  const coordinator = developerCoordinatorOwner({ home });
99
99
  if (coordinator.active) {
100
100
  if (!coordinator.ready) throw new Error(`coordinated updater is not ready: ${coordinator.reason}`);
101
- return { executable: nodePath, args: [coordinator.entry, '--apply'], source: 'developer-suite', coordinator };
101
+ // Hooks and timers get knowledge only, never the whole suite (brew, cargo, every npm install) under a session TTL
102
+ // that kills it mid-install (AuditC #10). The full suite runs only from the scheduled runner, which calls the
103
+ // coordinator directly, not through here.
104
+ // The registered snapshot moves only when an --update reconciles it, so it can predate --knowledge-only
105
+ // (4.7.20 and older refuse it: "unsupported developer update argument", exit 1). Pass the flag only to a
106
+ // coordinator whose source parses it, as runUpdate does for the updater's --result-file.
107
+ const knowledgeOnly = fs.readFileSync(coordinator.entry, 'utf8').includes("'--knowledge-only'");
108
+ return { executable: nodePath, args: [coordinator.entry, '--apply', ...(knowledgeOnly ? ['--knowledge-only'] : [])],
109
+ source: 'developer-suite', coordinator };
102
110
  }
103
111
  }
104
112
  source ??= updateSource({ home });
@@ -143,7 +143,8 @@ export function confirm({ footprint, env = process.env, home = os.homedir(), now
143
143
  const checkable = Boolean(integrity) && fs.existsSync(path.join(roots.kbDir, 'CORPUS-COVERAGE.json'));
144
144
  if (checkable && !integrity.valid) knowledgeProblems.push([`the bytes on disk fail the release-coverage check (${integrity.failures?.[0] || 'unknown'})`, UPDATE]);
145
145
  // A newer corpus is published and not yet installed (identity, not age): advisory, never a gate.
146
- const pending = newerCorpusPending({ source, corpusCheck: readJson(autoUpdatePaths(roots.brainHome).checkFile) });
146
+ const pending = newerCorpusPending({ source, corpusCheck: readJson(autoUpdatePaths(roots.brainHome).checkFile),
147
+ installedAtMs: signed ? Date.parse(signature.verifiedAt || '') : NaN });
147
148
  if (pending) {
148
149
  const lockAt = readJson(autoUpdatePaths(roots.brainHome).lockFile)?.at;
149
150
  const running = Number.isFinite(Date.parse(lockAt || '')) && now - Date.parse(lockAt) < AUTO_UPDATE_LOCK_STALE_MS;