ruvnet-brain 4.7.18 → 4.7.20

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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
9
9
 
10
10
  ### Discover the stack. Read the source. Build with confidence.
11
11
 
12
- [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.18-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
12
+ [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.20-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
13
13
 
14
14
  **A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
15
15
 
@@ -255,7 +255,7 @@ uses the protected release workflow; a consumer update downloads an available ge
255
255
  does not rebuild upstream knowledge. Check `--doctor`, `SOURCE.json` and refresh receipts
256
256
  before treating an installation as current.
257
257
 
258
- The **4.6.0 published-corpus baseline** is **205 public stores · 165,488 public source chunks**.
258
+ The **4.6.0 published-corpus baseline** is **205 public stores · 166,022 public source chunks**.
259
259
  Those are bundle census values, not a claim that every upstream repository is fresh today.
260
260
  Later corpus publication may advance the knowledge generation independently of code changes.
261
261
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.18",
3
+ "version": "4.7.20",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.7.18",
4
+ "version": "4.7.20",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.18",
3
+ "version": "4.7.20",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -1,7 +1,7 @@
1
1
  ---
2
- description: Open the RuvNet Brain Console (/rnbc) — a local web page that mirrors YOUR machine's RuvNet setup, explains it in plain English, and lets you safely configure and fix it. Read-only until you click; every machine change is explained first and is reversible.
3
- updated: 2026-10-10 06:00:22 EDT
4
- version: 1.0.2
2
+ description: "Open the RuvNet Brain Console (/rnbc) — a local web page that mirrors YOUR machine's RuvNet setup, explains it in plain English, and lets you safely configure and fix it. Read-only until you click; every machine change is explained first and is reversible."
3
+ updated: 2026-10-24 18:00:00 EDT
4
+ version: 1.0.3
5
5
  ---
6
6
 
7
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -186,7 +186,8 @@ function verifyUpdateModules(modules) {
186
186
  * the allowlisted values from env; an object is kept verbatim; null omits the field (an older registration without it).
187
187
  */
188
188
  export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
189
- identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false } = {}) {
189
+ identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false,
190
+ replacedUpdateModules = null } = {}) {
190
191
  validateIdentity(identity);
191
192
  if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
192
193
  const bytes = fs.readFileSync(source);
@@ -204,7 +205,8 @@ export function installNightlyRunner({ brainHome, source, nodePath = process.exe
204
205
  const record = { updateModules: modules, ...(developerSuite ? { mode: 'developer-suite' } : {}), schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
205
206
  nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
206
207
  ...(environment === null ? {} : { environment: environment ?? Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])) }),
207
- packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity) };
208
+ packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity),
209
+ ...(Array.isArray(replacedUpdateModules) && replacedUpdateModules.length ? { replacedUpdateModules } : {}) };
208
210
  const recordPath = path.join(dir, registrationName(identity));
209
211
  if (dryRun) return { ...record, recordPath };
210
212
  const tmp = `${recordPath}.tmp-${process.pid}`;
