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
@@ -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
 
@@ -14,7 +14,18 @@
14
14
  // listed with these exact bytes in the copy's own ARCHIVE-MANIFEST.json, or a public store family (named by either COVERAGE.json
15
15
  // or the live SOURCE.json) that a newer release replaced or retired;
16
16
  // * installer-written or reinstallable (node_modules/, the updater/validator files the installer places);
17
- // * a symbolic link identical in the live brain (links are compared, never followed).
17
+ // * a symbolic link identical in the live brain (links are compared, never followed);
18
+ // * (#335) a regular file byte-identical at the same path in a KEPT sibling: another copy the caller has
19
+ // already decided to retain, or a `kb.archive/<stamp>/<copy>/` tree. The caller passes only copies that
20
+ // stay on disk, so duplicate preserved copies release down to one and no unique byte is lost.
21
+ // * an OPERATIONAL file the Brain's own processes (or a ruflo hook run with the KB as its cwd) wrote into
22
+ // the tree: a top-level update.log / forge-update.log / forge-guard-injection.log, or ruflo's
23
+ // .claude-flow/ scratch, each a regular file of at most RESCUE_CAP_BYTES (RESCUE_TOTAL_CAP_BYTES per
24
+ // copy). No bundle ships these and nothing reads them back, but a log can be the only trace of a failed
25
+ // night, so they are not just dropped: they are returned as `rescue` (with their bytes), and the caller
26
+ // copies them out with footprint-io.mjs rescueOperationalFiles BEFORE it removes the copy; a failed
27
+ // rescue keeps the copy. The rescued files are kept for the newest released copies (brain-footprint.mjs
28
+ // FOOTPRINT_POLICY.rescuedCopiesKept).
18
29
  // Anything else — a user's own file, an unfenced store, a link the live brain lacks — KEEPS the copy, and
19
30
  // is named. Paths are checked for symlink ancestors within both KB roots before any exemption; host
20
31
  // aliases above those roots (such as macOS /tmp) are not traversed as part of this check. Invalid metadata,
@@ -54,11 +65,25 @@ const stemOf = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i
54
65
  const storeStem = (file) => path.basename(String(file)).toLowerCase()
55
66
  .replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '')
56
67
  .replace(/(?:\.big)?(?:\.(?:meta|symbols)\.json|\.passages\.jsonl)$/, '');
68
+ // Operational files (see the header). Top-level logs by exact name: plugin/scripts/nightly-scheduler.mjs
69
+ // installScheduler (update.log), kb/forge-guard-injection.mjs LOG_PATH, kb/forge-update.mjs's documented
70
+ // cron line (forge-update.log). .claude-flow/ is ruflo's working state, the same debris brain-footprint.mjs
71
+ // RUFLO_DEBRIS removes from ruflo-cwd. A larger file, a link, or anything else (.swarm/ included) stays unique,
72
+ // and so does every operational file past RESCUE_TOTAL_CAP_BYTES per copy (the rescue itself is bounded).
73
+ const OPERATIONAL_LOG = /^(?:update|forge-update|forge-guard-injection)\.log$/;
74
+ export const RESCUE_CAP_BYTES = 2 * 1024 * 1024;
75
+ export const RESCUE_TOTAL_CAP_BYTES = 16 * 1024 * 1024;
76
+ const isOperational = (relative) => (!relative.includes(path.sep) && OPERATIONAL_LOG.test(relative))
77
+ || (relative.split(path.sep)[0] === '.claude-flow' && relative.includes(path.sep));
57
78
  // Files the installer/updater writes into a KB that no bundle ships (bin/install.mjs placeUpdater,
58
79
  // placeTrustedCoverageValidator, ensureVerifier; the updater's snapshot receipt). Re-created on every install.
59
- const INSTALLER_WRITTEN = new Set(['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json', '.refresh-snapshot.json',
80
+ // Every bin/install.mjs UPDATER_FILES name must be here (tests/unit/kb-copy-proof-unknown-content.test.mjs holds the
81
+ // two lists together): download-retry.mjs and forge-guard-injection.mjs were placed but missing, so an old copy whose
82
+ // placed version differed from both the live file and its own manifest was kept as "unique" (review round 2, Opus #5).
83
+ export const INSTALLER_WRITTEN = new Set(['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json', '.refresh-snapshot.json',
60
84
  'forge-update.mjs', 'zip-extract.mjs', 'brain-profile.mjs', 'refresh-run.mjs', 'update-storage-transaction.mjs',
61
- 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'verify-citation.mjs', 'package-lock.json', '.DS_Store']);
85
+ 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'download-retry.mjs', 'forge-guard-injection.mjs',
86
+ 'verify-citation.mjs', 'package-lock.json', '.DS_Store']);
62
87
 
