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
@@ -58,6 +58,7 @@ import path from 'node:path';
58
58
  import crypto from 'node:crypto';
59
59
  import { spawnSync } from 'node:child_process';
60
60
  import { fileURLToPath } from 'node:url';
61
+ import { isDirectInvocation } from './entrypoint.mjs';
61
62
 
62
63
  const HERE = path.dirname(fileURLToPath(import.meta.url));
63
64
  export const REPO_ROOT = path.resolve(HERE, '..', '..');
@@ -974,20 +975,5 @@ export function main(argv = process.argv.slice(2)) {
974
975
  return 1;
975
976
  }
976
977
 
977
- // Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
978
- // NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
979
- // shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
980
- // never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
981
- // from "ran, found nothing", which is how prepareCorpusCandidate once reported SUCCESS with no
982
- // archive on disk. Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
983
- function isDirectInvocation() {
984
- try {
985
- if (!process.argv[1]) return false;
986
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
987
- } catch {
988
- return false;
989
- }
990
- }
991
-
992
- const invokedDirectly = isDirectInvocation();
978
+ const invokedDirectly = isDirectInvocation(import.meta.url);
993
979
  if (invokedDirectly) process.exit(main());
@@ -45,6 +45,7 @@ import os from 'node:os';
45
45
  import path from 'node:path';
46
46
  import { spawnSync } from 'node:child_process';
47
47
  import { fileURLToPath } from 'node:url';
48
+ import { isDirectInvocation } from './entrypoint.mjs';
48
49
 
49
50
  export const SCOPE = ['scripts/', 'kb/', 'plugin/scripts/', 'tests/', 'bin/'];
50
51
  export const THRESHOLD = 0.5;
