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
@@ -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); }
@@ -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
 
@@ -1,10 +1,75 @@
1
1
  /** Bounded, mechanical Codex JSONL normalization. No model inference or user-text persistence. */
2
2
  const LIMIT = 2 * 1024 * 1024;
3
3
  const text = content => typeof content === 'string' ? content : Array.isArray(content)
4
- ? content.filter(part => ['text', 'input_text', 'output_text'].includes(part?.type)).map(part => part.text || '').join('\n') : '';
5
- const message = (role, content) => ({ message: { role, content } });
4
+ ? content.filter(part => ['text', 'Text', 'input_text', 'output_text'].includes(part?.type)).map(part => part.text || '').join('\n') : '';
5
+ const message = (role, content) => ({ type: role, message: { role, content } });
6
6
  const parse = value => { try { return JSON.parse(value); } catch { return null; } };
7
7
 
8
+ const TOOL_ITEMS = new Set(['CommandExecution', 'FileChange', 'McpToolCall']);
9
+ const toolItem = record => record?.type === 'event_msg' && record.payload?.type === 'item_completed'
10
+ && TOOL_ITEMS.has(record.payload.item?.type) && typeof record.payload.item.id === 'string';
11
+ const startOf = record => Number.isFinite(record.payload.started_at_ms) ? record.payload.started_at_ms : NaN;
12
+ const endOf = record => Number.isFinite(record.payload.completed_at_ms) ? record.payload.completed_at_ms : NaN;
13
+
14
+ /**
15
+ * Native item records (#423) are written at completion, so concurrent items arrive in completion order.
16
+ * Each contiguous run is put back in start order; an item without timings, or whose interval overlaps
17
+ * another item, keeps its record but its result becomes `unknown` and can never count as a passing check.
18
+ */
19
+ function orderToolItems(records) {
20
+ const items = records.filter(toolItem);
21
+ const uncertain = new Set(items.filter((r, i) => !(startOf(r) <= endOf(r))
22
+ || items.some((o, j) => j !== i && startOf(o) < endOf(r) && startOf(r) < endOf(o))));
23
+ const out = [];
24
+ for (let i = 0; i < records.length;) {
25
+ if (!toolItem(records[i])) { out.push(records[i++]); continue; }
26
+ const run = [];
27
+ while (i < records.length && toolItem(records[i])) run.push(records[i++]);
28
+ out.push(...run.sort((a, b) => (startOf(a) || 0) - (startOf(b) || 0)));
29
+ }
30
+ return { ordered: out, uncertain, itemized: items.length > 0 };
31
+ }
32
+
33
+ function toolItemLines(record, uncertain) {
34
+ const item = record.payload.item;
35
+ const status = uncertain ? 'unknown' : item.status;
36
+ if (item.type === 'FileChange') {
37
+ return [message('assistant', [{ type: 'tool_use', id: item.id, name: 'apply_patch', input: { input: '' } }])];
38
+ }
39
+ if (item.type === 'McpToolCall') {
40
+ // A completed transport is not a successful tool: CallToolResult.isError:true (a failed run_tests) is a failure,
41
+ // and the field is kept on the result so no reader can mistake it for success (cross-vendor review D, 4.7.21).
42
+ const toolFailed = item.result?.isError === true;
43
+ return [message('assistant', [{ type: 'tool_use', id: item.id, name: `mcp__${item.server}__${item.tool}`, input: {} }]),
44
+ message('user', [{ type: 'tool_result', tool_use_id: item.id, status, ...(toolFailed ? { isError: true } : {}),
45
+ is_error: item.status !== 'completed' || Boolean(item.error) || toolFailed,
46
+ content: item.result === undefined ? '' : JSON.stringify(item.result) }])];
47
+ }
48
+ const argv = Array.isArray(item.command) ? item.command : [item.command];
49
+ // `[shell, -lc, script]` runs `script`; any other argv is the command line itself.
50
+ const command = argv.length === 3 && /^-l?c$/.test(String(argv[1])) ? String(argv[2]) : argv.map(String).join(' ');
51
+ return [message('assistant', [{ type: 'tool_use', id: item.id, name: 'Bash', input: { command } }]),
52
+ message('user', [{ type: 'tool_result', tool_use_id: item.id, status, ...(Number.isSafeInteger(item.exit_code) ? { exit_code: item.exit_code } : {}),
53
+ content: typeof item.aggregated_output === 'string' ? item.aggregated_output : '' }])];
54
+ }
55
+
56
+ /**
57
+ * Code-mode `exec` calls whose call record and output record bracket at least one native tool item: Codex records
58
+ * the items an exec script runs between that exec's call and its output (rollouts on the owner's Mac, codex-cli
59
+ * 0.164). An item carries no call id, so the bracket is the only pairing there is; anything else is opaque.
60
+ */
61
+ function pairedExecCalls(ordered) {
62
+ const paired = new Set();
63
+ ordered.forEach((record, i) => {
64
+ const p = record?.payload;
65
+ if (record?.type !== 'response_item' || p?.type !== 'custom_tool_call' || p.name !== 'exec' || typeof p.call_id !== 'string') return;
66
+ const end = ordered.findIndex((r, j) => j > i && r?.type === 'response_item' && r.payload?.type === 'custom_tool_call_output'
67
+ && r.payload.call_id === p.call_id);
68
+ if (end > i && ordered.slice(i + 1, end).some(toolItem)) paired.add(p.call_id);
69
+ });
70
+ return paired;
71
+ }
72
+
8
73
  /** Keep call IDs intact; torn/unknown records cannot manufacture successful tool evidence. */