63
88
  /** A directory that is a knowledge-base tree (SOURCE.json plus a store or the search entry point). */
64
89
  export const isKbTree = (dir) => Boolean(lstat(path.join(dir, 'SOURCE.json')))
@@ -140,10 +165,11 @@ function metadataFailure(root) {
140
165
  }
141
166
 
142
167
  /**
143
- * @returns {{disposable: boolean, unique: {file: string, why: string}[], reason: string}} — a kept copy
144
- * always names the files that keep it.
168
+ * @returns {{disposable: boolean, unique: {file: string, why: string}[], rescue?: {file: string, bytes: number,
169
+ * sha256: string}[], reason: string}} — a kept copy always names the files that keep it; a disposable copy
170
+ * lists the operational files that must be rescued before it is removed.
145
171
  */
146
- export function kbCopyProof({ copyDir, liveDir }) {
172
+ export function kbCopyProof({ copyDir, liveDir, keptSiblings = [] }) {
147
173
  const copy = lstat(copyDir);
148
174
  if (!copy || copy.isSymbolicLink() || !copy.isDirectory()) return { disposable: false, unique: [], reason: 'not a real directory (a link is never entered)' };
149
175
  const live = lstat(liveDir);
@@ -172,7 +198,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
172
198
  // it lists with these exact bytes is a public, re-downloadable release file.
173
199
  const shipped = new Map((readJson(path.join(copyDir, 'ARCHIVE-MANIFEST.json'))?.files || [])
174
200
  .filter((f) => typeof f?.path === 'string').map((f) => [path.normalize(f.path), f]));
175
- const unique = [];
201
+ // #335: retained siblings that may account for a file. Only real directories, never the copy or live itself.
202
+ const siblings = (Array.isArray(keptSiblings) ? keptSiblings : []).filter((dir) => {
203
+ const st = lstat(dir);
204
+ return st && st.isDirectory() && !st.isSymbolicLink() && path.resolve(dir) !== path.resolve(copyDir)
205
+ && path.resolve(dir) !== path.resolve(liveDir);
206
+ });
207
+ let bySibling = 0;
208
+ const inKeptSibling = (relative) => {
209
+ const hit = siblings.some((dir) => safeAncestors(dir, relative) && sameBytes(path.join(copyDir, relative), path.join(dir, relative)));
210
+ if (hit) bySibling += 1;
211
+ return hit;
212
+ };
213
+ const unique = []; const rescue = []; let rescueBytes = 0;
176
214
  let files;
177
215
  try { files = walk(copyDir); } catch (error) { return { disposable: false, unique, reason: `unreadable copy: ${error.message}` }; }
178
216
  for (const { relative, link, size } of files) {
@@ -183,7 +221,7 @@ export function kbCopyProof({ copyDir, liveDir }) {
183
221
  continue;
184
222
  }
185
223
  if (isPrivate) {
186
- if (!sameBytes(path.join(copyDir, relative), inLive)) {
224
+ if (!sameBytes(path.join(copyDir, relative), inLive) && !inKeptSibling(relative)) {
187
225
  unique.push({ file: relative, why: lstat(inLive) ? 'private file differs from the live brain' : 'private file absent from the live brain' });
188
226
  }
189
227
  continue;
@@ -202,9 +240,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
202
240
  if (listed && listed.bytes === size && listed.sha256 === sha256File(path.join(copyDir, relative))) continue;
203
241
  if (sameBytes(path.join(copyDir, relative), inLive)) continue; // existence alone proves no ownership or redundancy
204
242
  if (publicNames.has(storeStem(relative))) continue; // a public store family a newer release replaced or retired
243
+ if (inKeptSibling(relative)) continue; // #335: these exact bytes stay in a retained sibling copy or the archive
244
+ if (isOperational(relative) && size <= RESCUE_CAP_BYTES && rescueBytes + size <= RESCUE_TOTAL_CAP_BYTES) {
245
+ rescue.push({ file: relative, bytes: size, sha256: sha256File(path.join(copyDir, relative)) });
246
+ rescueBytes += size;
247
+ continue;
248
+ }
249
+ if (isOperational(relative)) { unique.push({ file: relative, why: 'an operational file past the rescue cap (2 MiB each, 16 MiB per copy)' }); continue; }
205
250
  unique.push({ file: relative, why: 'not in the live brain, not in this copy\'s release manifest, not a public store' });
206
251
  }
207
252
  return unique.length
208
253
  ? { disposable: false, unique, reason: `holds ${unique.length} file(s) the live brain does not: ${unique.slice(0, 4).map((u) => u.file).join(', ')}${unique.length > 4 ? ', …' : ''}` }
209
- : { disposable: true, unique, reason: 'every private file is byte-identical in the live brain; public bytes are re-downloadable' };
254
+ : { disposable: true, unique, rescue, reason: `${bySibling
255
+ ? `${bySibling} file(s) the live brain lacks are byte-identical in a kept copy or kb.archive; the rest is in the live brain or re-downloadable`
256
+ : 'every private file is byte-identical in the live brain; public bytes are re-downloadable'}${
257
+ rescue.length ? `; ${rescue.length} operational log/scratch file(s) are rescued to the brain home first` : ''}` };
210
258
  }
@@ -9,6 +9,7 @@ import { learningContext } from './runtime-preferences.mjs';
9
9
  import { recordLearningObservation, distillObservations, observationKey } from './learning-store.mjs';
10
10
  import { resolveRuflo } from './ruflo-bin.mjs';
11
11
  import { recordHookFailure } from './hook-failures.mjs';
12
+ import { isDirectInvocation } from './entrypoint.mjs';
12
13
 
13
14
  // LOAD BOUNDS (measured 2026-10-21: one busy session spawned hundreds of concurrent detached `ruflo memory store`
14
15
  // children, which timed out against each other and wrote 826 ledger rows in a day). Owner ruling 2026-10-12 stands:
@@ -148,7 +149,7 @@ function runDetached(mode) {
148
149
  } catch (error) { fail(error); } finally { if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT); }
149
150
  }
150
151
 
151
- const isMain = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
152
+ const isMain = isDirectInvocation(import.meta.url);
152
153
  if (isMain && ['--observe', '--distill'].includes(process.argv[2])) {
153
154
  runDetached(process.argv[2]);
154
155
  process.exit(0);
@@ -17,23 +17,29 @@ export function learningTarget(context, { env = process.env, explicitLegacyApply
17
17
  const root = path.join(context.home, '.claude', 'global-memory');
18
18
  const chosen = loadRuntimePreferences({ env, cwd: context.projectDir }).values.learningScope === 'user';
19
19
  if (!chosen && !explicitLegacyApply) throw new Error('user learning requires persisted user consent or explicit legacy Apply');
20
- const rootStat = fs.lstatSync(root);
21
- if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('user learning root is not a real directory');
20
+ // #377: the user store is a FIXED path, validated directly. It is not a project, so no Git root is
21
+ // resolved (a ~/.claude that is itself a Git checkout made every user write "foreign global learner
22
+ // root"), and a missing store says so instead of surfacing a raw lstat ENOENT.
22
23
  const db = path.join(root, '.swarm', 'memory.db');
23
- if (!fs.lstatSync(db).isFile()) throw new Error('user store not adopted');
24
- const resolved = resolveProjectStore({ projectDir: root, requestedStorePath: db, gitTimeoutMs: 1000 });
25
- if (resolved.projectRoot !== root) throw new Error('foreign global learner root');
26
- return resolved.canonicalAgentDbPath;
24
+ const entry = (file) => { try { return fs.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
25
+ const [rootStat, swarmStat, dbStat] = [root, path.dirname(db), db].map(entry);
26
+ if (!rootStat || !swarmStat || !dbStat) throw new Error(`user store not adopted (no ${db})`);
27
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) throw new Error('user learning root is not a real directory');
28
+ if (!swarmStat.isDirectory() || swarmStat.isSymbolicLink()) throw new Error('user learning .swarm is not a real directory');
29
+ if (!dbStat.isFile() || dbStat.isSymbolicLink() || dbStat.nlink !== 1) throw new Error('user store is not an exclusive regular file');
30
+ const canonicalRoot = path.join(fs.realpathSync.native(context.home), '.claude', 'global-memory');
31
+ if (fs.realpathSync.native(root) !== canonicalRoot) throw new Error('user learning root is reached through a link');
32
+ return path.join(canonicalRoot, '.swarm', 'memory.db');
27
33
  }
28
34
 
29
- export function runLearningRuflo(binary, args, db, { env = process.env, deadline = Date.now() + 18_000, maxStepMs = 6000 } = {}) {
35
+ export function runLearningRuflo(binary, args, db, { env = process.env, deadline = Date.now() + 18_000, maxStepMs = 6000, maxBuffer = 65536 } = {}) {
30
36
  const remaining = deadline - Date.now();
31
37
  if (remaining <= 0) throw new Error('learning deadline exhausted');
32
38
  const cwd = rufloRunDir(db, { root: env.RUVNET_RUFLO_CWD_ROOT || path.join(env.HOME || env.USERPROFILE || os.homedir(), '.cache', 'ruvnet-brain', 'learning-ruflo') });
33
39
  try {
34
40
  const invocation = rufloInvocation(binary, args);
35
41
  return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' },
36
- encoding: 'utf8', shell: false, timeout: Math.min(maxStepMs, remaining), killSignal: 'SIGKILL', maxBuffer: 65536, windowsHide: true });
42
+ encoding: 'utf8', shell: false, timeout: Math.min(maxStepMs, remaining), killSignal: 'SIGKILL', maxBuffer, windowsHide: true });
37
43
  } finally { fs.rmSync(cwd, { force: true, recursive: true }); }
38
44
  }
39
45
 
@@ -73,15 +79,53 @@ export function recordLearningObservation(binary, context, { tool, action, sessi
73
79
  return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true };
74
80
  }
75
81
 
76
- /** Observation count through `ruflo memory list` (no direct SQLite). Unknown stays unknown. */
77
- export function learningStoreStatus(db, { binary = resolveRuflo(), env = process.env } = {}) {
78
- if (!binary) return { known: false, observations: 0, patterns: null, lastDistillAt: null };
82
+ /**
83
+ * The namespace's last distillation from `ruflo memory distill status --db` (#377). That command has
84
+ * only a table output: the `distill_state cursor` table's Namespace column is 20 wide, so
85
+ * `learning-observations` prints as `learning-observat...`; a truncated cell matches by prefix, and
86
+ * an ambiguous or unreadable answer stays unknown. `null` with known=true means never distilled.
87
+ */
88
+ export function parseDistillStatus(text, namespace = LEARNING_NAMESPACE) {
89
+ const lines = String(text || '').replace(/\u001b\[[0-9;]*m/g, '').split(/\r?\n/);
90
+ const start = lines.findIndex((line) => /distill_state cursor/.test(line));
91
+ if (start < 0) return /No distill_state cursor rows yet/.test(lines.join('\n')) ? { known: true, lastDistillAt: null } : { known: false, lastDistillAt: null };
92
+ const rows = [];
93
+ for (const line of lines.slice(start + 1)) {
94
+ const cells = line.split('|').map((cell) => cell.trim());
95
+ if (cells.length < 5 || cells[1] === 'Namespace') continue;
96
+ rows.push({ name: cells[1], lastRun: cells[3] });
97
+ }
98
+ const exact = rows.filter((row) => row.name === namespace);
99
+ const truncated = rows.filter((row) => row.name.endsWith('...') && namespace.startsWith(row.name.slice(0, -3)));
100
+ const matches = exact.length ? exact : truncated;
101
+ if (!matches.length) return { known: true, lastDistillAt: null };
102
+ if (matches.length > 1) return { known: false, lastDistillAt: null };
103
+ if (matches[0].lastRun === 'never') return { known: true, lastDistillAt: null };
104
+ const at = Date.parse(matches[0].lastRun);
105
+ return Number.isFinite(at) ? { known: true, lastDistillAt: new Date(at).toISOString() } : { known: false, lastDistillAt: null };
106
+ }
107
+
108
+ /** Observation count through `ruflo memory list` and the last distillation through `ruflo memory distill status` (no direct SQLite). Unknown stays unknown. */
109
+ export function learningStoreStatus(db, { binary = resolveRuflo(), env = process.env, run = runLearningRuflo } = {}) {
110
+ const unknown = { known: false, observations: 0, patterns: null, lastDistillAt: null, distillKnown: false, pendingObservations: null, oldestPendingAt: null };
111
+ if (!binary) return unknown;
79
112
  try {
80
- const listed = runLearningRuflo(binary, ['memory', 'list', '--namespace', LEARNING_NAMESPACE, '--limit', '100000', '--format', 'json', '--path', db], db, { env });
113
+ const listed = run(binary, ['memory', 'list', '--namespace', LEARNING_NAMESPACE, '--limit', '100000', '--format', 'json', '--path', db], db, { env, maxBuffer: 64 * 1024 * 1024 });
81
114
  const text = String(listed.stdout || ''); const at = text.indexOf('[');
82
- if (listed.status !== 0 || at < 0) return { known: false, observations: 0, patterns: null, lastDistillAt: null };
83
- return { known: true, observations: JSON.parse(text.slice(at)).length, patterns: null, lastDistillAt: null };
84
- } catch { return { known: false, observations: 0, patterns: null, lastDistillAt: null }; }
115
+ if (listed.status !== 0 || at < 0) return unknown;
116
+ const rows = JSON.parse(text.slice(at));
117
+ let distill = { known: false, lastDistillAt: null };
118
+ try {
119
+ const status = run(binary, ['memory', 'distill', 'status', '--db', db], db, { env });
120
+ if (!status.error && status.status === 0) distill = parseDistillStatus(status.stdout);
121
+ } catch { /* distillation state stays unknown */ }
122
+ const since = distill.lastDistillAt ? Date.parse(distill.lastDistillAt) : -Infinity;
123
+ // Observations recorded after the last distillation are the ones still waiting for it.
124
+ const pending = distill.known ? rows.filter((row) => !(Number(row?.createdAt) <= since)) : null;
125
+ const oldest = pending ? Math.min(...pending.map((row) => Number(row?.createdAt)).filter(Number.isFinite)) : Infinity;
126
+ return { known: true, observations: rows.length, patterns: null, lastDistillAt: distill.lastDistillAt, distillKnown: distill.known,
127
+ pendingObservations: pending ? pending.length : null, oldestPendingAt: Number.isFinite(oldest) ? new Date(oldest).toISOString() : null };
128
+ } catch { return unknown; }
85
129
  }
86
130
 
87
131
  /** `ruflo memory backup` then `ruflo memory distill run`; both are rUv's own commands. */
@@ -118,9 +162,11 @@ export function observeLearning(options = {}) {
118
162
  if (!context.enabled) return result;
119
163
  try {
120
164
  result.learningDb = learningTarget(context, { env });
121
- const state = learningStoreStatus(result.learningDb, { env });
165
+ const state = learningStoreStatus(result.learningDb, { env, ...(options.binary ? { binary: options.binary } : {}), ...(options.run ? { run: options.run } : {}) });
122
166
  result.statusKnown = state.known; result.trajectories = state.observations; result.observations = state.observations;
123
167
  result.patterns = state.patterns; result.lastDistillAt = state.lastDistillAt;
168
+ result.distillKnown = state.distillKnown; result.pendingObservations = state.pendingObservations; result.oldestPendingAt = state.oldestPendingAt;
169
+ if (state.lastDistillAt) result.lastTrainSeconds = Math.max(0, Math.round(((options.now ?? Date.now()) - Date.parse(state.lastDistillAt)) / 1000));
124
170
  } catch { /* Missing consent/store/readback remains unknown. */ }
125
171
  return result;
126
172
  }
@@ -24,6 +24,7 @@ import { execFileSync } from 'node:child_process';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { makeLesson, updateLessons, loadLessons, ENFORCEMENT, ORIGIN, STATUS, TRIGGERS } from './lesson-store.mjs';
26
26
  import { loadNodeSqlite } from './node-sqlite.mjs';
27
+ import { isDirectInvocation } from './entrypoint.mjs';
27
28
 
28
29
  /** Every bridged lesson id starts with this. It is how a merge knows which rows it owns. */
29
30
  export const BRIDGE_PREFIX = 'G-';
@@ -250,8 +251,7 @@ const argv = process.argv.slice(2);
250
251
  */
251
252
  function isMain() {
252
253
  try {
253
- if (!process.argv[1]) return false;
254
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
254
+ return isDirectInvocation(import.meta.url);
255
255
  } catch { return false; }
256
256
  }
257
257
  if (isMain()) {