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
@@ -14,7 +14,7 @@ import { json, exists, mtimeMs, read } from './session-start-fsutil.mjs';
14
14
  import { unmountedNotice } from './brain-location.mjs';
15
15
  import { assessMoveLeftovers } from './footprint-io.mjs';
16
16
  import {
17
- describeFailedRefreshRun, readNightlyRegistration, refreshHistory, updateOwnedByAgenticKit,
17
+ describeFailedRefreshRun, developerRunHealth, readNightlyRegistration, refreshHistory, updateOwnedByAgenticKit,
18
18
  } from './nightly-scheduler.mjs';
19
19
 
20
20
  export const brainState = (env, home) => {
@@ -93,7 +93,7 @@ export const autoUpdatePaths = (brainHome) => ({
93
93
  checkFile: path.join(brainHome, 'corpus-check.json'),
94
94
  checkResultFile: path.join(brainHome, '.last-kb-check-result.json'),
95
95
  });
96
- export const AUTO_UPDATE_LOCK_STALE_MS = 35 * 60_000; // detach TTL (30 min) + slack
96
+ export const AUTO_UPDATE_LOCK_STALE_MS = 160 * 60_000; // detach TTL (session-start-update-plane ttlSec, 155 min) + slack
97
97
 
98
98
  /** The inputs every currency decision reads — one reader, shared by the line and the auto-update. */
99
99
  export const knowledgeFacts = ({ env = process.env, home, now = Date.now() } = {}) => {
@@ -108,7 +108,9 @@ export const knowledgeFacts = ({ env = process.env, home, now = Date.now() } = {
108
108
  const auto = autoUpdatePaths(brainHome);
109
109
  const provenWithin = (h) => Boolean((history.lastSuccess && hours(history.lastSuccess.at) <= h)
110
110
  || (check?.currencyVerdict === 'CURRENT' && Number.isFinite(checkMs) && hours(checkMs) <= h));
111
- return { brainHome, kbDir, source, builtMs, history, hours, provenWithin, auto,
111
+ // brain-confirmation.mjs SIGNATURE_RECORD (not imported: that module imports this one).
112
+ const installedAtMs = Date.parse(json(path.join(brainHome, 'knowledge-signature.json'))?.verifiedAt || '');
113
+ return { brainHome, kbDir, source, builtMs, history, hours, provenWithin, auto, installedAtMs,
112
114
  attempt: json(auto.attemptFile), corpusCheck: json(auto.checkFile),
113
115
  lockMs: Date.parse(json(auto.lockFile)?.at || '') || mtimeMs(auto.lockFile) };
114
116
  };
@@ -146,15 +148,87 @@ export const autoUpdateOptOut = ({ env = process.env, home, facts }) => {
146
148
  * installed tree carries no generation stamp) and that tag is not what SOURCE.json now names. A check
147
149
  * recorded before the update landed therefore stops reading as pending the moment B is installed.
148
150
  */
151
+ const SEMVER_TAG = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/;
152
+ /** Semver order of two release tags (>0 a newer, <0 older, 0 same); null when either is not semver. */
153
+ export const compareReleaseTags = (a, b) => {
154
+ const A = SEMVER_TAG.exec(String(a ?? '')), B = SEMVER_TAG.exec(String(b ?? ''));
155
+ if (!A || !B) return null;
156
+ for (let i = 1; i <= 3; i++) if (Number(A[i]) !== Number(B[i])) return Number(A[i]) > Number(B[i]) ? 1 : -1;
157
+ if ((A[4] || '') === (B[4] || '')) return 0;
158
+ if (!A[4]) return 1;
159
+ if (!B[4]) return -1;
160
+ // Semver §11.4: dot-separated identifiers left to right; numeric ones numerically (alpha.10 > alpha.9),
161
+ // numeric below alphanumeric, alphanumeric in ASCII order; a longer set wins when all before it are equal.
162
+ const X = A[4].split('.'), Y = B[4].split('.');
163
+ for (let i = 0; i < Math.max(X.length, Y.length); i++) {
164
+ if (X[i] === undefined) return -1;
165
+ if (Y[i] === undefined) return 1;
166
+ const xn = /^\d+$/.test(X[i]), yn = /^\d+$/.test(Y[i]);
167
+ if (xn && yn) { if (Number(X[i]) !== Number(Y[i])) return Number(X[i]) > Number(Y[i]) ? 1 : -1; continue; }
168
+ if (xn !== yn) return xn ? -1 : 1;
169
+ if (X[i] !== Y[i]) return X[i] > Y[i] ? 1 : -1;
170
+ }
171
+ return 0;
172
+ };
173
+
149
174
  export const newerCorpusPending = (facts) => {
150
175
  const c = facts.corpusCheck;
151
176
  if (!c?.candidateTag || !['UPDATE_AVAILABLE', 'UNKNOWN'].includes(c.verdict)) return null;
152
177
  const installed = [facts.source?.corpusReleaseTag, facts.source?.releaseTag].filter(Boolean);
153
178
  if (installed.includes(c.candidateTag)) return null;
179
+ // #401: a different tag is not a newer one. When both are semver release tags, order decides: a
180
+ // retained check naming v4.5.7 must never call an installed v4.5.9 out of date, and a v4.5.10
181
+ // candidate still reads as pending however recently something was installed.
182
+ const order = compareReleaseTags(c.candidateTag, facts.source?.releaseTag);
183
+ if (order !== null) { if (order <= 0) return null; }
184
+ else {
185
+ // Opaque (non-orderable) identities: a check recorded before the installed bytes were
186
+ // signature-verified is stale evidence the install superseded, not proof of a newer corpus.
187
+ const checkedMs = Date.parse(c.checkedAt || c.launchedAt || '');
188
+ if (Number.isFinite(facts.installedAtMs) && Number.isFinite(checkedMs) && checkedMs < facts.installedAtMs) return null;
189
+ }
154
190
  return { tag: c.candidateTag, installed: installed[0] || null, checkedAt: c.checkedAt || c.launchedAt || null, outcome: c.outcome };
155
191
  };
156
192
  const shortTag = (tag) => (tag && tag.length > 28 ? `${tag.slice(0, 26)}…` : tag || 'unknown');
157
193
 
194
+ /**
195
+ * Issue #391 / closure gap G-015: a FAILED run whose `update` phase PASSED left the knowledge current
196
+ * (the ledger stops at the first required failure, so every corpus phase before it passed too). When
197
+ * host-convergence is the ONLY failed phase, the host plugin sync is what did not finish: a host
198
+ * problem, not a knowledge failure, and it is reported as one. Any other failed phase (cleanup-pending,
199
+ * retention recovery-required) keeps its own reason through describeFailedRefreshRun.
200
+ * Returns { state, why } from the host-convergence evidence, or null for any other receipt.
201
+ */
202
+ export const hostSyncOnlyFailure = (receipt) => {
203
+ if (receipt?.status !== 'FAILED' || !Array.isArray(receipt.phases)) return null;
204
+ if (!receipt.phases.some((entry) => entry?.phase === 'update' && entry.status === 'PASS')) return null;
205
+ const failed = receipt.phases.filter((entry) => entry?.status === 'FAIL');
206
+ if (failed.length !== 1 || failed[0].phase !== 'host-convergence') return null;
207
+ const host = failed[0];
208
+ const state = host?.evidence?.state || null;
209
+ const why = String(host?.evidence?.error || state || '').split('\n')[0].trim().slice(0, 200);
210
+ return { state, why: why || 'no reason recorded (run npx ruvnet-brain --doctor)' };
211
+ };
212
+
213
+ /**
214
+ * A coordinated (developer-suite) nightly reports in nightly-suite-update.json, never in refresh-runs, so a failing or stale
215
+ * suite stayed silent while the knowledge was current (AuditC #26). One line then; '' when it is healthy, never ran, or absent.
216
+ */
217
+ const coordinatedSuiteLine = ({ brainHome, now, builtMs, age }) => {
218
+ let health;
219
+ try {
220
+ const registration = readNightlyRegistration({ brainHome });
221
+ if (!registration.ok || registration.record.mode !== 'developer-suite') return '';
222
+ health = developerRunHealth({ brainHome, registration: registration.record, now });
223
+ } catch { return ''; }
224
+ if (!['failed', 'stale'].includes(health.state)) return '';
225
+ const what = health.state === 'stale' ? `has not completed in ${Math.round(health.ageHours)}h`
226
+ : `FAILED${Number.isFinite(health.ageHours) ? ` ${age(now - health.ageHours * 3_600_000)}` : ''}: ${String(health.evidence || 'no reason recorded').split('\n')[0].slice(0, 200)}`;
227
+ return `${KNOWLEDGE_LINE_PREFIX}CURRENT, NIGHTLY SUITE ${health.state === 'stale' ? 'STALE' : 'FAILING'}] knowledge base built `
228
+ + `${Number.isFinite(builtMs) ? age(builtMs) : 'at an UNKNOWN time'} and current; the coordinated nightly update ${what}. `
229
+ + `Details: ${path.join(brainHome, 'scheduler', 'last-suite-attempt.json')} (verify: npx ruvnet-brain --doctor).`;
230
+ };
231
+
158
232
  export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), windowHours = 48 } = {}) => {
159
233
  const facts = knowledgeFacts({ env, home, now });
160
234
  const { brainHome, builtMs, history, hours, attempt } = facts;
@@ -169,7 +243,8 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
169
243
  const autoAbandoned = attempt?.outcome === 'launched' && !autoRunning;
170
244
  const autoFailed = Number.isFinite(launchedMs) && (attempt.outcome === 'failed' || autoAbandoned)
171
245
  && !(history.latest && history.latest.at >= launchedMs);
172
- const failing = latest?.status === 'FAILED' || autoFailed;
246
+ const hostOnly = hostSyncOnlyFailure(latest);
247
+ const failing = (latest?.status === 'FAILED' && !hostOnly) || autoFailed;
173
248
  const ageKnown = Number.isFinite(builtMs);
174
249
  const pending = newerCorpusPending(facts);
175
250
  if (!failing && pending && (proven || (ageKnown && hours(builtMs) <= windowHours))) {
@@ -181,8 +256,18 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
181
256
  return `${KNOWLEDGE_LINE_PREFIX}UPDATE PENDING] a newer corpus ${shortTag(pending.tag)} is published `
182
257
  + `(this machine has ${shortTag(pending.installed)}, built ${ageKnown ? age(builtMs) : 'at an UNKNOWN time'}); ${what}.`;
183
258
  }
184
- if (!failing && proven) return '';
185
- if (!failing && ageKnown && hours(builtMs) <= windowHours) return '';
259
+ const fresh = proven || (ageKnown && hours(builtMs) <= windowHours);
260
+ if (!failing && hostOnly && fresh) {
261
+ const restart = hostOnly.state === 'host-restart-required';
262
+ return `${KNOWLEDGE_LINE_PREFIX}CURRENT, HOST ${restart ? 'RESTART' : 'SYNC'} PENDING] `
263
+ + `knowledge base built ${ageKnown ? `${day(builtMs)} (${age(builtMs)})` : 'at an UNKNOWN time'}; `
264
+ + `the last refresh (${latest.action || 'unknown'}) ${age(history.latest.at)} passed its knowledge update phase, `
265
+ + `only the host plugin sync after it did not finish: ${hostOnly.why}. `
266
+ // A restart writes no refresh receipt, so this line stays until the next refresh records the sync.
267
+ + (restart ? 'Fix: restart Claude Code or Codex (`claude --continue` keeps a Claude conversation), then record it: npx ruvnet-brain@latest --update.'
268
+ : 'Fix: npx ruvnet-brain@latest --update (verify: npx ruvnet-brain --doctor).');
269
+ }
270
+ if (!failing && fresh) return coordinatedSuiteLine({ brainHome, now, builtMs, age });
186
271
  const kit = agenticKitUpdates({ home, facts });
187
272
  const agentKit = kit === 'delivering';
188
273
  // An agentic-kit machine must never be told to also --enable-nightly (one owner per machine).
@@ -190,17 +275,19 @@ export const knowledgeCurrency = ({ env = process.env, home, now = Date.now(), w
190
275
  const parts = [ageKnown ? `knowledge base built ${day(builtMs)} (${age(builtMs)})`
191
276
  : 'knowledge base age UNKNOWN (SOURCE.json missing or unreadable)'];
192
277
  if (autoFailed) {
193
- const why = autoAbandoned ? 'it never recorded an outcome (killed at its 30-minute limit, or the machine slept)'
278
+ const why = autoAbandoned ? 'it never recorded an outcome (killed at its time limit, or the machine slept)'
194
279
  : `exit ${attempt.code ?? 'unknown'}: ${attempt.reason || 'no reason recorded'}`;
195
280
  parts.push(`automatic update launched ${age(launchedMs)} FAILED — ${why}`);
281
+ } else if (hostOnly) {
282
+ parts.push(`last refresh (${latest.action || 'unknown'}) passed its knowledge update phase ${age(history.latest.at)} but its host sync did not finish: ${hostOnly.why}`);
196
283
  } else if (latest?.status === 'FAILED') {
197
284
  const why = describeFailedRefreshRun(latest) || 'failed';
198
285
  parts.push(`last refresh (${latest.action || 'unknown'}) FAILED ${age(history.latest.at)}: ${why}`);
199
286
  }
200
287
  if (autoRunning) parts.push(`an automatic update is running now (started ${age(facts.lockMs)})`);
201
- parts.push(history.receipts
202
- ? `${history.failuresSinceSuccess} failed run(s) since the last success (${history.lastSuccess ? day(history.lastSuccess.at) : 'none recorded'})`
203
- : 'no refresh has ever run on this machine');
288
+ parts.push(!history.receipts ? 'no refresh has ever run on this machine'
289
+ : history.lastSuccess ? `${history.failuresSinceSuccess} failed run(s) since the last success (${day(history.lastSuccess.at)})`
290
+ : `${history.failuresSinceSuccess} failed run(s) and no successful run on record`);
204
291
  if (kit === 'not-delivering') {
205
292
  parts.push(`agentic-kit claims updates (kit.json ruvnetBrain:true) but no update is proven in ${AGENTIC_KIT_PROOF_HOURS}h, so the Brain's own self-heal runs instead`);
206
293
  }
@@ -84,6 +84,7 @@ export function surfaceIssuePointer({ stateDir, emit, now, env, home, platform,
84
84
  export function surfaceNtfyAlerts({ stateDir, emit, env, home, platform, cwd, now = Date.now() }) {
85
85
  try {
86
86
  if (!maintainerIssueEntitlement(env, home, 'stuinfla/ruvnet-brain', platform) || !isMaintainerRepo(cwd)) return;
87
+ surfaceJobHealth({ stateDir, emit, now, cwd });
87
88
  // The reader writes a few-byte summary (scripts/ntfy-inbox.mjs writeSummary) so a large alert list can never
88
89
  // push this banner past its read limit; the detailed list is only a fallback for an older reader.
89
90
  let total = 0; let kinds = {};
@@ -117,3 +118,65 @@ export function surfaceNtfyAlerts({ stateDir, emit, env, home, platform, cwd, no
117
118
  emit(`[RuvNet Brain — NTFY: ${total} open alert(s) (${summaryText || 'none listed'})${over}${age}. Details: node scripts/ntfy-inbox.mjs --open]`);
118
119
  } catch { /* advisory only */ }
119
120
  }
121
+
122
+ const WATCHDOG_LABEL = 'com.ruvnet.nightly-watchdog';
123
+ const WATCHDOG_MAX_AGE_H = 30;
124
+ const MAX_RECEIPTS = 64;
125
+ const MAX_RECEIPT_BYTES = 64 * 1024;
126
+
127
+ /** The job labels config/scheduled-jobs.json registers, found by walking up from the session's project directory (the
128
+ * maintainer gate already requires this checkout to be the repo), or null when there is no readable registry. */
129
+ export function registeredJobLabels(cwd) {
130
+ if (!cwd) return null;
131
+ let dir = path.resolve(cwd);
132
+ for (let depth = 0; depth < 8; depth++) {
133
+ const file = path.join(dir, 'config', 'scheduled-jobs.json');
134
+ try {
135
+ if (fs.statSync(file).size <= MAX_STATUS_BYTES) {
136
+ const jobs = json(file)?.jobs;
137
+ if (Array.isArray(jobs)) return new Set(jobs.map((j) => j?.label).filter((l) => typeof l === 'string'));
138
+ }
139
+ } catch { /* not here: look one level up */ }
140
+ const up = path.dirname(dir);
141
+ if (up === dir) break;
142
+ dir = up;
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * WHO WATCHES THE WATCHDOG (4.7.21). nightly-watchdog.mjs cannot judge its own run, and a watchdog that stopped
149
+ * firing would be silent by construction. This reads the local heartbeat receipts (scripts/job-heartbeat.sh) —
150
+ * no network, bounded file count and size — and says ONE line when any receipt is failed/killed or the
151
+ * watchdog's own receipt is older than 30 h. Machines without heartbeats (every end user) get nothing.
152
+ * Only receipts of jobs the registry still lists count (review fix): a retired job's last failed receipt left in the
153
+ * heartbeat directory must not read as "failing" forever. Without a readable registry every receipt counts.
154
+ */
155
+ export function surfaceJobHealth({ stateDir, emit, now = Date.now(), cwd }) {
156
+ try {
157
+ const dir = path.join(stateDir, 'heartbeats');
158
+ let names;
159
+ try { names = fs.readdirSync(dir).filter((n) => n.endsWith('.json')).slice(0, MAX_RECEIPTS); } catch { return; }
160
+ if (!names.length) return;
161
+ const registered = registeredJobLabels(cwd);
162
+ const red = [];
163
+ let watchdogAt = null;
164
+ for (const name of names) {
165
+ const file = path.join(dir, name);
166
+ try { if (fs.statSync(file).size > MAX_RECEIPT_BYTES) continue; } catch { continue; }
167
+ const r = json(file);
168
+ if (!r || typeof r !== 'object') continue;
169
+ const label = name.slice(0, -'.json'.length);
170
+ if (registered && !registered.has(label)) continue;
171
+ if (r.state === 'failed' || r.state === 'killed') red.push(label);
172
+ if (label === WATCHDOG_LABEL) watchdogAt = Date.parse(r.ended_at || r.started_at || '');
173
+ }
174
+ const parts = [];
175
+ if (red.length) parts.push(`${red.length} scheduled job(s) failing (${red.slice(0, 3).join(', ')}${red.length > 3 ? ', …' : ''})`);
176
+ if (watchdogAt !== null) {
177
+ const ageH = Number.isFinite(watchdogAt) ? (now - watchdogAt) / 3_600_000 : Infinity;
178
+ if (ageH > WATCHDOG_MAX_AGE_H) parts.push(`the nightly watchdog last ran ${Number.isFinite(ageH) ? `${Math.floor(ageH)}h ago` : 'at an unreadable time'}`);
179
+ }
180
+ if (parts.length) emit(`[RuvNet Brain — JOBS: ${parts.join('; ')}. Details: node scripts/nightly-watchdog.mjs --no-push]`);
181
+ } catch { /* advisory only */ }
182
+ }
@@ -11,6 +11,8 @@ import { read, write, exists, json, firstVersion, dispatchDetached, mtimeMs } fr
11
11
  import { KNOWLEDGE_LINE_PREFIX, AUTO_UPDATE_LOCK_STALE_MS, autoUpdateOptOut, knowledgeFacts } from './session-start-health.mjs';
12
12
  import { inventoryFootprint } from './brain-footprint.mjs';
13
13
  import { confirm, footprintAlarm } from './brain-confirmation.mjs';
14
+ import { claimCodexOvernightNotice } from './codex-overnight-notice.mjs';
15
+ import { developerCoordinatorOwner } from './developer-update-owner.mjs';
14
16
 
15
17
  /**
16
18
  * KNOWLEDGE SELF-HEAL (owner invariant 2026-09-30: no brain may ever be more than 48h old) AND
@@ -31,7 +33,9 @@ import { confirm, footprintAlarm } from './brain-confirmation.mjs';
31
33
  * (plugin/mcp/server.mjs, announce:false so it never consumes the once-per-session line). The outcome is
32
34
  * recorded by the worker and spoken by the NEXT session's knowledge line.
33
35
  */
34
- export const AUTO_UPDATE_POLICY = Object.freeze({ staleHours: 24, retryHours: 6, offlineRetryMinutes: 30, ttlSec: 1800, checkMinutes: 60 });
36
+ // ttlSec sits above host-update.mjs's knowledge limit (150 min), which sits above the coordinator's installer limit
37
+ // (135 min = the 90-minute bundle download deadline plus the apply): each kill lands on a level that records an outcome.
38
+ export const AUTO_UPDATE_POLICY = Object.freeze({ staleHours: 24, retryHours: 6, offlineRetryMinutes: 30, ttlSec: 9300, checkMinutes: 60 });
35
39
  export const corpusCheckMinutes = (env = {}) => {
36
40
  const raw = env.RUVNET_CORPUS_CHECK_MINUTES;
37
41
  const n = Number(raw);
@@ -159,6 +163,8 @@ export const compareVersions = (a, b) => {
159
163
  };
160
164
 
161
165
  export const stableSpine = ({ env, hookDir, stateDir, home, pluginVersion, emit, now }) => {
166
+ const overnight = claimCodexOvernightNotice(stateDir); // the unattended nightly's deferred Codex reload, said once
167
+ if (overnight) emit(overnight);
162
168
  const activeFile = path.join(stateDir, 'active.json');
163
169
  const stamp = path.join(stateDir, '.last-update-check');
164
170
  let seedDispatched = false;
@@ -240,6 +246,14 @@ export const heartbeat = ({ env, hookDir, stateDir, home, running, seedDispatche
240
246
  for (let i = 0; i < 3 && active; i += 1) { if ((r[i] || 0) !== (a[i] || 0)) { newer = (r[i] || 0) > (a[i] || 0); break; } }
241
247
  if (active && !newer) return;
242
248
  } catch { /* unknown active generation: keep the existing announcement */ }
249
+ let coordinated = false;
250
+ try { coordinated = developerCoordinatorOwner({ home, brainHome: env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') }).active; } catch { /* legacy path */ }
251
+ if (coordinated) {
252
+ // Coordinated machines install new code only in the scheduled nightly; saying "installing now" would be false.
253
+ emit(`[RuvNet Brain — v${remoteVersion} is published; this machine's coordinated nightly update installs it at its next scheduled run]`);
254
+ emit('');
255
+ return;
256
+ }
243
257
  if (pref === 'yes' && exists(path.join(hookDir, 'host-update.mjs'))) {
244
258
  dispatchDetached(hookDir, 600, path.join(stateDir, '.last-auto-update.log'),
245
259
  process.execPath, [path.join(hookDir, 'host-update.mjs')], env);
@@ -60,6 +60,7 @@ import os from 'node:os';
60
60
  import { spawnSync } from 'node:child_process';
61
61
  import { fileURLToPath } from 'node:url';
62
62
  import { parseHookEvent, toolName, commandOf, findInvocations, rawToolResponse, readStdinBounded } from './hook-input.mjs';
63
+ import { isDirectInvocation } from './entrypoint.mjs';
63
64
 
64
65
  const MANAGED_TOOLS = ['gh', 'vercel', 'netlify', 'npm', 'git'];
65
66
 
@@ -183,7 +184,7 @@ async function main() {
183
184
  }
184
185
 
185
186
  function isMain() {
186
- try { return process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); }
187
+ try { return isDirectInvocation(import.meta.url); }
187
188
  catch { return false; }
188
189
  }
189
190
 
@@ -47,6 +47,7 @@
47
47
  */
48
48
  import fs from 'node:fs';
49
49
  import { fileURLToPath } from 'node:url';
50
+ import { isDirectInvocation } from './entrypoint.mjs';
50
51
 
51
52
  /** Env vars that bill per token. OPENROUTER is deliberately absent — see below. */
52
53
  export const METERED_KEYS = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY'];
@@ -113,7 +114,7 @@ export function check(command, env = process.env) {
113
114
  }
114
115
 
115
116
  const isMain = (() => {
116
- try { return process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); }
117
+ try { return isDirectInvocation(import.meta.url); }
117
118
  catch { return false; }
118
119
  })();
119
120
 
@@ -28,6 +28,7 @@
28
28
  import fs from 'node:fs';
29
29
  import path from 'node:path';
30
30
  import { fileURLToPath } from 'node:url';
31
+ import { isDirectInvocation } from './entrypoint.mjs';
31
32
 
32
33
  export const RUV_TOOL = 'ruflo memory store|retrieve|search (or `ruflo hooks session-end|post-task`)';
33
34
  /** Repo-relative scope: our own executable code. Tests and fixtures are never in scope. */
@@ -197,7 +198,7 @@ function repoRootOf(file) {
197
198
  }
198
199
 
199
200
  function isMain() {
200
- try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; }
201
+ try { return isDirectInvocation(import.meta.url); } catch { return false; }
201
202
  }
202
203
  if (isMain()) {
203
204
  let code = 0;
@@ -42,7 +42,7 @@ import { fileURLToPath } from 'node:url';
42
42
  import { nativeTurnLines } from './native-turn-transcript.mjs';
43
43
  import { resolveRuflo, rufloInvocation, rufloCwdFor, rufloScratchRoot } from './ruflo-bin.mjs';
44
44
  import { redactText } from './continuity-events.mjs';
45
- import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
45
+ import { resolveProjectStore, globalEnrollmentAllowed, storeEncryptedAtRest, ENCRYPTED_STORE_REASON } from './project-store-resolver.mjs';
46
46
  import { recordHookFailure } from './hook-failures.mjs';
47
47
 
48
48
  const digest = (value) => crypto.createHash('sha256').update(String(value)).digest('hex');
@@ -53,6 +53,7 @@ function appendReceipt(file, row) {
53
53
  }
54
54
  const flagValue = (args, flag) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
55
55
  import { contentPathExcludes, pathIsExcluded, privateTurn, maskExcludedPaths, captureFailureReason, firstTurnCaptureNotice } from './turn-capture-privacy.mjs';
56
+ import { isDirectInvocation } from './entrypoint.mjs';
56
57
 
57
58
  export const TURN_NAMESPACE = 'turns';
58
59
  export const MIN_OUTCOME_CHARS = 200;
@@ -315,7 +316,8 @@ export function captureTurnOutcome({
315
316
  if (!sessionKey) report.skipped = 'no session identity in the host payload';
316
317
  else if (turn.finalText.length < MIN_OUTCOME_CHARS && !turn.files.length) {
317
318
  report.skipped = turn.excludedResource ? 'turn content withheld: explicitly excluded resource' : host === 'codex' && !message ? 'codex payload carried no last_assistant_message' : 'trivial turn';
318
- } else {
319
+ } else if (storeEncryptedAtRest(db)) report.skipped = loud(`${ENCRYPTED_STORE_REASON}; turn not written (opt out: RUVNET_TURN_CAPTURE=off)`, db); // #439: no ruflo spawn that can only be refused
320
+ else {
319
321
  const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
320
322
  const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
321
323
  const identity = crypto.createHash('sha256').update(`${host}:${db}:${sessionKey}`).digest('hex');
@@ -402,6 +404,7 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
402
404
  const data = { key: flagValue(queued.args, '--key'), value: flagValue(queued.args, '--value'), db: flagValue(queued.args, '--path') };
403
405
  if (typeof data.key !== 'string' || typeof data.value !== 'string') throw new Error('invalid turn store step');
404
406
  if (data.db !== db) throw new Error('foreign turn store rejected');
407
+ if (storeEncryptedAtRest(db)) throw new Error(ENCRYPTED_STORE_REASON);
405
408
  if (queued.brainHome !== brainHome) throw new Error('turn consent settings differ from trusted caller');
406
409
  binding = queued;
407
410
  key = data.key; value = maskExcludedPaths(redactText(data.value), target.contentPathExcludes, target.capturePath);
@@ -471,6 +474,7 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
471
474
  let target;
472
475
  try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
473
476
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
477
+ if (storeEncryptedAtRest(target.db)) return { state: 'warn', line: `turn recording off for this store — ${ENCRYPTED_STORE_REASON}. Keep this project's store unencrypted, or set RUVNET_TURN_CAPTURE=off to stop trying` };
474
478
  const notice = noticeOnFirstUse ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
475
479
  const status = (value) => ({ ...value, ...(notice ? { notice } : {}) });
476
480
  let rows = [];
@@ -494,7 +498,7 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
494
498
  : { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' });
495
499
  }
496
500
 
497
- if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--run-steps') {
501
+ if (isDirectInvocation(import.meta.url) && process.argv[2] === '--run-steps') {
498
502
  try { runSteps(JSON.parse(process.argv[3] || '{}')); } catch { /* a detached worker has no one to report to */ }
499
503
  process.exit(0);
500
504
  }
@@ -89,6 +89,7 @@ import { spawnSync } from 'node:child_process';
89
89
  import { fileURLToPath, pathToFileURL } from 'node:url';
90
90
  import { readStdinBounded, isHarnessGenerated } from './hook-input.mjs';
91
91
  import { resolveBash } from './hook-shim-bash.mjs';
92
+ import { ADVISORY_CONTRACT } from './advisory-contract.mjs';
92
93
 
93
94
  // WHERE THIS FILE'S SIBLINGS LIVE. Resolved from THIS file's own location so it is correct under the
94
95
  // Stable Spine (an immutable versions/<gen> tree) and in a dev checkout alike — hook-shim.mjs has
@@ -127,6 +128,17 @@ const MAX_BUFFER = 1 << 20;
127
128
 
128
129
  const VALID_CHANNELS = new Set(['advocacy', 'promotion', 'lesson', 'alarm']);
129
130
  const VALID_EFFECTS = new Set(['advisory', 'block']);
131
+ // QUIET BY DEFAULT, ENFORCED HERE (4.7.21 review F1). Unless RUVNET_STOP_NUDGES=full, this run is narrowed
132
+ // to lesson + alarm: lesson producers still run and deliver, advocacy/promotion producers are never spawned.
133
+ // The shim also exports RUVNET_UNPROMPTED_CHANNELS, but the shim is boot-frozen and this body is hot-updated,
134
+ // so the quiet promise must not depend on which shim generation happens to dispatch it. An explicit
135
+ // RUVNET_UNPROMPTED_CHANNELS still wins (the shim's own narrowing, or a caller that names channels).
136
+ const QUIET_DEFAULT_CHANNELS = 'lesson,alarm';
137
+ const NUDGES_FULL = String(process.env.RUVNET_STOP_NUDGES || '').toLowerCase() === 'full';
138
+ const CHANNEL_LIST = process.env.RUVNET_UNPROMPTED_CHANNELS || (NUDGES_FULL ? '' : QUIET_DEFAULT_CHANNELS);
139
+ const ALLOWED_CHANNELS = CHANNEL_LIST
140
+ ? new Set(CHANNEL_LIST.split(',').map((c) => c.trim()).filter((c) => VALID_CHANNELS.has(c)))
141
+ : VALID_CHANNELS;
130
142
 
131
143
  /** Exit 0 with byte-empty stdout — the fail-safe and the "nothing to say" path. */
132
144
  function silent() { process.exit(0); }
@@ -242,7 +254,7 @@ if (!event) silent();
242
254
  if (typeof promptText === 'string' && isHarnessGenerated(promptText)) silent();
243
255
  }
244
256
 
245
- const producers = resolveProducers(EVENT);
257
+ const producers = resolveProducers(EVENT).filter((p) => p.channels.some((c) => ALLOWED_CHANNELS.has(c)));
246
258
  if (!producers.length) silent(); // unknown event, or nothing wired for it — never speak on a guess
247
259
 
248
260
  // ── Spawn producers as CAPTURED children, collect candidate lines ──────────────────────────────────
@@ -299,6 +311,7 @@ for (const { s, channels } of rawLines) {
299
311
  try { c = JSON.parse(s); } catch { continue; } // not JSON → drop (a rogue's raw line)
300
312
  if (!c || typeof c !== 'object' || Array.isArray(c)) continue;
301
313
  if (!VALID_CHANNELS.has(c.channel)) continue; // unknown/absent channel → drop
314
+ if (!ALLOWED_CHANNELS.has(c.channel)) continue; // narrowed by the shim's quiet default → drop
302
315
  if (!channels.includes(c.channel)) continue; // ANTI-SPOOF (GPT-5.6-Sol): this producer is
303
316
  // not authorised for this channel → drop
304
317
  if (!VALID_EFFECTS.has(c.effect)) continue; // unknown/absent effect → drop
@@ -471,9 +484,13 @@ const hookEventName = EVENT ? EVENT.split('-')[0] : 'UserPromptSubmit';
471
484
 
472
485
  // SYNCHRONOUS write — the truncation fix on the byte-owner itself (see the block path above).
473
486
  if (!advisories.length) silent();
474
- const additionalContext = advisories.map((a) => a.copy).join('\n\n');
487
+ // #489: optional recommendations carry the advisory contract into the model-visible context, once,
488
+ // so a suggestion is never promoted into a question/approval dialog. Alarms and lessons are not offers.
489
+ const delivered = advisories.some((a) => a.channel === 'advocacy')
490
+ ? [...advisories, { copy: ADVISORY_CONTRACT, channel: 'advisory-contract' }] : advisories;
491
+ const additionalContext = delivered.map((a) => a.copy).join('\n\n');
475
492
  try { fs.writeSync(1, process.env.RUVNET_HOOK_CONTEXT_BUDGET === '1'
476
- ? emitOwnedContext(contextFrame('unprompted-speech', hookEventName, advisories.map((a) => contextBlock(a.copy, { id: a.channel, critical: a.channel === 'alarm' }))))
493
+ ? emitOwnedContext(contextFrame('unprompted-speech', hookEventName, delivered.map((a) => contextBlock(a.copy, { id: a.channel, critical: a.channel === 'alarm' }))))
477
494
  : JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } })); }
478
495
  catch { /* if even the final write fails, silence is the fail-safe */ }
479
496
  process.exit(0);