9
74
  export function nativeTurnLines(lines, host) {
10
75
  if (host !== 'codex') return lines;
@@ -15,19 +80,46 @@ export function nativeTurnLines(lines, host) {
15
80
  const record = parse(lines[i]); if (record) records.push(record);
16
81
  }
17
82
  records.reverse();
83
+ const { ordered, uncertain, itemized } = orderToolItems(records);
84
+ const paired = pairedExecCalls(ordered);
18
85
  const out = []; const sessions = new Map(); const calls = new Map();
19
- for (const record of records) {
86
+ // Codex writes the same answer up to three times (response_item, item_completed AgentMessage,
87
+ // task_complete.last_agent_message); any one of them may be the only copy that survived.
88
+ let said = new Set();
89
+ const say = (role, value) => {
90
+ if (role === 'user') said = new Set();
91
+ else if (said.has(value.trim())) return;
92
+ else said.add(value.trim());
93
+ out.push(message(role, value));
94
+ };
95
+ for (const record of ordered) {
20
96
  const p = record.payload;
97
+ if (toolItem(record)) {
98
+ if (!calls.has(p.item.id)) out.push(...toolItemLines(record, uncertain.has(record)));
99
+ continue;
100
+ }
21
101
  if (record.type === 'event_msg' && p?.type === 'user_message') {
22
- out.push(message('user', p.message || '')); continue;
102
+ say('user', p.message || ''); continue;
23
103
  }
24
104
  if (record.type === 'event_msg' && p?.type === 'agent_message' && typeof p.message === 'string') {
25
- out.push(message('assistant', p.message)); continue;
105
+ say('assistant', p.message); continue;
106
+ }
107
+ // Native turn end (rollout and `codex exec`): the closing answer, recorded once per turn.
108
+ if (record.type === 'event_msg' && p?.type === 'task_complete' && typeof p.last_agent_message === 'string') {
109
+ say('assistant', p.last_agent_message); continue;
110
+ }
111
+ if (record.type === 'event_msg' && p?.type === 'item_completed' && p.item?.type === 'AgentMessage') {
112
+ say('assistant', text(p.item.content)); continue;
26
113
  }
27
114
  if (record.type !== 'response_item' || !p) continue;
28
115
  if (p.type === 'message' && ['user', 'assistant'].includes(p.role)) {
29
- out.push(message(p.role, text(p.content))); continue;
116
+ say(p.role, text(p.content)); continue;
30
117
  }
118
+ // A code-mode `exec` script's actions are the native items mapped above, but only an exec PROVABLY paired with
119
+ // them (review r2, 4.7.21) is dropped; an opaque one (no item inside its call/output bracket, or no output yet)
120
+ // stays a tool call named `exec`, which the audit counts as a possible change (fail toward asking).
121
+ if (itemized && paired.has(p.call_id) && ['custom_tool_call', 'custom_tool_call_output'].includes(p.type)) continue;
122
+ if (itemized && p.type === 'custom_tool_call_output' && !calls.has(p.call_id)) continue;
31
123
  if (['function_call', 'custom_tool_call'].includes(p.type) && typeof p.call_id === 'string') {
32
124
  const args = typeof p.arguments === 'string' ? parse(p.arguments) : p.arguments;
33
125
  let name = String(p.name || '').split('.').at(-1); let input = args || {};
@@ -6,7 +6,7 @@ import { spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { automaticPath } from './automatic-update.mjs';
8
8
  import { sharedLockStatus } from './developer-update-lock.mjs';
9
- import { EXECUTION_MODULES } from './developer-update-policy.mjs';
9
+ import { EXECUTION_MODULES, OPTIONAL_EXECUTION_MODULES } from './developer-update-policy.mjs';
10
10
 
11
11
  export const NIGHTLY_LABEL = 'com.ruvnet.brain-update';
12
12
  export const NIGHTLY_HOUR = 3;
@@ -145,8 +145,13 @@ function inspectRefreshOwner(owner) {
145
145
  const HISTORICAL_UPDATE_MODULES = ['automatic-update.mjs', 'user-settings.mjs', 'ruvnet-gate1-pattern.mjs'];
146
146
  const BRIDGED_UPDATE_MODULES = [...HISTORICAL_UPDATE_MODULES, 'developer-update-owner.mjs'];
147
147
  const LEGACY_UPDATE_MODULES = [...BRIDGED_UPDATE_MODULES, 'developer-update-policy.mjs'];
148
- const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs')];
148
+ const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => !OPTIONAL_EXECUTION_MODULES.includes(name))];
149
+ // Registered by 4.7.20 and earlier (before developer-update.mjs imported ./entrypoint.mjs); still a valid closure.
150
+ const PRE_ENTRYPOINT_DEVELOPER_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name !== 'entrypoint.mjs')])];
149
151
  const DEVELOPER_UPDATE_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES])];
