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
@@ -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()) {
@@ -80,6 +80,8 @@ import {
80
80
  CONFIG_ROOT,
81
81
  loadLessons,
82
82
  lessonsFor,
83
+ projectNameFor,
84
+ lessonApplies,
83
85
  ENFORCEMENT,
84
86
  STATUS,
85
87
  ORIGIN,
@@ -263,42 +265,8 @@ const gateState = event ? readGateState() : {};
263
265
  * the fastest way to make someone uninstall a nudge is to nudge them about something that has
264
266
  * nothing to do with what they are doing.
265
267
  */
266
- const HERE = (() => {
267
- let d = process.cwd();
268
- for (let i = 0; i < 12; i++) {
269
- if (fs.existsSync(path.join(d, '.git'))) break;
270
- const up = path.dirname(d);
271
- if (up === d) { d = process.cwd(); break; }
272
- d = up;
273
- }
274
- return path.basename(d);
275
- })();
276
- /**
277
- * A "suffix match" for project names, bounded so it cannot fire on a bare shared tail. `a` matches
278
- * `b` when `b` is a whole path/name SEGMENT suffix of `a` — the character immediately before it must
279
- * be a separator, never a mid-word letter. Without this, "Sentry" matched inside "WhitSentry" and any
280
- * project name that merely happens to end another's, which is the exact cross-project leak the
281
- * 2026-07-22 project-scope fix (this same function, below) was written to close.
282
- */
283
- const segmentSuffixMatch = (a, b) => {
284
- if (a === b) return true;
285
- if (a.length <= b.length || !a.endsWith(b)) return false;
286
- return /[-_/]/.test(a[a.length - b.length - 1]);
287
- };
288
-
289
- /** Does this lesson belong to the project we are standing in? Match is loose on purpose — stored
290
- * names carry prefixes like `Code-` that the directory name does not — but bounded to a real
291
- * delimiter so it cannot match a bare, accidental shared suffix between unrelated projects. */
292
- const isHome = (l) => {
293
- const ps = Array.isArray(l.projects) ? l.projects : [];
294
- if (!ps.length) return true; // unscoped: applies anywhere, by declaration
295
- return ps.some((p) => {
296
- const n = String(p).replace(/^Code-/, '');
297
- return n === HERE || String(p) === HERE
298
- || segmentSuffixMatch(HERE, n) || segmentSuffixMatch(n, HERE);
299
- });
300
- };
301
- const isUniversal = (l) => Array.isArray(l.projects) && l.projects.length >= 2;
268
+ const HERE = projectNameFor(process.cwd());
269
+ const applies = (l) => lessonApplies(l, HERE);
302
270
 
303
271
  // Apply the mutate-machine predicate defined above. `mutate-machine` is requested for EVERY Bash
304
272
  // call (plugin/scripts/lesson-hooks.sh:98) — it is the ONLY trigger the dispatcher fires unconditionally
@@ -313,10 +281,10 @@ const effectiveTriggers = command === null
313
281
  const seen = new Set();
314
282
  const candidates = [];
315
283
  for (const t of effectiveTriggers) {
316
- for (const l of lessonsFor(t, lessons.filter((l) => isHome(l) || isUniversal(l)), { limit: 3 })) {
284
+ for (const l of lessonsFor(t, lessons.filter(applies), { limit: 3 })) {
317
285
  if (seen.has(l.id)) continue;
318
- // Away from home, only a lesson with cross-project evidence may speak.
319
- if (!isHome(l) && !isUniversal(l)) continue;
286
+ // Away from home, only a lesson with cross-project evidence may speak (lessonApplies: one rule, shared with the Console).
287
+ if (!applies(l)) continue;
320
288
  seen.add(l.id); candidates.push(l);
321
289
  }
322
290
  }
@@ -36,6 +36,8 @@
36
36
  * node scripts/lesson-promote.mjs --json # machine-readable, for the console
37
37
  * node scripts/lesson-promote.mjs --apply # write the promotion block (backs up first)
38
38
  * node scripts/lesson-promote.mjs --min-projects 3
39
+ * node scripts/lesson-promote.mjs --demote-theme <key> # reject a theme — sticky, never re-proposed
40
+ * node scripts/lesson-promote.mjs --restore-theme <key> # undo a demotion
39
41
  */
40
42
  import fs from 'node:fs';
41
43
  import path from 'node:path';
@@ -124,34 +126,55 @@ export function collectLessons(root = PROJECTS) {
124
126
  * other project's context.
125
127
  */
126
128
  /**
127
- * Themes the user has explicitly rejected. Read from the lesson store's demoted rows.
129
+ * THE DEMOTED-THEMES FILE — where "reject this theme, permanently" is recorded, and why it is not a
130
+ * field on a lesson row.
128
131
  *
129
- * WITHOUT THIS, DEMOTION WAS THEATRE. `lesson-ratify.mjs --demote` set a flag the miner never
130
- * looked at, so the next mining run would re-propose the exact rule the user had just deleted.
131
- * ADR-030 §5 states the requirement plainly — "a one-click demote that the next nightly silently
132
- * undoes is worse than no demote at all, because the user stops trusting the control and, correctly,
133
- * stops using it" — and the code did not implement it. Verified 2026-07-22: zero references to
134
- * `demoted` in this file.
132
+ * The original design (2026-07-22) stored a `themeKey` on a demoted lesson inside `lessons.json` and
133
+ * read it back here. It never worked, and could not have: `makeLesson()` (`lesson-store.mjs`)
134
+ * destructures a FIXED field set and freezes the result, so an unknown key — `themeKey` included — is
135
+ * silently dropped the moment a row passes through it. Verified 2026-09-07 by construction: a
136
+ * hand-edited row carrying `themeKey` survives only until the very next legitimate write ANYWHERE in
137
+ * the store (`lesson-ratify.mjs --demote`, `updateLessons()`, any `loadLessons()` → `saveLessons()`
138
+ * round trip) — at that point every row, not just the one touched, has been re-serialized through
139
+ * `makeLesson()` and the field is gone. There was also no writer at all: `lesson-ratify.mjs --demote`
140
+ * only ever demotes by lesson `id`, never by theme. `demotedThemeKeys()` therefore always returned an
141
+ * empty set in every real invocation, and the sticky-demotion guard in `analyze()` below could never
142
+ * fire — the exact "demotion is theatre" failure ADR-030 §5 names, just for a different reason than
143
+ * the 2026-07-22 comment this replaces described.
135
144
  *
136
- * Read defensively: the store may be absent, locked, or from a newer schema. A miner that throws
137
- * because it could not read an optional file is worse than one that proposes a rejected theme.
145
+ * `lesson-gate.mjs`'s `OPTIN_PATH` documents the identical failure shape for a different field
146
+ * (`userOptedIntoBlocking`) and moved that consent to its own file for exactly this reason. That
147
+ * precedent was never applied here; it is now. Theme demotion gets its own small file, written only
148
+ * by `--demote-theme` / `--restore-theme` below, and never touched by the mining or lesson-store
149
+ * write paths — so no future lesson-store write can silently erase it.
138
150
  */
139
- function demotedThemeKeys() {
151
+ export const DEMOTED_THEMES_PATH = process.env.RUVNET_DEMOTED_THEMES
152
+ || path.join(HOME, '.config', 'ruvnet-brain', 'demoted-themes.json');
153
+
154
+ function demotedThemeKeys(file = DEMOTED_THEMES_PATH) {
140
155
  try {
141
- const file = process.env.RUVNET_LESSON_STORE
142
- || path.join(os.homedir(), '.config', 'ruvnet-brain', 'lessons.json');
143
156
  const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
144
- return new Set(
145
- (raw.lessons || [])
146
- .filter((l) => l && l.demoted === true && typeof l.themeKey === 'string')
147
- .map((l) => l.themeKey),
148
- );
149
- } catch { return new Set(); }
157
+ // Tolerant of both shapes, same reasoning as OPTIN_PATH: this file is expected to be hand-edited.
158
+ const list = Array.isArray(raw) ? raw : Array.isArray(raw?.demoted) ? raw.demoted : [];
159
+ return new Set(list.filter((x) => typeof x === 'string' && x.length));
160
+ } catch { return new Set(); } // absent or unparseable → nothing demoted. Never fail INTO hiding a theme.
161
+ }
162
+
163
+ /** The only writer of DEMOTED_THEMES_PATH. Demotes or restores one theme key. */
164
+ export function setThemeDemoted(key, demoted, file = DEMOTED_THEMES_PATH) {
165
+ const current = demotedThemeKeys(file);
166
+ if (demoted) current.add(key); else current.delete(key);
167
+ fs.mkdirSync(path.dirname(file), { recursive: true });
168
+ fs.writeFileSync(file, JSON.stringify({ demoted: [...current].sort() }, null, 2) + '\n');
169
+ return current;
150
170
  }
151
171
 
152
- export function analyze(lessons, { minProjects = MIN_PROJECTS, rejected = null } = {}) {
153
- // Injectable for tests; defaults to the real store so the CLI honours real demotions.
154
- const demoted = rejected instanceof Set ? rejected : demotedThemeKeys();
172
+ export function analyze(lessons, { minProjects = MIN_PROJECTS, rejected = null, demotedThemesFile } = {}) {
173
+ // `rejected` is a pre-built Set, injectable for pure clustering tests.
174
+ // `demotedThemesFile` redirects the REAL disk reader (`demotedThemeKeys()`) to a fixture path — for
175
+ // tests that must exercise the actual read/write round trip, not a Set built by hand. Neither
176
+ // option is used by the CLI, which always takes the real default file for a real process.
177
+ const demoted = rejected instanceof Set ? rejected : demotedThemeKeys(demotedThemesFile);
155
178
  const eligible = lessons.filter((l) => l.type === 'feedback');
156
179
  const themes = [];
157
180
  for (const t of THEMES) {
@@ -227,12 +250,50 @@ export function applyPromotion(result, { file, now }) {
227
250
  ? existing.replace(new RegExp(`${BEGIN}[\\s\\S]*?${END}`), block) // replace ONLY our fence
228
251
  : `${existing.trimEnd()}\n\n${block}\n`; // first run: append
229
252
 
230
- try { fs.writeFileSync(file, next); } catch (e) { return { ok: false, log: `write failed: ${e.message}; backup at ${backup}` }; }
253
+ // ATOMIC write — temp sibling then rename, never in place. The same discipline bin/install.mjs's
254
+ // offerClaudeMd() already applies to this exact file (ADR-063 lineage): an in-place writeFileSync
255
+ // leaves the real file truncated if the process dies mid-write (disk full, SIGKILL, power loss).
256
+ // A rename within the same directory is atomic on every OS this project ships to, so CLAUDE.md is
257
+ // always either fully the old content or fully the new content, never a torn hybrid.
258
+ const tmp = `${file}.ruvnet-tmp`;
259
+ try {
260
+ fs.writeFileSync(tmp, next);
261
+ fs.renameSync(tmp, file);
262
+ } catch (e) {
263
+ try { fs.unlinkSync(tmp); } catch { /* best effort — the real file is untouched either way */ }
264
+ return { ok: false, log: `write failed: ${e.message}; backup at ${backup}` };
265
+ }
266
+
267
+ // ROUND-TRIP PROOF, not an inferred success: a successful rename proves the bytes landed at
268
+ // `file`'s path, but not that they are the bytes this call intended — read the real path back
269
+ // and compare, the same discipline degradation-watch.mjs's proveMemoryDurable() applies to every
270
+ // other durable write in this repo.
271
+ let onDisk = '';
272
+ try { onDisk = fs.readFileSync(file, 'utf8'); } catch (e) {
273
+ return { ok: false, log: `wrote ${file.replace(HOME, '~')} but could not read it back to verify (${e.message}); backup at ${backup}` };
274
+ }
275
+ if (onDisk !== next) {
276
+ return { ok: false, log: `wrote ${file.replace(HOME, '~')} but the read-back did not match — refusing to report success; backup at ${backup}` };
277
+ }
278
+
231
279
  return { ok: true, backup, promoted: result.promotable.length, log: `promoted ${result.promotable.length} process(es) into ${file.replace(HOME, '~')}` };
232
280
  }
233
281
 
234
282
  // ── CLI ──────────────────────────────────────────────────────────────────────────────────────────
235
283
  const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWith('lesson-promote.mjs');
284
+ if (invokedDirectly && (has('--demote-theme') || has('--restore-theme'))) {
285
+ const demoting = has('--demote-theme');
286
+ const key = arg(demoting ? '--demote-theme' : '--restore-theme');
287
+ if (!THEMES.some((t) => t.key === key)) {
288
+ console.error(`\n ✗ unknown theme "${key}". Known keys: ${THEMES.map((t) => t.key).join(', ')}\n`);
289
+ process.exit(1);
290
+ }
291
+ setThemeDemoted(key, demoting);
292
+ console.log(`\n ${demoting ? '✓ demoted' : '✓ restored'} theme "${key}" — `
293
+ + `${demoting ? 'will never be re-proposed by a future scan' : 'eligible again on the next scan'} `
294
+ + `(${DEMOTED_THEMES_PATH.replace(HOME, '~')})\n`);
295
+ process.exit(0);
296
+ }
236
297
  if (invokedDirectly) {
237
298
  const result = analyze(collectLessons());
238
299
  if (has('--json')) { console.log(JSON.stringify(result, null, 2)); process.exit(0); }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Where a lesson applies: the ONE project-identity and applicability rule, shared by plugin/scripts/lesson-gate.mjs and
3
+ * the Console (scripts/onboarding-console.mjs). Re-exported from lesson-store.mjs. Pure apart from the git/fs reads
4
+ * projectNameFor makes, which are bounded.
5
+ */
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { resolveProjectStore } from './project-store-resolver.mjs';
9
+
10
+ /**
11
+ * THE project identity lessons are scoped by: the folder name of the CANONICAL project root (the primary checkout,
12
+ * the same root resolveProjectStore uses for the store), so a linked worktree `feature-x` is the project it belongs to,
13
+ * not its own name. Falls back to the nearest folder holding a `.git`, then to the folder itself, when the resolver
14
+ * refuses (the name is only a lesson-scope label; a refusal must not silence the gate).
15
+ */
16
+ export function projectNameFor(cwd = process.cwd()) {
17
+ try { return path.basename(resolveProjectStore({ projectDir: cwd, gitTimeoutMs: 500 }).projectRoot); } catch { /* fall through */ }
18
+ let d = cwd;
19
+ for (let i = 0; i < 12; i++) {
20
+ if (fs.existsSync(path.join(d, '.git'))) return path.basename(d);
21
+ const up = path.dirname(d);
22
+ if (up === d) break;
23
+ d = up;
24
+ }
25
+ return path.basename(cwd);
26
+ }
27
+
28
+ /**
29
+ * `a` matches `b` when `b` is a whole path/name SEGMENT suffix of `a`: the character before it must be a separator,
30
+ * never a mid-word letter ("Sentry" must not match inside "WhitSentry").
31
+ */
32
+ export function segmentSuffixMatch(a, b) {
33
+ if (a === b) return true;
34
+ if (a.length <= b.length || !a.endsWith(b)) return false;
35
+ return /[-_/]/.test(a[a.length - b.length - 1]);
36
+ }
37
+
38
+ /**
39
+ * THE applicability rule (lesson-gate and the Console share it): a lesson applies to a project when it is unscoped,
40
+ * scoped to that project (stored names may carry a `Code-` prefix the folder lacks), or proven in two or more
41
+ * projects (cross-project rediscovery is what makes it universal, ADR-029).
42
+ */
43
+ export function lessonApplies(lesson, projectName) {
44
+ const ps = Array.isArray(lesson?.projects) ? lesson.projects : [];
45
+ if (!ps.length || ps.length >= 2) return true;
46
+ const here = String(projectName);
47
+ return ps.some((p) => {
48
+ const n = String(p).replace(/^Code-/, '');
49
+ return n === here || String(p) === here || segmentSuffixMatch(here, n) || segmentSuffixMatch(n, here);
50
+ });
51
+ }
@@ -24,7 +24,7 @@ import fs from 'node:fs';
24
24
  import os from 'node:os';
25
25
  import path from 'node:path';
26
26
  import { SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
27
-
27
+ export { projectNameFor, segmentSuffixMatch, lessonApplies } from './lesson-scope.mjs';
28
28
  export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS, STATUS, isUntouchedOwnerSeedRow } from './lesson-provenance.mjs';
29
29
 
30
30
  /** Resolve fixture/plugin configuration without mutating the child process account HOME. */
@@ -36,6 +36,7 @@ import { fileURLToPath } from 'node:url';
36
36
  import { createHash } from 'node:crypto';
37
37
  import { parseHookEvent, toolName, field, readStdinBounded } from './hook-input.mjs';
38
38
  import { contains, projectDirectory } from './project-identity.mjs';
39
+ import { isDirectInvocation } from './entrypoint.mjs';
39
40
 
40
41
  // ── date, from the system clock, formatted in the repo's standard timezone ─────────────────────────
41
42
  // Same idiom as scripts/self-update.mjs's README badge stamp: Intl.DateTimeFormat is a Node builtin
@@ -312,7 +313,7 @@ async function main() {
312
313
  }
313
314
 
314
315
  function isMain() {
315
- try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
316
+ try { return isDirectInvocation(import.meta.url); }
316
317
  catch { return false; }
317
318
  }
318
319