@@ -485,7 +486,7 @@ export function evaluate(payload, { env = process.env, now = Date.now(), deadlin
485
486
  }
486
487
 
487
488
  const isMain = (() => {
488
- try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
489
+ try { return isDirectInvocation(import.meta.url); }
489
490
  catch { return false; }
490
491
  })();
491
492
 
@@ -0,0 +1,34 @@
1
+ // plugin/scripts/entrypoint.mjs — the ONE answer to "was this module run as the program, or imported?"
2
+ //
3
+ // Every dual-purpose file in this repo (a library when imported, a CLI when run) asks that question.
4
+ // The copy-pasted answer compared `process.argv[1]` with `import.meta.url` directly, e.g.
5
+ // path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
6
+ // import.meta.url === pathToFileURL(process.argv[1]).href
7
+ // argv[1] is the path as the caller typed it, symlinks included; Node resolves symlinks before it sets
8
+ // import.meta.url. Run through a symlink (npm bin shims, wrapper scripts, a symlinked checkout, every
9
+ // os.tmpdir() path on macOS) the two sides differ, main() never runs, and the process exits 0 having
10
+ // done nothing. A release gate that exits 0 without checking anything passes. Dream Cycle PRs #295,
11
+ // #317, #333, #367, #412 and #484 each found one more copy by hand.
12
+ //
13
+ // Usage:
14
+ // import { isDirectInvocation } from './entrypoint.mjs'; // path relative to this file
15
+ // if (isDirectInvocation(import.meta.url)) main();
16
+ //
17
+ // tests/unit/entrypoint-guard-sweep.test.mjs fails when a raw comparison reappears outside this file,
18
+ // and runs a script through a symlink to prove this helper makes it execute.
19
+ import fs from 'node:fs';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ /**
23
+ * True when `moduleUrl` (pass `import.meta.url`) is the file Node was asked to run. Both sides are
24
+ * compared as real paths. Never throws: `realpathSync` throws on a path that does not resolve (under
25
+ * a test runner argv[1] need not be a real file), and an imported module must not crash its importer.
26
+ */
27
+ export function isDirectInvocation(moduleUrl, argv1 = process.argv[1]) {
28
+ if (!argv1 || !moduleUrl) return false;
29
+ try {
30
+ return fs.realpathSync(argv1) === fs.realpathSync(fileURLToPath(moduleUrl));
31
+ } catch {
32
+ return false;
33
+ }
34
+ }
@@ -1,5 +1,6 @@
1
1
  // footprint-io.mjs — the footprint sweep's disk operations (ADR-0098), kept apart from the classifier in
2
- // brain-footprint.mjs: byte counts, log rotation, the guarded removal, and the KEPT-proof cache.
2
+ // brain-footprint.mjs: byte counts, log rotation, the guarded removal, the rescue of a released copy's
3
+ // operational files, and the KEPT-proof cache.
3
4
  //
4
5
  // THE KEPT-PROOF CACHE (independent review S7).
5
6
  // kbCopyProof hashes every file of a GB-sized copy. A copy kept because it holds data the live brain lacks
@@ -7,6 +8,7 @@
7
8
  // which can only keep it again. Only KEPT results are cached, keyed by a cheap stat fingerprint of the copy
8
9
  // and of the live brain (no hashing, no tree walk); a stale entry can only KEEP a copy, never remove one, and
9
10
  // any change to the live brain's identity files (a restore, an update) invalidates it.
11
+ import crypto from 'node:crypto';
10
12
  import fs from 'node:fs';
11
13
  import path from 'node:path';
12
14
 
@@ -157,7 +159,43 @@ export function removeWithin(target, expectedParent, owned) {
157
159
  return size;
158
160
  }
159
161
 
162
+ /**
163
+ * RESCUE BEFORE RELEASE. kbCopyProof lists a disposable copy's operational files (logs, ruflo scratch) as
164
+ * `rescue`, each with the bytes it was proven with. They are copied to <brainHome>/kb-copy-rescued/<copy
165
+ * name>/<path> and verified there BEFORE the copy is removed. Any mismatch (the source changed since the
166
+ * proof, a different file already at the destination, a link in the way) throws, and the caller keeps the
167
+ * copy. Retention of the rescued directories is brain-footprint.mjs's (FOOTPRINT_POLICY.rescuedCopiesKept).
168
+ */
169
+ export const RESCUE_DIR = 'kb-copy-rescued';
170
+ const sha256Of = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
171
+ const regular = (file) => { const st = lstat(file); return Boolean(st && st.isFile() && !st.isSymbolicLink()); };
172
+ export function rescueOperationalFiles(copyDir, rescue, brainHome, name = path.basename(copyDir)) {
173
+ if (!rescue?.length) return null;
174
+ const root = path.join(brainHome, RESCUE_DIR); const dest = path.join(root, name);
175
+ for (const dir of [brainHome, root, dest]) {
176
+ if (lstat(dir)?.isSymbolicLink()) throw new Error(`refusing to rescue into ${dir}: it is a link`);
177
+ }
178
+ for (const { file, sha256 } of rescue) {
179
+ const from = path.join(copyDir, file); const to = path.join(dest, file);
180
+ if (!regular(from) || sha256Of(from) !== sha256) throw new Error(`${file} changed after the copy was proven; the copy is kept`);
181
+ fs.mkdirSync(path.dirname(to), { recursive: true });
182
+ if (!lstat(to)) fs.copyFileSync(from, to, fs.constants.COPYFILE_EXCL);
183
+ if (!regular(to) || sha256Of(to) !== sha256) throw new Error(`could not rescue ${file} to ${to} (a different file is there); the copy is kept`);
184
+ }
185
+ // A reused rescue folder (an earlier sweep rescued, then could not release, this copy) holds the only copy of these
186
+ // logs once the copy goes: make it the newest, so retention keeps it among the newest five (review r2). A failure
187
+ // throws, and the caller keeps the copy.
188
+ const touched = new Date(); fs.utimesSync(dest, touched, touched);
189
+ return dest;
190
+ }
191
+
160
192
  const PROOF_CACHE = '.footprint-proof-cache.json';
193
+ // #335: schema 1 KEPT proofs predate kept-sibling accounting (two identical preserved copies each kept the
194
+ // other forever); they are proven once more. A dropped cache entry can only cost a re-hash, never a byte.
195
+ const PROOF_CACHE_SCHEMA = 2;
196
+ // The proof rule a cached KEPT verdict was reached under. A verdict from an older rule (one that kept copies
197
+ // the current rule releases, e.g. for a log file before rescue existed) is not reused; that copy is proven again.
198
+ const PROOF_RULE = 2;
161
199
  const IDENTITY_FILES = ['SOURCE.json', 'PRIVATE-STORES.json', 'COVERAGE.json', 'RVF-GENERATIONS.json'];
162
200
  const statKey = (file) => { try { const st = fs.lstatSync(file); return `${st.size}:${Math.floor(st.mtimeMs)}`; } catch { return '-'; } };
163
201
  const fingerprint = (dir) => [statKey(dir), ...IDENTITY_FILES.map((f) => statKey(path.join(dir, f)))].join('|');
@@ -165,21 +203,21 @@ const fingerprint = (dir) => [statKey(dir), ...IDENTITY_FILES.map((f) => statKey
165
203
  export function readProofCache(brainHome) {
166
204
  try {
167
205
  const doc = JSON.parse(fs.readFileSync(path.join(brainHome, PROOF_CACHE), 'utf8'));
168
- if (doc && typeof doc.entries === 'object' && doc.entries) return doc;
206
+ if (doc && doc.schemaVersion === PROOF_CACHE_SCHEMA && typeof doc.entries === 'object' && doc.entries) return doc;
169
207
  } catch { /* none yet */ }
170
- return { schemaVersion: 1, entries: {} };
208
+ return { schemaVersion: PROOF_CACHE_SCHEMA, entries: {} };
171
209
  }
172
210
 
173
211
  /** The cached KEPT proof for this copy, if neither the copy nor the live brain changed since. */
174
212
  export function cachedKept(cache, copyDir, liveDir) {
175
213
  const hit = cache.entries[copyDir];
176
- return hit && hit.copy === fingerprint(copyDir) && hit.live === fingerprint(liveDir) ? hit : null;
214
+ return hit && hit.rule === PROOF_RULE && hit.copy === fingerprint(copyDir) && hit.live === fingerprint(liveDir) ? hit : null;
177
215
  }
178
216
 
179
217
  export function rememberKept(brainHome, copyDir, liveDir, proof) {
180
218
  try {
181
219
  const cache = readProofCache(brainHome);
182
- cache.entries[copyDir] = { copy: fingerprint(copyDir), live: fingerprint(liveDir), reason: proof.reason,
220
+ cache.entries[copyDir] = { rule: PROOF_RULE, copy: fingerprint(copyDir), live: fingerprint(liveDir), reason: proof.reason,
183
221
  unique: (proof.unique || []).slice(0, 10), at: new Date().toISOString() };
184
222
  const file = path.join(brainHome, PROOF_CACHE);
185
223
  fs.writeFileSync(`${file}.tmp-${process.pid}`, JSON.stringify(cache));
@@ -187,7 +225,96 @@ export function rememberKept(brainHome, copyDir, liveDir, proof) {
187
225
  } catch { /* uncached: the next sweep proves it again, which is only slower */ }
188
226
  }
189
227
 
190
- /** The honest remedy for a copy no command can remove: what it holds, and that the owner decides. */
191
- export const keptCopyFix = (copyDir, hit) => `nothing to run: ${copyDir} is kept because it holds ${
228
+ /** The honest remedy for a kept copy: what it holds, and the explicit archive command that releases it (#335). */
229
+ export const keptCopyFix = (copyDir, hit) => `npx ruvnet-brain --clean --archive — ${copyDir} is kept because it holds ${
192
230
  (hit.unique || []).some((u) => /private/i.test(u.why || '')) ? 'private ' : ''}data the live brain lacks (${
193
- (hit.unique || []).slice(0, 3).map((u) => u.file).join(', ') || hit.reason}); restore that into the live brain, or delete the copy yourself once inspected`;
231
+ (hit.unique || []).slice(0, 3).map((u) => u.file).join(', ') || hit.reason}); that copies only those files into kb.archive/ and releases the copy (or restore them into the live brain yourself)`;
232
+
233
+ const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
234
+ /**
235
+ * Every `<archiveDir>/<stamp>/<label>` tree `--clean --archive` wrote: real directories only, links never entered.
236
+ * A dot-named entry is an archive still being written (or one a killed sweep left half-written); it is never
237
+ * a reference, so a partial archive can never account for a copy's bytes.
238
+ */
239
+ export function archiveRoots(archiveDir) {
240
+ const real = (p) => { const st = lstat(p); return Boolean(st && st.isDirectory() && !st.isSymbolicLink()); };
241
+ if (!real(archiveDir)) return [];
242
+ return names(archiveDir).map((s) => path.join(archiveDir, s)).filter(real)
243
+ .flatMap((stampDir) => names(stampDir).filter((n) => !n.startsWith('.')).map((n) => path.join(stampDir, n)).filter(real));
244
+ }
245
+
246
+ /** Undo an archive this sweep wrote (the copy it came from is being kept, so the archive holds nothing unique). */
247
+ export function discardArchive(root) {
248
+ fs.rmSync(root, { recursive: true, force: true });
249
+ fs.rmSync(`${root}.json`, { force: true });
250
+ }
251
+
252
+ /**
253
+ * Discard an archive ONLY while the copy it came from still holds every archived byte (review 4.7.21 r2). Between
254
+ * the archive and the re-proof another process (an updater's reclaimBackups, which counts this archive as
255
+ * evidence) may have removed the copy; a re-proof of a missing directory still says "keep", so an unconditional
256
+ * discard would lose the bytes in both places. The copy must still be a real directory and each archived file a
257
+ * regular file there with the same SHA-256; otherwise the archive is kept and the reason is returned.
258
+ */
259
+ export function discardArchiveIfMirrored({ root, copyDir, files }) {
260
+ const dir = lstat(copyDir);
261
+ if (!dir || dir.isSymbolicLink() || !dir.isDirectory()) return { discarded: false, reason: `${copyDir} is no longer a real directory` };
262
+ for (const { file, sha256 } of files || []) {
263
+ const held = path.join(copyDir, file);
264
+ let same = false;
265
+ try { same = regular(held) && sha256File(held) === sha256; } catch { same = false; }
266
+ if (!same) return { discarded: false, reason: `${file} is no longer identical in ${copyDir}` };
267
+ }
268
+ discardArchive(root);
269
+ return { discarded: true };
270
+ }
271
+
272
+ /**
273
+ * #335 `--clean --archive` (explicit, never automatic): COPY exactly the files a KEPT proof named unique into
274
+ * `<stampDir>/<label>/<same relative path>`, verify each byte-for-byte, and write `<stampDir>/<label>.json`.
275
+ * Moves and deletes nothing: the caller re-proves the copy against this archive before releasing it. A link,
276
+ * an unsafe ancestor, a non-regular file, too little free space or any copy error archives nothing usable.
277
+ * ALL OR NOTHING: the files go to a dot-named temp sibling and are renamed to `<label>` only after every one
278
+ * verifies (the receipt likewise); a failure removes the temp, so no half-written `<label>` is ever left for a
279
+ * later sweep to compare against.
280
+ */
281
+ export function archiveUniqueFiles({ copyDir, unique, stampDir, label, now = Date.now() }) {
282
+ if (!Array.isArray(unique) || !unique.length) return { ok: false, reason: 'no file is named unique; the copy is kept for another reason' };
283
+ const root = path.join(stampDir, label);
284
+ if (lstat(root)) return { ok: false, reason: `${root} already exists` };
285
+ let need = 0;
286
+ for (const { file, why } of unique) {
287
+ const st = lstat(path.join(copyDir, file));
288
+ if (/link|ancestor/i.test(String(why)) || !st || !st.isFile() || st.isSymbolicLink()) return { ok: false, reason: `${file} is not a regular file that can be archived (${why})` };
289
+ need += st.size;
290
+ }
291
+ const temp = path.join(stampDir, `.${label}.partial-${process.pid}-${crypto.randomBytes(4).toString('hex')}`);
292
+ const receipt = path.join(stampDir, `${label}.json`); const receiptTemp = `${temp}.json`;
293
+ let renamed = false;
294
+ try {
295
+ fs.mkdirSync(stampDir, { recursive: true });
296
+ const free = typeof fs.statfsSync === 'function' ? fs.statfsSync(stampDir) : null;
297
+ if (free && Number(free.bavail) * Number(free.bsize) < need + 64 * 1024 * 1024) {
298
+ return { ok: false, reason: `not enough free space to archive ${need} bytes` };
299
+ }
300
+ const files = [];
301
+ for (const { file } of unique) {
302
+ const src = path.join(copyDir, file); const dst = path.join(temp, file);
303
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
304
+ fs.copyFileSync(src, dst, fs.constants.COPYFILE_EXCL);
305
+ const sha256 = sha256File(src);
306
+ if (sha256File(dst) !== sha256) throw new Error(`archived bytes of ${file} differ from the source`);
307
+ files.push({ file, bytes: fs.lstatSync(dst).size, sha256 });
308
+ }
309
+ fs.writeFileSync(receiptTemp, `${JSON.stringify({ schemaVersion: 1, kind: 'ruvnet-brain-kb-archive',
310
+ source: copyDir, archivedAt: new Date(now).toISOString(), files }, null, 2)}\n`, { flag: 'wx' });
311
+ if (lstat(root) || lstat(receipt)) throw new Error(`${root} appeared while archiving`);
312
+ fs.renameSync(temp, root); renamed = true;
313
+ fs.renameSync(receiptTemp, receipt);
314
+ return { ok: true, root, files, bytes: need };
315
+ } catch (error) {
316
+ fs.rmSync(temp, { recursive: true, force: true }); fs.rmSync(receiptTemp, { force: true });
317
+ if (renamed) fs.rmSync(root, { recursive: true, force: true });
318
+ return { ok: false, reason: `archive failed: ${error.message}` };
319
+ }
320
+ }
@@ -36,6 +36,7 @@ import { spawnSync } from 'node:child_process';
36
36
  import { fileURLToPath, pathToFileURL } from 'node:url';
37
37
  import { normalizeHostEvent } from './hook-input.mjs';
38
38
  import { developmentHooksSuspended } from './development-maintenance.mjs';
39
+ import { isDirectInvocation } from './entrypoint.mjs';
39
40
 
40
41
  const HERE = path.dirname(fileURLToPath(import.meta.url));
41
42
  const PLUGIN_ROOT = path.resolve(HERE, '..');
@@ -155,5 +156,5 @@ async function main() {
155
156
  return out.code;
156
157
  }
157
158
 
158
- const isMain = (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
159
+ const isMain = isDirectInvocation(import.meta.url);
159
160
  if (isMain) process.exit(await main().catch(() => 0));
@@ -235,7 +235,8 @@ fi
235
235
  # FOOT_V computed above are reused there.)
236
236
  if [ "$RUFLO_STATE" = "yes" ] && [ "$MEM_STATE" = "off" ]; then
237
237
  out_to 1-2-memory-offer <<'EOF'
238
- This project runs the Ruflo stack but AgentDB persistent project memory is NOT set up (.swarm/memory.db does not exist) — decisions made here are being lost between sessions. rUv's default is memory ON. If you have not already offered this session, offer ONCE, plainly and warmly: "One thing I noticed: this project doesn't have persistent memory turned on — AgentDB would let me carry decisions and context across sessions instead of starting cold each time. Want me to turn it on and wire it up?" On a yes, set it up with the ruflo tools you have (a first memory_store write creates the store) and confirm with the real file path. If they decline, respect it for the rest of the session — the status line keeps them informed without nagging.
238
+ This project runs the Ruflo stack but AgentDB persistent project memory is NOT set up (.swarm/memory.db does not exist) — decisions made here are being lost between sessions. rUv's default is memory ON. If you have not already mentioned it this session, state it ONCE, plainly and warmly: "One thing I noticed: this project doesn't have persistent memory turned on — AgentDB would let me carry decisions and context across sessions instead of starting cold each time; saying 'turn on memory' sets it up." If the user says to, set it up with the ruflo tools you have (a first memory_store write creates the store) and confirm with the real file path. If they decline, respect it for the rest of the session — the status line keeps them informed without nagging.
239
+ Optional, informational advice: mention it at most once as a statement and continue the current task. Do not open a question or approval dialog because of it; it grants no authority to install or update anything.
239
240
  EOF
240
241
  fi
241
242
  # A stale mtime is not a user-facing event. SessionEnd/PreCompact own capture;
@@ -330,7 +331,8 @@ Explain it in the user's words, not the plumbing's:
330
331
  in THIS project, and read the reason it prints. "opt-in required" or "anchor required" means off.
331
332
  - TURN IT OFF: remove that env var (and `RUFLO_DAEMON_AUTOSTART=0` stops the daemon auto-starting).
332
333
 
333
- Offer like this: "Ruflo can quietly tune how it recalls memory — testing changes against a frozen benchmark and only keeping what provably wins, with a receipt you can replay. It's free, it's off by default, and it does nothing until this project has enough history. Want me to turn it on?" If they decline, respect it and never raise it again unless they ask.
334
+ Offer like this: "Ruflo can quietly tune how it recalls memory — testing changes against a frozen benchmark and only keeping what provably wins, with a receipt you can replay. It's free, it's off by default, and it does nothing until this project has enough history; saying 'turn on the flywheel' enables it." If they decline, respect it and never raise it again unless they ask.
335
+ Optional, informational advice: mention it at most once as a statement and continue the current task. Do not open a question or approval dialog because of it; it grants no authority to install or update anything.
334
336
  EOF
335
337
  fi
336
338
 
@@ -431,7 +433,8 @@ if [ -s "$VCACHE" ]; then
431
433
  done < "$VCACHE"
432
434
  if [ -n "$OUTDATED" ]; then
433
435
  { echo "[RuvNet Brain — stack updates available:$OUTDATED]"
434
- echo "Mention this ONCE per session, briefly and helpfully (\"your ruflo is a version behind — want me to update it?\"); if already mentioned this session, stay silent."
436
+ echo "Mention this ONCE per session, briefly, as a statement (\"your ruflo is a version behind the latest release.\"); if already mentioned this session, stay silent."
437
+ echo "Optional, informational advice: mention it at most once as a statement and continue the current task. Do not open a question or approval dialog because of it; it grants no authority to install or update anything."
435
438
  } | out_to 4-2-stack
436
439
  fi
437
440
  fi
@@ -23,6 +23,7 @@ import os from 'node:os';
23
23
  import path from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { normalizeToolOutcome } from './continuity-events.mjs';
26
+ import { isDirectInvocation } from './entrypoint.mjs';
26
27
 
27
28
  const BANNER = /^Searched \d+ RuvNet repos \(/;
28
29
  const CARD = /^⚡ FAST LANE — [^\n]*\n#1 repo=\S+ evidence=curated-capability-card\n/;
@@ -126,7 +127,7 @@ async function main() {
126
127
  }
127
128
 
128
129
  function isMain() {
129
- try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
130
+ try { return isDirectInvocation(import.meta.url); }
130
131
  catch { return false; }
131
132
  }
132
133
  if (isMain()) main();
@@ -2,6 +2,7 @@
2
2
  // Keep executable strings and paths: mentioning a product in code still requires grounding.
3
3
  import fs from 'node:fs';
4
4
  import { fileURLToPath } from 'node:url';
5
+ import { isDirectInvocation } from './entrypoint.mjs';
5
6
 
6
7
  export function codeWithoutInertText(source, python = false) {
7
8
  if (/\r(?!\n)|[\u2028\u2029]/.test(source)) throw new Error('Unsupported line boundary');
@@ -80,7 +81,7 @@ export function projectGroundingInput(raw) {
80
81
  }
81
82
 
82
83
  function isMain() {
83
- try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); }
84
+ try { return isDirectInvocation(import.meta.url); }
84
85
  catch { return false; }
85
86
  }
86
87
 
@@ -60,6 +60,7 @@ import crypto from 'node:crypto';
60
60
  import { spawnSync } from 'node:child_process';
61
61
  import { fileURLToPath, pathToFileURL } from 'node:url';
62
62
  import { parseHookEvent, toolName } from './hook-input.mjs';
63
+ import { isDirectInvocation } from './entrypoint.mjs';
63
64
 
64
65
  const HERE = path.dirname(fileURLToPath(import.meta.url));
65
66
 
@@ -585,7 +586,7 @@ function main() {
585
586
  // stdin here is a pipe bash has already drained and re-fed (`printf '%s' "$INPUT" | node …`), so the
586
587
  // held-open-stdin hole ADR-055 F20 found in unprompted-runtime.mjs cannot occur on this path: there
587
588
  // is no interactive terminal on the other end, ever.
588
- if (process.argv[1] && pathToFileURL(process.argv[1]).href === import.meta.url) {
589
+ if (isDirectInvocation(import.meta.url)) {
589
590
  let code = 0;
590
591
  try { code = main(); } catch (e) {
591
592
  try { process.stderr.write(`[grounding-substance] degraded (${e && e.message}) — write permitted\n`); } catch { /* never */ }
@@ -88,6 +88,7 @@ import {
88
88
  architectureShadow, auditAssertions, correctionText, describeSources, loadVocabulary, logShadow,
89
89
  relayShadow, ruvCapabilityClaims, searchedThisTurn, turnSources,
90
90
  } from './grounding-turn-evidence.mjs';
91
+ import { isDirectInvocation } from './entrypoint.mjs';
91
92
 
92
93
  const HOME = os.homedir();
93
94
  const EXIT_ALLOW = 0;
@@ -238,8 +239,7 @@ async function main() {
238
239
  * helpers — same guard decision-gate.mjs uses, for the same reason (entrypoint-guard-safety). */
239
240
  function isMain() {
240
241
  try {
241
- if (!process.argv[1]) return false;
242
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
242
+ return isDirectInvocation(import.meta.url);
243
243
  } catch { return false; }
244
244
  }
245
245
 
@@ -58,6 +58,7 @@ import { loadSettings, writeAtomic } from './user-settings.mjs';
58
58
  import { classifyPrompt, loadVocabulary } from './grounding-turn-evidence.mjs';
59
59
  import { answerOf, brainAnsweredResponse } from './grounding-answer.mjs';
60
60
  import { RUVNET_GATE1_TERMS } from './ruvnet-gate1-pattern.mjs';
61
+ import { isDirectInvocation } from './entrypoint.mjs';
61
62
 
62
63
  const HOME = os.homedir();
63
64
  export const MARKER_DIR = process.env.RUVNET_GROUNDING_TURN_DIR
@@ -305,8 +306,7 @@ async function main() {
305
306
  * helpers — same guard decision-gate.mjs uses, for the same reason (entrypoint-guard-safety). */
306
307
  function isMain() {
307
308
  try {
308
- if (!process.argv[1]) return false;
309
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
309
+ return isDirectInvocation(import.meta.url);
310
310
  } catch { return false; }
311
311
  }
312
312
 
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import os from 'node:os';
4
4
  import crypto from 'node:crypto';
5
5
  import { fileURLToPath } from 'node:url';
6
+ import { isDirectInvocation } from './entrypoint.mjs';
6
7
 
7
8
  export const FRAME_KIND = 'brain-owned-context-blocks';
8
9
  const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
@@ -73,7 +74,7 @@ export function emitOwnedContext(frame, { env = process.env, rawText = false } =
73
74
  }
74
75
 
75
76
  let direct = false;
76
- try { direct = fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } catch {}
77
+ try { direct = isDirectInvocation(import.meta.url); } catch {}
77
78
  if (direct && process.argv[2] === '--ground-dir') {
78
79
  const directory = process.argv[3]; const blocks = [];
79
80
  for (const id of fs.readdirSync(directory).filter((id) => /^\d+-\d+-[\w-]+$/.test(id)).sort((a, b) => Number(a.split('-')[0]) - Number(b.split('-')[0]))) {
@@ -28,6 +28,7 @@
28
28
 
29
29
  import fs from 'node:fs';
30
30
  import { fileURLToPath } from 'node:url';
31
+ import { isDirectInvocation } from './entrypoint.mjs';
31
32
 
32
33
  // ── GROK CLI PAYLOADS (4.5) ──────────────────────────────────────────────────────────────────────
33
34
  //
@@ -677,8 +678,7 @@ export function invocationLines(cmd, tools) {
677
678
  // argv[1] pointing at some other file.
678
679
  function isMain() {
679
680
  try {
680
- if (!process.argv[1]) return false;
681
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
681
+ return isDirectInvocation(import.meta.url);
682
682
  } catch {
683
683
  return false;
684
684
  }
@@ -215,8 +215,13 @@ if (BRAIN_OFF && entry.offBehavior === 'silence') process.exit(0);
215
215
  // prompt. They stay registered and run only when RUVNET_STOP_NUDGES=full, so nothing is deleted and the
216
216
  // tests still exercise them. The blocking hooks (decision-gate), the grounding directive and the silent
217
217
  // capture hooks are unaffected.
218
- const QUIET_BY_DEFAULT = new Set(['unprompted-speech', 'capacity-aware-parallel-work', 'grounding-turn-gate']);
219
- if (QUIET_BY_DEFAULT.has(hookId) && String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() !== 'full') process.exit(0);
218
+ // unprompted-speech is not exited (2026-10-25): its lesson channel is the only route a recorded correction
219
+ // takes to the next prompt, and exiting it whole silenced every learned lesson from 4.7.8 (D4 wire: 0 bytes).
220
+ // Quiet runs only its lesson/alarm producers; advocacy, promotion and anticipate offers stay silent.
221
+ const NUDGES_FULL = String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
222
+ const QUIET_BY_DEFAULT = new Set(['capacity-aware-parallel-work', 'grounding-turn-gate']);
223
+ if (QUIET_BY_DEFAULT.has(hookId) && !NUDGES_FULL) process.exit(0);
224
+ const QUIET_SPEECH_CHANNELS = hookId === 'unprompted-speech' && !NUDGES_FULL ? 'lesson,alarm' : null;
220
225
 
221
226
  // Read a bounded payload before dispatch. The ground body owns the quiet-prompt
222
227
  // decision because it resolves canonical memory beyond the hook process's cwd.
@@ -370,6 +375,7 @@ function registeredTimeoutMs(extraArgs) {
370
375
  // chosen from the typed table — never from input.
371
376
  function runHook(file, activeVersion = '') {
372
377
  if (!fs.existsSync(file)) return 0; // nothing to run — never invent a failure
378
+ try { if (QUIET_SPEECH_CHANNELS && !fs.readFileSync(file, 'utf8').includes('RUVNET_UNPROMPTED_CHANNELS')) return 0; } catch { return 0; } // quiet + a <=4.7.20 spine body that ignores the narrowing (it would speak advocacy) → the old whole-hook quiet exit
373
379
  let cmd;
374
380
  if (entry.interpreter === 'node') {
375
381
  cmd = process.execPath;
@@ -395,6 +401,7 @@ function runHook(file, activeVersion = '') {
395
401
  // machine where `node` is not on the PATH the host hands its hooks. Additive: older bodies ignore it.
396
402
  RUVNET_NODE_BIN: process.execPath,
397
403
  ...(['unprompted-speech', 'ground-ruvnet', 'decision-gate'].includes(hookId) ? { RUVNET_HOOK_CONTEXT_BUDGET: '1' } : {}),
404
+ ...(QUIET_SPEECH_CHANNELS ? { RUVNET_UNPROMPTED_CHANNELS: QUIET_SPEECH_CHANNELS } : {}),
398
405
  ...(activeVersion ? { RUVNET_BRAIN_ACTIVE_VERSION: activeVersion } : {}),
399
406
  ...(BRAIN_OFF && entry.offBehavior === 'partial' ? { RUVNET_BRAIN_OFF: '1' } : {}),
400
407
  };
@@ -2,7 +2,7 @@
2
2
  // Host-neutral automatic updater. The published installer is the single coordinator for Claude
3
3
  // Code and Codex, so lifecycle updates cannot drift into host-specific shell pipelines again.
4
4
  import { spawnSync } from 'node:child_process';
5
- import { automaticInvocation, automaticPath, updateSource } from './automatic-update.mjs';
5
+ import { automaticInvocation, automaticPath, installedUpdater, updateSource } from './automatic-update.mjs';
6
6
  import { developerCoordinatorOwner } from './developer-update-owner.mjs';
7
7
 
8
8
  const CHILD_ENV_KEYS = new Set([
@@ -92,7 +92,20 @@ if (knowledgeAt >= 0) {
92
92
  // Decide by the recorded IDENTITY verdict whenever this run wrote one; the exit code only when it did
93
93
  // not (a network failure or an incompatible release dies before recording). Measured against the real
94
94
  // API: an install whose profile selects no stores exits 0 on UPDATE_AVAILABLE ("All stores current").
95
- const newer = verdict ? ['UPDATE_AVAILABLE', 'UNKNOWN'].includes(verdict) : check.status === 10;
95
+ let newer = verdict ? ['UPDATE_AVAILABLE', 'UNKNOWN'].includes(verdict) : check.status === 10;
96
+ // Exit 5 is a remembered rejection keyed to the KB's runtime stamp. Only the installer re-stamps it, so when the
97
+ // version that would run differs from the stamp, run the update anyway (AuditC #5; the coordinator does the same).
98
+ if (!newer && !verdict && check.status === 5) {
99
+ let stamped = null, target = null;
100
+ try { stamped = readJson(path.join(kbDir, 'RUNTIME-IDENTITY.json'))?.brainVersion || null; } catch { /* no stamp */ }
101
+ try {
102
+ if (source === 'latest') {
103
+ const response = await fetch('https://registry.npmjs.org/ruvnet-brain/latest', { signal: AbortSignal.timeout(5_000) });
104
+ target = response.ok ? (await response.json()).version || null : null;
105
+ } else target = installedUpdater().version || null;
106
+ } catch { /* unknown target: stays incompatible */ }
107
+ newer = Boolean(target && target !== stamped);
108
+ }
96
109
  if (!newer && (verdict || check.status === 0)) {
97
110
  note({ outcome: verdict === 'REFUSED' ? 'refused' : 'current', verdict: verdict || 'CURRENT', candidateTag, reason: '' });
98
111
  stop();
@@ -125,7 +138,9 @@ if (knowledgeAt >= 0) {
125
138
  const invocation = automaticInvocation(['--update', '--no-nightly-prompt'], { source });
126
139
  const run = spawnSync(invocation.executable, invocation.args, {
127
140
  env: childEnvironment(), encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
128
- timeout: Number(process.env.RUVNET_KNOWLEDGE_UPDATE_TIMEOUT_MS || 25 * 60_000),
141
+ // Above the coordinator's installer limit (135 min: a 90-minute bundle deadline plus the apply), below the
142
+ // SessionStart TTL (session-start-update-plane.mjs AUTO_UPDATE_POLICY.ttlSec), so an outcome is always recorded.
143
+ timeout: Number(process.env.RUVNET_KNOWLEDGE_UPDATE_TIMEOUT_MS || 150 * 60_000),
129
144
  });
130
145
  process.stdout.write(run.stdout || '');
131
146
  process.stderr.write(run.stderr || '');
@@ -133,8 +148,11 @@ if (knowledgeAt >= 0) {
133
148
  const tail = `${run.stderr || ''}\n${run.stdout || ''}`.replace(/\u001b\[[0-9;]*m/g, '')
134
149
  .split('\n').map((line) => line.trim()).filter(Boolean);
135
150
  const code = run.error ? null : run.status;
136
- record({ outcome: code === 0 ? 'succeeded' : 'failed', code,
137
- reason: code === 0 ? '' : String(run.error?.message || tail.find((l) => /✗|error|fail/i.test(l)) || tail.at(-1) || 'no output').slice(0, 200) });
151
+ // Exit 12 is the installer's "knowledge applied, redundant rollback cleanup still pending"; the coordinator
152
+ // (developer-update.mjs) accepts it as success too. Recording it FAILED spoke a false failure line.
153
+ const succeeded = code === 0 || code === 12;
154
+ record({ outcome: succeeded ? 'succeeded' : 'failed', code,
155
+ reason: succeeded ? '' : String(run.error?.message || tail.find((l) => /✗|error|fail/i.test(l)) || tail.at(-1) || 'no output').slice(0, 200) });
138
156
  }
139
157
  } catch (error) {
140
158
  record({ outcome: 'failed', code: null, reason: error.message.slice(0, 200) });
@@ -147,6 +165,12 @@ if (knowledgeAt >= 0) {
147
165
  let invocation;
148
166
  try { invocation = automaticInvocation(['--update', '--host-sync-only', '--no-nightly-prompt']); }
149
167
  catch (error) { process.stderr.write(`[ruvnet-brain] automatic update refused: ${error.message}\n`); process.exit(1); }
168
+ if (invocation.source === 'developer-suite') {
169
+ // The coordinator installs code packages only in its scheduled run, and its knowledge step belongs to the SessionStart
170
+ // self-heal (--knowledge, sized for the bundle download). Running it here under a 9-minute limit would kill it mid-apply.
171
+ process.stdout.write('[ruvnet-brain] coordinated updates own this machine: the scheduled nightly installs new code; knowledge updates run from the SessionStart self-heal\n');
172
+ process.exit(0);
173
+ }
150
174
  const result = spawnSync(invocation.executable, invocation.args, {
151
175
  // The downloaded package must not inherit unrelated API keys, cloud credentials, or tokens from
152
176
  // the interactive host. npm's registry integrity protects the package bytes; this boundary
@@ -36,6 +36,7 @@ import fs from 'node:fs';
36
36
  import os from 'node:os';
37
37
  import path from 'node:path';
38
38
  import { fileURLToPath } from 'node:url';
39
+ import { isDirectInvocation } from './entrypoint.mjs';
39
40
 
40
41
  /**
41
42
  * Each entry knows how to enumerate the identifiers its CLI actually accepts on THIS machine.
@@ -118,7 +119,7 @@ export function check(command, opts = {}) {
118
119
  }
119
120
 
120
121
  const isMain = (() => {
121
- try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
122
+ try { return isDirectInvocation(import.meta.url); }
122
123
  catch { return false; }
123
124
  })();
124
125