152
+ /** A staged coordinator that imports an optional member must have it staged beside it (review B, 4.7.21). */
153
+ const missingOptional = (modules) => OPTIONAL_EXECUTION_MODULES.find(name => modules['developer-update.mjs'] && !modules[name]
154
+ && fs.readFileSync(modules['developer-update.mjs'].path, 'utf8').includes(`./${name}`));
150
155
  /** The shipped module set and the content digest it is registered under (`update-modules-<digest>`); reads only. */
151
156
  function readModuleSet(sourceDir, developerSuite) {
152
157
  const files = developerSuite ? DEVELOPER_UPDATE_MODULES : LEGACY_UPDATE_MODULES;
@@ -170,8 +175,8 @@ function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.
170
175
 
171
176
  function verifyUpdateModules(modules) {
172
177
  if (!modules) return; // existing schema-2 registrations remain inspectable
173
- if (![HISTORICAL_UPDATE_MODULES, BRIDGED_UPDATE_MODULES, LEGACY_UPDATE_MODULES, PREVIOUS_DEVELOPER_MODULES, DEVELOPER_UPDATE_MODULES].some(names => [...names].sort().join(',') === Object.keys(modules).sort().join(','))) throw new Error('invalid update module closure');
174
- if (modules['developer-update.mjs'] && !modules['plugin-artifact-proof.mjs'] && fs.readFileSync(modules['developer-update.mjs'].path, 'utf8').includes('./plugin-artifact-proof.mjs')) throw new Error('new coordinator artifact closure is incomplete');
178
+ if (![HISTORICAL_UPDATE_MODULES, BRIDGED_UPDATE_MODULES, LEGACY_UPDATE_MODULES, PREVIOUS_DEVELOPER_MODULES, PRE_ENTRYPOINT_DEVELOPER_MODULES, DEVELOPER_UPDATE_MODULES].some(names => [...names].sort().join(',') === Object.keys(modules).sort().join(','))) throw new Error('invalid update module closure');
179
+ if (missingOptional(modules)) throw new Error(missingOptional(modules) === 'plugin-artifact-proof.mjs' ? 'new coordinator artifact closure is incomplete' : `coordinator closure is missing ${missingOptional(modules)}`);
175
180
  for (const [name, item] of Object.entries(modules)) {
176
181
  if (!path.isAbsolute(item.path) || path.basename(item.path) !== name
177
182
  || path.dirname(item.path) !== path.dirname(modules['automatic-update.mjs'].path)
@@ -344,7 +349,7 @@ export function developerRunHealth({ brainHome, registration, now = Date.now(),
344
349
  const boundTo = modules => Boolean(modules) && validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
345
350
  && receipt.sourceSha256 === modules['developer-update.mjs']?.sha256
346
351
  && Object.entries(receipt.sourceSnapshot || {}).every(([name, digest]) => validDigest(digest) && digest === modules[name]?.sha256)
347
- && EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || modules[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === modules[name]?.sha256);
352
+ && EXECUTION_MODULES.filter(name => !OPTIONAL_EXECUTION_MODULES.includes(name) || modules[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === modules[name]?.sha256);
348
353
  // A receipt from the module set an update just replaced is still the night's proof, but only one that FINISHED before
349
354
  // the replacement: the next scheduled run must bind to the current set.
350
355
  // Judged by LAUNCH time: a night's own knowledge step can install a release and re-point the modules before the run
@@ -310,8 +310,8 @@ export function buildPackageCandidate({ prompt, pick, findingPrefix = 'recommend
310
310
  findingId: `${findingPrefix}${card.id}`,
311
311
  severity: 'normal',
312
312
  observationHash: stateHashOf([`package:${card.id}`]),
313
- copy: `[RuvNet Brain — rUv already ships this] If it genuinely fits this request, tell the user in ONE `
314
- + `sentence: "rUv ships ${card.id} — ${description} (source: ${card.source}). Say 'use ${name}' to proceed, or ignore this." `
313
+ copy: `[RuvNet Brain — rUv already ships this] If it genuinely fits this request, state it in ONE `
314
+ + `sentence: "rUv ships ${card.id} — ${description} (source: ${card.source}); 'use ${name}' adopts it." `
315
315
  + 'Install state unknown from here; confirm with search_ruvnet before building, say it once, then carry on.',
316
316
  capability: name,
317
317
  package: card.id,
@@ -4,6 +4,25 @@ import path from 'node:path';
4
4
  import os from 'node:os';
5
5
  import { execFileSync } from 'node:child_process';
6
6
 
7
+ /**
8
+ * ruflo encryption at rest (ruflo ADR-096, CLAUDE_FLOW_ENCRYPT_AT_REST=1) rewrites `.swarm/memory.db` as an
9
+ * AES-GCM image that starts with the magic bytes `RFE1`. ruflo opens that image only through its sql.js
10
+ * path and keeps native better-sqlite3 on a separate plaintext file (ruflo #2786), so a write that
11
+ * demands the native writer (`--require-native`, `--append-only`) is refused on every attempt (#439).
12
+ * Reads (`memory retrieve|search|list`) are unaffected. Four bytes are read; a missing or short file is
13
+ * not encrypted.
14
+ */
15
+ export const ENCRYPTED_STORE_MAGIC = 'RFE1';
16
+ export const ENCRYPTED_STORE_REASON = 'store is encrypted at rest (ruflo RFE1); the native append-only writer cannot open it';
17
+ export function storeEncryptedAtRest(file) {
18
+ let fd;
19
+ try {
20
+ fd = fs.openSync(file, 'r');
21
+ const head = Buffer.alloc(4);
22
+ return fs.readSync(fd, head, 0, 4, 0) === 4 && head.toString('latin1') === ENCRYPTED_STORE_MAGIC;
23
+ } catch { return false; } finally { if (fd !== undefined) try { fs.closeSync(fd); } catch { /* already closed */ } }
24
+ }
25
+
7
26
  function sha256(value) {
8
27
  return crypto.createHash('sha256').update(value).digest('hex');
9
28
  }
@@ -13,6 +13,7 @@ import {
13
13
  readStdinBounded,
14
14
  toolName,
15
15
  } from './hook-input.mjs';
16
+ import { isDirectInvocation } from './entrypoint.mjs';
16
17
 
17
18
  const SUCCESS = new Set(['completed', 'success', 'succeeded']);
18
19
  const FAILURE = new Set(['failed', 'error', 'cancelled', 'canceled', 'timed_out', 'timeout']);
@@ -84,6 +85,6 @@ async function main() {
84
85
  appendObservation(observationFrom(event, new Date().toISOString(), decision));
85
86
  }
86
87
 
87
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
88
+ if (isDirectInvocation(import.meta.url)) {
88
89
  try { await main(); } catch { /* advisory observer: fail open */ }
89
90
  }
@@ -25,14 +25,28 @@ import { redactText } from './continuity-events.mjs';
25
25
  /** hooks.json gives this boundary 10 s; the shim's watchdog fires at 8.5 s. Plan inside it. */
26
26
  export const CAPTURE_BUDGET_MS = 8_000;
27
27
 
28
+ /**
29
+ * Claude Code's SessionEnd budget: 1.5 s shared by every SessionEnd hook, and "Timeouts set on
30
+ * plugin-provided hooks don't raise the budget" (code.claude.com/docs/en/hooks.md, SessionEnd), so
31
+ * hooks.json's 10 s does not apply there. Only CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS overrides it.
32
+ */
33
+ export const CLAUDE_SESSION_END_BUDGET_MS = 1_500;
34
+
28
35
  /**
29
36
  * The budget this invocation really has. The Codex wrapper hands its own kill deadline down as
30
37
  * RUVNET_CODEX_BUDGET_MS (2200 ms at SessionEnd, which Codex caps at 3 s); 300 ms is left for the
31
- * adapter → shim → body spawns.
38
+ * adapter → shim → body spawns. At SessionEnd under Claude Code the same 300 ms comes off Claude
39
+ * Code's own budget (issue #420 item 2: planning for 8 s ran the checkpoint inline and /exit printed
40
+ * "Hook cancelled"); with that budget the checkpoint is handed to the detached child below.
32
41
  */
33
- export function effectiveBudgetMs(env = process.env) {
42
+ export function effectiveBudgetMs(env = process.env, event = null) {
34
43
  const handed = Number(env.RUVNET_CODEX_BUDGET_MS);
35
- return Number.isFinite(handed) && handed > 0 ? Math.max(0, Math.min(CAPTURE_BUDGET_MS, handed - 300)) : CAPTURE_BUDGET_MS;
44
+ if (Number.isFinite(handed) && handed > 0) return Math.max(0, Math.min(CAPTURE_BUDGET_MS, handed - 300));
45
+ const claude = !env.RUVNET_HOOK_HOST || env.RUVNET_HOOK_HOST === 'claude';
46
+ if (event !== 'SessionEnd' || !claude) return CAPTURE_BUDGET_MS;
47
+ const configured = Number(env.CLAUDE_CODE_SESSIONEND_HOOKS_TIMEOUT_MS);
48
+ const budget = Number.isFinite(configured) && configured > 0 ? configured : CLAUDE_SESSION_END_BUDGET_MS;
49
+ return Math.max(0, Math.min(CAPTURE_BUDGET_MS, budget - 300));
36
50
  }
37
51
 
38
52
  /**
@@ -65,7 +79,7 @@ export function detachCheckpoint({ projectDir, event, payload, env, brainHome })
65
79
  return { detached: true, pid: child.pid };
66
80
  }
67
81
 
68
- export async function runLifecycleBoundary(event, payload, { env = process.env, deadlineAt = Date.now() + effectiveBudgetMs(env) } = {}) {
82
+ export async function runLifecycleBoundary(event, payload, { env = process.env, deadlineAt = Date.now() + effectiveBudgetMs(env, event) } = {}) {
69
83
  const projectDir = typeof payload?.cwd === 'string' && path.isAbsolute(payload.cwd) ? payload.cwd : projectDirectory();
70
84
  const host = env.RUVNET_HOOK_HOST || 'claude';
71
85
  if (event === 'PostToolUse') {
@@ -82,6 +96,12 @@ export async function runLifecycleBoundary(event, payload, { env = process.env,
82
96
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { turn, checkpoint: { stored: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
83
97
  const brainHome = env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
84
98
  if (deadlineAt - Date.now() < CHECKPOINT_NEED_MS) {
99
+ // A persisted opt-out is answered here, so no detached child is started for a project that refused
100
+ // capture (the child re-checks consent anyway; a deadline throw here just leaves that check to it).
101
+ try {
102
+ const consent = resolveTurnDb({ projectDir, brainHome, deadlineAt });
103
+ if (consent.skipped) return { turn, checkpoint: { stored: false, skipped: consent.skipped } };
104
+ } catch { /* out of time: the detached child decides */ }
85
105
  try { return { turn, checkpoint: { stored: false, ...detachCheckpoint({ projectDir, event, payload, env, brainHome }) } }; } catch (error) {
86
106
  recordHookFailure({ hook: 'checkpoint', event, error: `skipped: hook budget too small and detach failed (${error.code || 'spawn'})` }, { brainHome });
87
107
  return { turn, checkpoint: { stored: false, skipped: 'hook budget too small; detach failed' } };
@@ -36,6 +36,7 @@ import { surfaceSignals } from './session-start-signals.mjs';
36
36
  import { failureLine, FAILURE_LINE_PREFIX } from './hook-failures.mjs';
37
37
  import { brainState, health, knowledgeCurrency, mcpReadiness, KNOWLEDGE_LINE_PREFIX } from './session-start-health.mjs';
38
38
  import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './session-start-update-plane.mjs';
39
+ import { CODEX_OVERNIGHT_PREFIX } from './codex-overnight-notice.mjs';
39
40
  import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
40
41
  import { describeLifecycleHooks, readHookContracts } from './session-start-hook-description.mjs';
41
42
  import { createStageTracer } from './session-start-trace.mjs';
@@ -43,6 +44,7 @@ import { sessionStartDeadlineAt, STAGE_BUDGETS_MS } from './session-start-budget
43
44
  import { loadSettings } from './user-settings.mjs';
44
45
  import { sessionStartProofRecorder } from './session-start-proof.mjs';
45
46
  import { ALIAS_NOTICE, probeCodexConsoleAlias, readSessionSource } from './codex-console-alias.mjs';
47
+ import { isDirectInvocation } from './entrypoint.mjs';
46
48
 
47
49
  // Re-exported for callers/tests that import the entitlement check directly from this file's own
48
50
  // long-standing public surface (tests/unit/session-start-core-parity.test.mjs).
@@ -116,7 +118,9 @@ export async function runSessionStart({
116
118
  || s.startsWith(FOOTPRINT_LINE_PREFIX)
117
119
  || s.startsWith('[RuvNet Brain — OPEN ISSUES')
118
120
  || s.startsWith('[RuvNet Brain — NTFY:')
121
+ || s.startsWith('[RuvNet Brain — JOBS:')
119
122
  || s.startsWith('[RuvNet Brain — TURN CAPTURE]')
123
+ || s.startsWith(CODEX_OVERNIGHT_PREFIX)
120
124
  || /\bopen issue\(s\)/i.test(s)
121
125
  || /^\[RuvNet Brain — external signal/i.test(s)
122
126
  || (s.startsWith('Workflow ') && !/\b(Say it plainly|offer to look|ask only|run:|invoke)\b/i.test(s))
@@ -452,21 +456,7 @@ export async function runSessionStart({
452
456
  return { ok: true, outputBytes: Buffer.byteLength(output, 'utf8'), platform };
453
457
  }
454
458
 
455
- // Entry-point guard. Compares REALPATHS on both sides: path.resolve() normalizes a path but does
456
- // NOT follow symlinks, while import.meta.url IS symlink-resolved by Node. Through a symlink (npm bin
457
- // shims, wrapper scripts, and every os.tmpdir() path on macOS) the two sides disagree, so main()
458
- // never runs -- and because nothing throws, the process exits 0. A silent exit 0 is indistinguishable
459
- // from "ran, found nothing". Reproduced live 2026-07-27; pinned by tests/unit/entrypoint-symlink.test.mjs.
460
- function isDirectInvocation() {
461
- try {
462
- if (!process.argv[1]) return false;
463
- return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url));
464
- } catch {
465
- return false;
466
- }
467
- }
468
-
469
- const direct = isDirectInvocation();
459
+ const direct = isDirectInvocation(import.meta.url);
470
460
  if (direct) {
471
461
  const sessionSource = await readSessionSource();
472
462
  runSessionStart({ sessionSource, startedAt: performance.timeOrigin }).catch((error) => {