@@ -239,6 +241,12 @@ export function nightlyModuleCurrency({ brainHome, identity = NIGHTLY_LABEL, mod
239
241
  * and the LaunchAgent command stays exactly what it was; anything else is refused and left as it was.
240
242
  */
241
243
  export const SCHEDULER_VISIBLE_FIELDS = Object.freeze(['identity', 'nodePath', 'runnerPath', 'runnerSha256', 'argv', 'environment', 'packageTarget', 'bundleTarget', 'mode']);
244
+ /** The replaced-module history after replacing `record.updateModules` now: newest first, last 48 h, at most 20. */
245
+ export function nextReplacedSets(record, now = Date.now()) {
246
+ return [{ updateModules: record.updateModules, replacedAt: new Date(now).toISOString() },
247
+ ...(Array.isArray(record.replacedUpdateModules) ? record.replacedUpdateModules : [])]
248
+ .filter((entry) => now - Date.parse(entry?.replacedAt || '') <= 48 * 3_600_000).slice(0, 20);
249
+ }
242
250
  export function refreshNightlyModules(options = {}) {
243
251
  const currency = nightlyModuleCurrency(options);
244
252
  if (currency.state !== 'stale') return currency;
@@ -250,7 +258,11 @@ export function refreshNightlyModules(options = {}) {
250
258
  const candidate = (options.build || ((p) => installNightlyRunner({ ...p, dryRun: true })))(plan);
251
259
  const differs = SCHEDULER_VISIBLE_FIELDS.filter((key) => JSON.stringify(candidate[key] ?? null) !== JSON.stringify(record[key] ?? null));
252
260
  if (differs.length) return { ...currency, state: 'refused', reason: `refresh would change ${differs.join(', ')}; registration left as it was`, fix: NIGHTLY_ENABLE_FIX };
253
- const refreshed = installNightlyRunner(plan);
261
+ // Remember the module set this replaces: the receipt of the night that ran on it stays valid evidence until the next
262
+ // night (2026-10-24: doctor said "nightly refresh FAILED" all day after a session-start update re-pointed the modules).
263
+ // Newest first, every set replaced in the last 48 h (a receipt older than the 30 h health window is stale anyway), at
264
+ // most 20: any number of refreshes before the next night keeps the set that night ran on (reviews 2026-10-24).
265
+ const refreshed = installNightlyRunner({ ...plan, replacedUpdateModules: nextReplacedSets(record) });
254
266
  return { ...currency, state: 'refreshed', registered: registeredModuleDigest(refreshed), fix: undefined };
255
267
  }
256
268
 
@@ -329,10 +341,19 @@ export function developerRunHealth({ brainHome, registration, now = Date.now(),
329
341
  } catch (error) { return { state: 'failed', evidence: `Coordinated update receipt unreadable or refused: ${error.message}`, receipt: null }; }
330
342
  if (!receipt) return { state: 'never-ran', evidence: 'No coordinated update receipt exists yet.', receipt: null };
331
343
  const validDigest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
332
- const bound = validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
333
- && receipt.sourceSha256 === registration.updateModules?.['developer-update.mjs']?.sha256
334
- && Object.entries(receipt.sourceSnapshot || {}).every(([name, digest]) => validDigest(digest) && digest === registration.updateModules?.[name]?.sha256)
335
- && EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || registration.updateModules?.[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === registration.updateModules?.[name]?.sha256);
344
+ const boundTo = modules => Boolean(modules) && validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
345
+ && receipt.sourceSha256 === modules['developer-update.mjs']?.sha256
346
+ && 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);
348
+ // A receipt from the module set an update just replaced is still the night's proof, but only one that FINISHED before
349
+ // the replacement: the next scheduled run must bind to the current set.
350
+ // Judged by LAUNCH time: a night's own knowledge step can install a release and re-point the modules before the run
351
+ // records finishedAt (cross-model review 2026-10-24). A run launched after its set was replaced must bind to the new one.
352
+ const launched = Date.parse(receipt.startedAt || receipt.finishedAt || '');
353
+ const ranOnPrevious = !boundTo(registration.updateModules) && Array.isArray(registration.replacedUpdateModules)
354
+ && registration.replacedUpdateModules.some((entry) => Number.isFinite(Date.parse(entry?.replacedAt || ''))
355
+ && launched <= Date.parse(entry.replacedAt) && boundTo(entry.updateModules));
356
+ const bound = boundTo(registration.updateModules) || ranOnPrevious;
336
357
  const ageHours = (now - Date.parse(receipt.finishedAt || receipt.startedAt || '')) / 3_600_000;
337
358
  const times = [receipt.startedAt, receipt.finishedAt].filter(value => value !== undefined).map(value => Date.parse(value));
338
359
  const timestampsValid = times.length > 0 && times.every(value => Number.isFinite(value) && value <= now)
@@ -349,7 +370,7 @@ export function developerRunHealth({ brainHome, registration, now = Date.now(),
349
370
  if (receipt.state !== 'completed') return { state: 'failed', ageHours, receipt, evidence: 'Coordinated update terminal state is unverified.' };
350
371
  if (ageHours > maxAgeHours) return { state: 'stale', ageHours, receipt, evidence: `Last coordinated update is ${ageHours.toFixed(1)}h old.` };
351
372
  const verification = { ok: bound && receipt.mode === 'apply' && receipt.schedulerIdentity === registration.identity && receipt.state === 'completed' && receipt.ok === true && ageHours >= 0 && ageHours <= maxAgeHours };
352
- return { state: verification.ok ? 'ok' : 'failed', verification, ageHours, receipt, evidence: verification.ok ? `Coordinated updates completed ${ageHours.toFixed(1)}h ago; ${receipt.config?.scope || 'unknown'} scope.` : 'Coordinated update timestamp or terminal evidence is invalid.' };
373
+ return { state: verification.ok ? 'ok' : 'failed', verification, ageHours, receipt, evidence: verification.ok ? `Coordinated updates completed ${ageHours.toFixed(1)}h ago; ${receipt.config?.scope || 'unknown'} scope.${ranOnPrevious ? ' That run used the code an update replaced since; the next night runs the update.' : ''}` : 'Coordinated update timestamp or terminal evidence is invalid.' };
353
374
  }
354
375
 
355
376
  export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Date.now(), maxAgeHours = 30,
@@ -31,7 +31,7 @@ import { turnRecordingStatus } from './turn-outcome-capture.mjs';
31
31
  import {
32
32
  read, json, exists, mkdir, write, runNode,
33
33
  } from './session-start-fsutil.mjs';
34
- import { maintainerIssueEntitlement, surfaceIssuePointer } from './session-start-issue-alert.mjs';
34
+ import { maintainerIssueEntitlement, surfaceIssuePointer, surfaceNtfyAlerts } from './session-start-issue-alert.mjs';
35
35
  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';
@@ -115,6 +115,7 @@ export async function runSessionStart({
115
115
  || s.startsWith(KNOWLEDGE_LINE_PREFIX)
116
116
  || s.startsWith(FOOTPRINT_LINE_PREFIX)
117
117
  || s.startsWith('[RuvNet Brain — OPEN ISSUES')
118
+ || s.startsWith('[RuvNet Brain — NTFY:')
118
119
  || s.startsWith('[RuvNet Brain — TURN CAPTURE]')
119
120
  || /\bopen issue\(s\)/i.test(s)
120
121
  || /^\[RuvNet Brain — external signal/i.test(s)
@@ -305,6 +306,7 @@ export async function runSessionStart({
305
306
 
306
307
  tracer.stage('issue-pointer', () => {
307
308
  surfaceIssuePointer({ stateDir, emit, now, env, home, platform, cwd });
309
+ surfaceNtfyAlerts({ stateDir, emit, now, env, home, platform, cwd });
308
310
  });
309
311
  tracer.stage('signal-surface', () => {
310
312
  surfaceSignals({ env, cwd, stateDir, hookDir, emit, now });
@@ -75,3 +75,45 @@ export function surfaceIssuePointer({ stateDir, emit, now, env, home, platform,
75
75
  emit(`[RuvNet Brain — OPEN ISSUES: ${open.length} on ${status.repo}${breachNote}, checked ${ageHours.toFixed(1)}h ago — full detail: node scripts/issue-watch.mjs]`);
76
76
  } catch { /* advisory only — a broken cache read must never affect the session */ }
77
77
  }
78
+
79
+ /**
80
+ * The owner's ntfy alerts that still need action (scripts/ntfy-inbox.mjs keeps the list; owner, 2026-10-25: "I don't
81
+ * want to be in the middle of this equation where I'm having to tell you what's broken"). Same maintainer gate as the
82
+ * issue pointer; one line, naming the count and the three newest, with the instruction to handle them first.
83
+ */
84
+ export function surfaceNtfyAlerts({ stateDir, emit, env, home, platform, cwd, now = Date.now() }) {
85
+ try {
86
+ if (!maintainerIssueEntitlement(env, home, 'stuinfla/ruvnet-brain', platform) || !isMaintainerRepo(cwd)) return;
87
+ // The reader writes a few-byte summary (scripts/ntfy-inbox.mjs writeSummary) so a large alert list can never
88
+ // push this banner past its read limit; the detailed list is only a fallback for an older reader.
89
+ let total = 0; let kinds = {};
90
+ const summaryPath = path.join(stateDir, 'ntfy-open-summary.json');
91
+ const summary = (() => { try { return fs.statSync(summaryPath).size <= 4096 ? json(summaryPath) : null; } catch { return null; } })();
92
+ // The summary is written before the list; if the list is newer anyway (an interrupted write), trust the list.
93
+ // Compare the files' own write times (the summary's `at` is when the poll started, which can be minutes earlier).
94
+ const listNewer = (() => { try { return fs.statSync(path.join(stateDir, 'ntfy-open-alerts.json')).mtimeMs > fs.statSync(summaryPath).mtimeMs + 5000; } catch { return false; } })();
95
+ let dropped = 0;
96
+ if (summary && Number.isInteger(summary.open) && !listNewer) {
97
+ if (Number.isInteger(summary.dropped)) dropped = summary.dropped;
98
+ total = summary.open;
99
+ for (const [k, n] of Object.entries(summary.kinds || {})) if (['job', 'ci', 'issue', 'pr', 'other'].includes(k) && Number.isInteger(n)) kinds[k] = n;
100
+ } else {
101
+ const file = path.join(stateDir, 'ntfy-open-alerts.json');
102
+ let size = 0;
103
+ try { size = fs.statSync(file).size; } catch { return; }
104
+ // Too big to read here is still news: say so rather than staying silent.
105
+ if (size > MAX_STATUS_BYTES) { emit('[RuvNet Brain — NTFY: open alerts present (list too large to summarise here). Details: node scripts/ntfy-inbox.mjs --open]'); return; }
106
+ const open = (json(file, []) || []).filter((a) => a && !a.closedAt);
107
+ total = open.length;
108
+ // Counts by kind only, never message text: anyone who learns the topic can post to it, so a title here would
109
+ // be a way to put words into the session (review 2026-10-25). The owner's tool prints the details.
110
+ for (const a of open) { const k = ['job', 'ci', 'issue', 'pr'].includes(a.kind) ? a.kind : 'other'; kinds[k] = (kinds[k] || 0) + 1; }
111
+ }
112
+ if (!total && !dropped) return;
113
+ const summaryText = Object.entries(kinds).map(([k, n]) => `${n} ${k}`).join(', ');
114
+ const polled = json(path.join(stateDir, 'ntfy-inbox-state.json'), {})?.polledAt;
115
+ const age = polled ? `, read ${((now - Date.parse(polled)) / 3_600_000).toFixed(1)}h ago` : '';
116
+ const over = dropped ? `; ${dropped} more dropped past the cap` : '';
117
+ emit(`[RuvNet Brain — NTFY: ${total} open alert(s) (${summaryText || 'none listed'})${over}${age}. Details: node scripts/ntfy-inbox.mjs --open]`);
118
+ } catch { /* advisory only */ }
119
+ }
@@ -5,7 +5,103 @@ import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { spawn, spawnSync } from 'node:child_process';
8
- import { codexHookIdentities } from './codex-hook-trust.mjs';
8
+ import zlib from 'node:zlib';
9
+ import { codexHookIdentities, codexPluginContentHash } from './codex-hook-trust.mjs';
10
+
11
+ /**
12
+ * sha256 of every regular file under package/plugin/ in a verified npm tarball, read in memory (ustar with pax `path`
13
+ * records; nothing is extracted or written). Strict: a bad header checksum, a non-octal size, a body past the end, or
14
+ * any entry type other than file, directory or pax is refused rather than guessed (review 2026-10-24).
15
+ */
16
+ export function publishedPluginFiles(archive) {
17
+ const tar = zlib.gunzipSync(archive);
18
+ const files = new Map();
19
+ const field = (b) => { const i = b.indexOf(0); return (i < 0 ? b : b.subarray(0, i)).toString('utf8'); };
20
+ const octal = (b, what) => { const t = field(b).trim(); if (!/^[0-7]+$/.test(t)) throw new Error(`Published archive has a non-octal ${what}`); return parseInt(t, 8); };
21
+ const paxRecords = (body) => {
22
+ const out = {}; let i = 0;
23
+ while (i < body.length) {
24
+ const space = body.indexOf(0x20, i);
25
+ if (space < 0) throw new Error('Published archive has a malformed pax record');
26
+ const len = Number(body.subarray(i, space).toString('ascii'));
27
+ if (!Number.isSafeInteger(len) || len <= space - i || i + len > body.length || body[i + len - 1] !== 0x0a) throw new Error('Published archive has a malformed pax record');
28
+ const rec = body.subarray(space + 1, i + len - 1).toString('utf8');
29
+ const eq = rec.indexOf('=');
30
+ if (eq < 1) throw new Error('Published archive has a malformed pax record');
31
+ out[rec.slice(0, eq)] = rec.slice(eq + 1);
32
+ i += len;
33
+ }
34
+ return out;
35
+ };
36
+ let pax = null; let off = 0;
37
+ for (; off + 512 <= tar.length;) {
38
+ const header = tar.subarray(off, off + 512);
39
+ if (header.every((b) => b === 0)) break;
40
+ const sum = header.reduce((n, b, i) => n + (i >= 148 && i < 156 ? 32 : b), 0);
41
+ if (octal(header.subarray(148, 156), 'checksum') !== sum) throw new Error('Published archive header checksum mismatch');
42
+ const size = octal(header.subarray(124, 136), 'size');
43
+ const type = header[156] === 0 ? '0' : String.fromCharCode(header[156]);
44
+ const prefix = field(header.subarray(345, 500));
45
+ const name = pax?.path ?? (prefix ? `${prefix}/${field(header.subarray(0, 100))}` : field(header.subarray(0, 100)));
46
+ if (off + 512 + size > tar.length) throw new Error('Published archive entry runs past the end');
47
+ const body = tar.subarray(off + 512, off + 512 + size);
48
+ off += 512 + Math.ceil(size / 512) * 512;
49
+ if (type === 'x') { pax = paxRecords(body); continue; }
50
+ if (type === 'g') { pax = null; continue; }
51
+ pax = null;
52
+ if (type === '5') continue;
53
+ if (type !== '0') throw new Error(`Published archive has an unsupported entry type ${JSON.stringify(type)}`);
54
+ if (name.startsWith('package/plugin/')) files.set(name.slice('package/plugin/'.length), digest(body));
55
+ }
56
+ if (!files.size) throw new Error('Published archive carries no plugin files');
57
+ return files;
58
+ }
59
+
60
+ // Codex writes this under the installed plugin itself: one SKILL.md per migrated slash command (openai/codex
61
+ // rust-v0.164.0 codex-rs/core-plugins/src/command_migration.rs render_command_skill). It is not release bytes, so it is
62
+ // accepted only when every byte is what that template renders from the release's own verified command file.
63
+ const CODEX_GENERATED = 'migrated-command-skills';
64
+ function commandParts(text) {
65
+ const m = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)([\s\S]*)$/.exec(text);
66
+ if (!m) return null;
67
+ const d = /^description:\s*"((?:[^"\\]|\\.)*)"\s*$/m.exec(m[1]);
68
+ return d ? { description: JSON.parse(`"${d[1]}"`).trim(), body: m[2] } : null;
69
+ }
70
+ function expectedGeneratedSkill(skillName, sourceName, commandText) {
71
+ const parts = commandParts(commandText);
72
+ if (!parts || !parts.description) return null;
73
+ const body = parts.body.trim() || 'No command template body was found.';
74
+ return `---\nname: ${JSON.stringify(skillName)}\ndescription: ${JSON.stringify(parts.description)}\n---\n\n# ${skillName}\n\n`
75
+ + `Use this skill when the user asks to run the migrated source command \`${sourceName}\`.\n\n## Command Template\n\n${body}\n`;
76
+ }
77
+
78
+ /**
79
+ * ONE pass over the installed plugin: each file's bytes are checked against the verified release (or, for Codex's own
80
+ * generated skills, against the template rendered from the verified command file) and the SAME bytes feed the content
81
+ * fingerprint returned. Throws on any changed, extra or missing file, link, or generated file that is not exactly
82
+ * Codex's rendering.
83
+ */
84
+ export function verifiedPluginContentHash(root, pluginFiles, { platform = process.platform } = {}) {
85
+ const seen = new Set(); const commands = new Map(); const generated = new Map();
86
+ const genPrefix = `.codex-plugin/${CODEX_GENERATED}/`;
87
+ const hash = codexPluginContentHash(root, { platform, onFile: (rel, bytes) => {
88
+ if (rel.startsWith(genPrefix)) { generated.set(rel.slice(genPrefix.length), bytes); return; }
89
+ if (pluginFiles.get(rel) !== digest(bytes)) throw new Error(`Installed plugin file differs from the verified release: ${rel}`);
90
+ seen.add(rel);
91
+ if (/^commands\/[^/]+\.md$/.test(rel)) commands.set(rel.slice('commands/'.length, -3), bytes.toString('utf8'));
92
+ } });
93
+ const missing = [...pluginFiles.keys()].filter((k) => !seen.has(k));
94
+ if (missing.length) throw new Error(`Installed plugin lacks ${missing.length} released file(s), e.g. ${missing[0]}`);
95
+ for (const [rel, bytes] of generated) {
96
+ const m = /^source-command-([a-z0-9-]+)\/SKILL\.md$/.exec(rel);
97
+ const source = m && commands.get(m[1]);
98
+ const expected = source && expectedGeneratedSkill(`source-command-${m[1]}`, m[1], source);
99
+ if (!expected || bytes.toString('utf8') !== expected) throw new Error(`Codex-generated file is not the rendering of a verified release command: ${rel}`);
100
+ }
101
+ return hash;
102
+ }
103
+ /** Kept for callers that only need the yes/no: the installed plugin is the verified release. */
104
+ export function installedPluginMatchesRelease(root, pluginFiles) { verifiedPluginContentHash(root, pluginFiles); return true; }
9
105
 
10
106
  /** Authenticate exact published package bytes before reading one archive member. No extraction/install. */
11
107
  export async function obtainVerifiedPublishedHookIdentity({ version, fetch: fetcher = globalThis.fetch,
@@ -43,7 +139,9 @@ export async function obtainVerifiedPublishedHookIdentity({ version, fetch: fetc
43
139
  { input: archive, timeout: Math.min(remaining, 3000), maxBuffer: 256 * 1024 });
44
140
  if (result.error || result.status !== 0 || !result.stdout?.length) throw new Error('Platform tar cannot read exact published hooks entry');
45
141
  if (!codexHookIdentities(JSON.parse(result.stdout)).size) throw new Error('Published native hooks are invalid');
46
- return { state: 'verified', releaseIdentity: { version, integrity, hooksSha256: digest(result.stdout) },
142
+ let pluginFiles = null;
143
+ try { pluginFiles = publishedPluginFiles(archive); } catch { pluginFiles = null; /* content trust is then refused, never guessed */ }
144
+ return { state: 'verified', releaseIdentity: { version, integrity, hooksSha256: digest(result.stdout), pluginFiles },
47
145
  metadataUrl, tarballUrl, archiveBytes: archive.length };
48
146
  } catch (error) { return blocked(error.message); }
49
147
  }
@@ -142,9 +240,14 @@ const rowsFor = (response) => {
142
240
  if (!Array.isArray(response?.data)) throw new Error('Native hook registry unavailable');
143
241
  return response.data.flatMap((r) => r.hooks || []).filter((r) => r.pluginId === OWNER);
144
242
  };
145
- function assertRegistry(rows, identities) {
243
+ // Codex <0.164: currentHash is the declaration hash. 0.164+: "<declaration hash>/<plugin content hash>" (discovery.rs).
244
+ const splitHash = (current) => { const [declaration, content = null] = String(current || '').split('/'); return { declaration, content }; };
245
+ function assertRegistry(rows, identities, contentHash = null) {
146
246
  if (rows.length !== identities.size || new Set(rows.map((r) => r.key)).size !== identities.size
147
- || rows.some((row) => identities.get(row.key) !== row.currentHash)) throw new Error('Native registry differs from verified released artifact');
247
+ || rows.some((row) => identities.get(row.key) !== splitHash(row.currentHash).declaration)) throw new Error('Native registry differs from verified released artifact');
248
+ if (rows.some((row) => splitHash(row.currentHash).content !== null && splitHash(row.currentHash).content !== contentHash)) {
249
+ throw new Error('Native plugin content fingerprint differs from the verified installed plugin');
250
+ }
148
251
  }
149
252
 
150
253
  function otherSettings(config, keys) {
@@ -153,6 +256,7 @@ function otherSettings(config, keys) {
153
256
  const state = clone?.hooks?.state?.[key];
154
257
  if (state && typeof state === 'object') {
155
258
  delete state.trusted_hash;
259
+ delete state.trusted_content_hash;
156
260
  if (!Object.keys(state).length) delete clone.hooks.state[key];
157
261
  }
158
262
  }
@@ -170,7 +274,7 @@ function otherSettings(config, keys) {
170
274
  export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, installedVersion, hooksPath,
171
275
  configPath, nativeBinary, cwd = process.cwd(), backupDir = path.join(path.dirname(configPath || ''), 'backups'),
172
276
  rpc: suppliedRpc, timeoutMs = 10000 } = {}) {
173
- let client; let wrote = false; let writeAttempted = false; let backupPath;
277
+ let client; let wrote = false; let writeAttempted = false; let backupPath; let contentRefusal = 'Verified release plugin files unavailable';
174
278
  try {
175
279
  if (!releaseIdentity || !/^\d+\.\d+\.\d+$/.test(releaseIdentity.version || '')
176
280
  || installedVersion !== releaseIdentity.version || !/^[a-f0-9]{64}$/.test(releaseIdentity.hooksSha256 || '')
@@ -181,9 +285,19 @@ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, install
181
285
  if (digest(bytes) !== releaseIdentity.hooksSha256) return blocked('Installed hooks differ from verified release');
182
286
  const identities = codexHookIdentities(JSON.parse(bytes));
183
287
  if (!identities.size) return blocked('Verified artifact has no native hooks');
288
+ // Codex 0.164+ also pins the installed plugin folder. Approve that fingerprint only for a folder that holds exactly
289
+ // the verified release's plugin/ bytes (Codex's own generated migrations aside); otherwise refuse, never guess.
290
+ const pluginRoot = path.dirname(path.dirname(hooksPath));
291
+ let contentHash = null;
292
+ if (releaseIdentity.pluginFiles instanceof Map) {
293
+ try { contentHash = verifiedPluginContentHash(pluginRoot, releaseIdentity.pluginFiles); }
294
+ catch (error) { contentHash = null; contentRefusal = error.message; }
295
+ }
184
296
  if (!suppliedRpc) client = await nativeClient(nativeBinary, cwd, Date.now() + Math.min(timeoutMs, 20000), configPath);
185
297
  const rpc = suppliedRpc || client.rpc;
186
- const before = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(before, identities);
298
+ const before = rowsFor(await rpc('hooks/list', { cwds: [cwd] }));
299
+ if (!contentHash && before.some((row) => splitHash(row.currentHash).content !== null)) return blocked(contentRefusal);
300
+ assertRegistry(before, identities, contentHash);
187
301
  const disabled = before.filter((r) => r.enabled === false).map((r) => r.key).sort();
188
302
  if (before.some((r) => !['trusted', 'modified', 'untrusted', 'managed'].includes(r.trustStatus))) return blocked('Unknown native trust state');
189
303
  const pending = before.filter((r) => r.enabled === true && ['modified', 'untrusted'].includes(r.trustStatus));
@@ -194,8 +308,11 @@ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, install
194
308
  // Config stays memory-only except the private backup. No logging of effective config values.
195
309
  const prior = fs.readFileSync(configPath);
196
310
  backupPath = privateBackup(configPath, prior, backupDir);
197
- const edits = pending.map((r) => ({ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_hash`,
198
- value: identities.get(r.key), mergeStrategy: 'replace' }));
311
+ const edits = pending.flatMap((r) => [{ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_hash`,
312
+ value: identities.get(r.key), mergeStrategy: 'replace' },
313
+ // The second approval Codex 0.164 stores separately (plugin_trust.rs matches_hash).
314
+ ...(splitHash(r.currentHash).content !== null ? [{ keyPath: `hooks.state.${JSON.stringify(r.key)}.trusted_content_hash`,
315
+ value: contentHash, mergeStrategy: 'replace' }] : [])]);
199
316
  writeAttempted = true;
200
317
  await rpc('config/batchWrite', { filePath: configPath, expectedVersion: users[0].version, reloadUserConfig: true, edits });
201
318
  wrote = true;
@@ -203,7 +320,7 @@ export async function reconcileVerifiedCodexHookTrust({ releaseIdentity, install
203
320
  const afterUsers = (afterRead.layers || []).filter((r) => r.name?.type === 'user' && r.name.file === configPath);
204
321
  if (afterUsers.length !== 1 || otherSettings(users[0].config, pending.map((r) => r.key))
205
322
  !== otherSettings(afterUsers[0].config, pending.map((r) => r.key))) throw new Error('Unrelated config changed; no whole-file rollback attempted');
206
- const after = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(after, identities);
323
+ const after = rowsFor(await rpc('hooks/list', { cwds: [cwd] })); assertRegistry(after, identities, contentHash);
207
324
  if (JSON.stringify(after.filter((r) => r.enabled === false).map((r) => r.key).sort()) !== JSON.stringify(disabled)
208
325
  || after.some((r) => r.enabled === true && !['trusted', 'managed'].includes(r.trustStatus))) throw new Error('Native trust repair verification incomplete');
209
326
  return { state: 'registry-verified', changed: true, editedTrustHashes: edits.length,
@@ -23,6 +23,7 @@
23
23
  // that one hook `modified`, the other ten still `trusted`. currentHash from `hooks/list` equals
24
24
  // codexHookHash() below for every hook (tests/unit/codex-hook-trust.test.mjs pins the real values).
25
25
  import crypto from 'node:crypto';
26
+ import nodeFs from 'node:fs';
26
27
 
27
28
  const EVENT_LABEL = Object.freeze({
28
29
  PreToolUse: 'pre_tool_use', PermissionRequest: 'permission_request', PostToolUse: 'post_tool_use',
@@ -92,3 +93,47 @@ export function codexTrustChanges(prevHooksFile, nextHooksFile, keySource) {
92
93
  export const CODEX_TRUST_ACTION = 'Start Codex (interactive, in any project). At the "Hooks need review" prompt choose'
93
94
  + ' "Trust all and continue" — or type /hooks and trust each RuvNet Brain hook. Until then Codex skips them'
94
95
  + ' (codex exec never asks). Then re-run npx ruvnet-brain --doctor.';
96
+
97
+ /**
98
+ * Codex's plugin bundle fingerprint, ported byte for byte from openai/codex rust-v0.164.0
99
+ * codex-rs/hooks/src/engine/plugin_fingerprint.rs plugin_content_hash(). Since 0.164 a plugin hook's
100
+ * `currentHash` is "<declaration hash>/<this hash>" and its approval stores the two separately
101
+ * (`trusted_hash` + `trusted_content_hash`, plugin_trust.rs matches_hash). Measured 2026-10-24: every Brain update
102
+ * left all 13 Codex hooks "modified" because the Brain wrote only the declaration hash.
103
+ * Walk: depth-first, entries sorted by file name (bytes), skipping __pycache__, .venv, *.pyc, *.egg-info; each entry
104
+ * hashes its root-relative path (u64 LE length + bytes), then "directory", or "file" + u64 LE size + contents.
105
+ * Anything that is neither (a symlink) throws, as it does in Codex.
106
+ */
107
+ export function codexPluginContentHash(root, { fsImpl = null, onFile = null, platform = process.platform } = {}) {
108
+ const fs = fsImpl || nodeFs;
109
+ const u64 = (n) => { const b = Buffer.alloc(8); b.writeBigUInt64LE(BigInt(n)); return b; };
110
+ if (!fs.lstatSync(root).isDirectory()) throw new Error('plugin root is not a directory');
111
+ const hash = crypto.createHash('sha256').update('codex-plugin-bundle-v1');
112
+ // Rust hashes the OS-native relative path bytes: '\\' separators on Windows (review 2026-10-24).
113
+ const sep = platform === 'win32' ? '\\' : '/';
114
+ const pending = [''];
115
+ while (pending.length) {
116
+ const rel = pending.pop();
117
+ const base = rel.split('/').pop();
118
+ if (rel && (base === '__pycache__' || base === '.venv' || base.endsWith('.pyc') || base.endsWith('.egg-info'))) continue;
119
+ const name = Buffer.from(rel.split('/').join(sep));
120
+ hash.update(u64(name.length)).update(name);
121
+ const full = rel ? `${root}/${rel}` : root;
122
+ const st = fs.lstatSync(full);
123
+ if (st.isDirectory()) {
124
+ hash.update('directory');
125
+ const entries = fs.readdirSync(full).sort((a, b) => Buffer.compare(Buffer.from(a), Buffer.from(b)));
126
+ for (const entry of entries.reverse()) pending.push(rel ? `${rel}/${entry}` : entry);
127
+ } else if (st.isFile()) {
128
+ const bytes = fs.readFileSync(full);
129
+ if (bytes.length !== st.size) throw new Error('plugin file changed while fingerprinting');
130
+ // The caller sees exactly the bytes that enter the fingerprint: one read, so nothing can change between a check
131
+ // and the hash it approves (review 2026-10-24: a separate verification pass was a swap window).
132
+ onFile?.(rel, bytes);
133
+ hash.update('file').update(u64(st.size)).update(bytes);
134
+ } else {
135
+ throw new Error(`unsupported plugin entry type: ${full}`);
136
+ }
137
+ }
138
+ return `sha256:${hash.digest('hex')}`;
139
+ }
@@ -46,6 +46,21 @@ export const OWNER_LOGIN = 'stuinfla';
46
46
  // moved here.)
47
47
  export const BOT_MARKER = '🤖 Automated';
48
48
  const SLA_HOURS = 4;
49
+ // WHO IS WAITING (owner, 2026-10-25: "10,747 notifications"). An issue opened by the owner's own account or a bot is
50
+ // work the owner's automation filed (Dream Cycle, dependabot), not a person waiting for an answer: it never pages
51
+ // and gets no acknowledgment. Only a person's issue pages.
52
+ const AUTOMATION_AUTHORS = new Set([OWNER_LOGIN, 'dependabot', 'dependabot[bot]', 'github-actions', 'github-actions[bot]', 'app/dependabot']);
53
+ export const isPersonIssue = (issue) => !AUTOMATION_AUTHORS.has(String(issue?.author?.login || '').toLowerCase())
54
+ && !issue?.author?.is_bot && !String(issue?.author?.login || '').endsWith('[bot]');
55
+ // ONE morning summary instead of a page per issue per 4 hours: the first run at or after 9 a.m. Eastern sends it,
56
+ // only if a person's issue is still waiting, and at most once per Eastern calendar day.
57
+ const DIGEST_HOUR_ET = 9;
58
+ const etParts = (ms) => Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric',
59
+ month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }).formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
60
+ export function digestDue(lastDay, now) {
61
+ const p = etParts(now); const day = `${p.year}-${p.month}-${p.day}`;
62
+ return { due: Number(p.hour) % 24 >= DIGEST_HOUR_ET && lastDay !== day, day };
63
+ }
49
64
  const STATE_PATH = process.env.ISSUE_WATCH_STATE
50
65
  || path.join(os.homedir(), '.claude', 'ruvnet-brain', 'issue-watch-state.json');
51
66
  // A compact, always-current snapshot the SessionStart hook surfaces (2026-07-17). ntfy alerts are
@@ -78,7 +93,9 @@ function ghJson(args) {
78
93
  /** Resolve the ntfy topic the same way the rest of the repo does: env, then the machine-wide
79
94
  * cache file, then the repo .env — see scripts/notify.sh / scripts/job-heartbeat.sh / scripts/nightly-watchdog.mjs. */
80
95
  function resolveTopic() {
81
- if (process.env.NTFY_TOPIC) return process.env.NTFY_TOPIC;
96
+ // Set-but-EMPTY is an explicit opt-out (job-heartbeat.sh's convention): tests use it, and it must never fall
97
+ // through to the repo's real .env topic (2026-10-25: a unit test read the owner's topic that way).
98
+ if (process.env.NTFY_TOPIC !== undefined) return process.env.NTFY_TOPIC || null;
82
99
  try {
83
100
  const t = fs.readFileSync(path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ntfy-topic'), 'utf8').trim();
84
101
  if (t) return t;
@@ -133,7 +150,7 @@ export function judgeIssue(issue, comments, now) {
133
150
 
134
151
  export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}) {
135
152
  const issues = ghJson(['issue', 'list', '--repo', repo, '--state', 'open', '--json',
136
- 'number,title,createdAt,comments,updatedAt']);
153
+ 'number,title,createdAt,comments,updatedAt,author']);
137
154
 
138
155
  const state = loadState();
139
156
  const results = [];
@@ -144,17 +161,23 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
144
161
  const { ageHours, ownerComment, breach } = judgeIssue(issue, detail.comments || [], now);
145
162
  const url = `https://github.com/${repo}/issues/${issue.number}`;
146
163
  const key = String(issue.number);
147
- const last = state[key]?.lastAlertAt ? Date.parse(state[key].lastAlertAt) : null;
148
- const dueForAlert = breach && (!last || (now - last) / 3_600_000 >= SLA_HOURS);
164
+ // A person's issue pages ONCE when it first passes the SLA, never again (it used to repeat every 4 h, forever,
165
+ // which with ~14 old issues and an hourly run was most of the 10,747 notifications). The morning digest below
166
+ // carries whatever is still waiting. An existing lastAlertAt means that one page already went out.
167
+ const person = isPersonIssue(issue);
168
+ const dueForAlert = person && breach && !state[key]?.lastAlertAt;
149
169
  // First sighting → page once, immediately (rule 2 in the header). Delivery-derived like
150
170
  // lastAlertAt: state is only written when the push actually went out, so a failed push
151
171
  // retries next run instead of burying the sighting.
152
172
  const firstSighting = !state[key];
153
- // Acknowledgment delivery is not page delivery. Retry each missing channel independently.
154
- const needsNewPage = !state[key]?.newAlertAt;
155
- const needsAck = !state[key]?.ackAt;
173
+ // Acknowledgment delivery is not page delivery. Retry each missing channel independently. Neither applies to an
174
+ // issue the owner's automation opened.
175
+ // Arrival is paged once, instantly, by .github/workflows/ntfy-alerts.yml (the `issues: opened` event). Paging it
176
+ // here too sent every new issue twice (review 2026-10-25), so this watcher owns only the SLA page and the digest.
177
+ const needsNewPage = false;
178
+ const needsAck = person && !state[key]?.ackAt;
156
179
 
157
- results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url });
180
+ results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url, person });
158
181
 
159
182
  if (needsNewPage || needsAck) {
160
183
  if (dryRun) {
@@ -215,6 +238,22 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
215
238
  }
216
239
  }
217
240
 
241
+ // The morning digest: one notification naming every person's issue still unanswered, oldest first.
242
+ const waiting = results.filter((r) => r.person && r.breach).sort((a, b) => b.ageHours - a.ageHours);
243
+ const digest = digestDue(state._digest?.day, now);
244
+ if (waiting.length && digest.due) {
245
+ const title = `Waiting on a reply: ${waiting.length} contributor issue${waiting.length === 1 ? '' : 's'}`;
246
+ const body = waiting.slice(0, 8).map((r) => `#${r.number} (${fmtAge(r.ageHours)}) ${r.title.slice(0, 70)}`).join('\n')
247
+ + (waiting.length > 8 ? `\n…and ${waiting.length - 8} more` : '') + `\nhttps://github.com/${repo}/issues`;
248
+ if (dryRun) alertsSent.push({ kind: 'digest', sent: false, reason: 'dry-run', count: waiting.length });
249
+ else {
250
+ const topic = resolveTopic();
251
+ const sent = topic ? await pushNtfy(topic, { title, body, priority: 'default', tags: 'clipboard' }) : false;
252
+ if (sent) state._digest = { day: digest.day, sentAt: new Date(now).toISOString(), count: waiting.length };
253
+ alertsSent.push({ kind: 'digest', sent, count: waiting.length, reason: topic ? null : 'no ntfy topic configured' });
254
+ }
255
+ }
256
+
218
257
  if (!dryRun) saveState(state);
219
258
 
220
259
  return { results, alertsSent, checkedAt: new Date(now).toISOString() };
@@ -353,8 +392,10 @@ async function main() {
353
392
  if (r.dueForAlert) {
354
393
  const alert = output.alertsSent.find((a) => a.number === r.number && a.kind === 'sla-breach');
355
394
  console.log(` ${dryRun ? '[DRY-RUN] would push ntfy alert' : alert?.sent ? 'ntfy alert pushed' : `ntfy alert NOT sent (${alert?.reason})`}`);
395
+ } else if (r.breach && !r.person) {
396
+ console.log(' opened by the owner\'s own automation — never paged');
356
397
  } else if (r.breach) {
357
- console.log(` already alerted within the last ${SLA_HOURS}h — not repeating`);
398
+ console.log(' already paged once — listed in the morning summary until answered');
358
399
  }
359
400
  console.log('');
360
401
  }