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 +2 -2
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +3 -3
- package/plugin/scripts/nightly-scheduler.mjs +29 -8
- package/plugin/scripts/session-start-core.mjs +3 -1
- package/plugin/scripts/session-start-issue-alert.mjs +42 -0
- package/scripts/codex-hook-trust-reconcile.mjs +126 -9
- package/scripts/codex-hook-trust.mjs +45 -0
- package/scripts/issue-watch.mjs +50 -9
- package/scripts/job-heartbeat.sh +107 -10
- package/scripts/nightly-gists.sh +4 -1
- package/scripts/ntfy-inbox.mjs +526 -0
- package/scripts/release-qualification-contract.mjs +9 -0
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
|
-
[](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 ·
|
|
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.
|
|
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.
|
|
4
|
+
"version": "4.7.20",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -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-
|
|
4
|
-
version: 1.0.
|
|
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
|
-
|
|
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
|
|
333
|
-
&& receipt.sourceSha256 ===
|
|
334
|
-
&& Object.entries(receipt.sourceSnapshot || {}).every(([name, digest]) => validDigest(digest) && digest ===
|
|
335
|
-
&& EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' ||
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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] }));
|
|
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.
|
|
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
|
+
}
|
package/scripts/issue-watch.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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(
|
|
398
|
+
console.log(' already paged once — listed in the morning summary until answered');
|
|
358
399
|
}
|
|
359
400
|
console.log('');
|
|
360
401
|
}
|