ruvnet-brain 4.7.19 → 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 +8 -16
  64. package/plugin/scripts/session-start-health.mjs +97 -10
  65. package/plugin/scripts/session-start-issue-alert.mjs +105 -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 +164 -47
  138. package/scripts/job-heartbeat.sh +145 -13
  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 +30 -21
  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 +549 -0
  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 +71 -26
  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
@@ -33,6 +33,7 @@ import path from 'node:path';
33
33
  import os from 'node:os';
34
34
  import { spawnSync } from 'node:child_process';
35
35
  import { fileURLToPath, pathToFileURL } from 'node:url';
36
+ import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
36
37
 
37
38
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
38
39
  const REPO = 'stuinfla/ruvnet-brain';
@@ -46,6 +47,23 @@ export const OWNER_LOGIN = 'stuinfla';
46
47
  // moved here.)
47
48
  export const BOT_MARKER = '🤖 Automated';
48
49
  const SLA_HOURS = 4;
50
+ // A person's issue whose detail AND list comments cannot be read this many runs in a row fails the run once (review fix).
51
+ const UNREADABLE_RUNS = 3;
52
+ // WHO IS WAITING (owner, 2026-10-25: "10,747 notifications"). An issue opened by the owner's own account or a bot is
53
+ // work the owner's automation filed (Dream Cycle, dependabot), not a person waiting for an answer: it never pages
54
+ // and gets no acknowledgment. Only a person's issue pages.
55
+ const AUTOMATION_AUTHORS = new Set([OWNER_LOGIN, 'dependabot', 'dependabot[bot]', 'github-actions', 'github-actions[bot]', 'app/dependabot']);
56
+ export const isPersonIssue = (issue) => !AUTOMATION_AUTHORS.has(String(issue?.author?.login || '').toLowerCase())
57
+ && !issue?.author?.is_bot && !String(issue?.author?.login || '').endsWith('[bot]');
58
+ // ONE morning summary instead of a page per issue per 4 hours: the first run at or after 9 a.m. Eastern sends it,
59
+ // only if a person's issue is still waiting, and at most once per Eastern calendar day.
60
+ const DIGEST_HOUR_ET = 9;
61
+ const etParts = (ms) => Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York', year: 'numeric',
62
+ month: '2-digit', day: '2-digit', hour: '2-digit', hour12: false }).formatToParts(new Date(ms)).map((p) => [p.type, p.value]));
63
+ export function digestDue(lastDay, now) {
64
+ const p = etParts(now); const day = `${p.year}-${p.month}-${p.day}`;
65
+ return { due: Number(p.hour) % 24 >= DIGEST_HOUR_ET && lastDay !== day, day };
66
+ }
49
67
  const STATE_PATH = process.env.ISSUE_WATCH_STATE
50
68
  || path.join(os.homedir(), '.claude', 'ruvnet-brain', 'issue-watch-state.json');
51
69
  // A compact, always-current snapshot the SessionStart hook surfaces (2026-07-17). ntfy alerts are
@@ -54,31 +72,40 @@ const STATE_PATH = process.env.ISSUE_WATCH_STATE
54
72
  const STATUS_PATH = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'open-issues.json');
55
73
  const GH_BIN = process.env.GH_BIN || 'gh';
56
74
 
57
- function ghJson(args) {
58
- // Retry ONCE on a transient network-shaped failure (2026-07-19, same class as issue-fix's 1am
59
- // "TLS handshake timeout" page): 20s of patience absorbs a blip; a second failure still fails
60
- // LOUD. Bounded, logged, never silent.
75
+ /** A network-shaped gh failure worth waiting out (2026-07-19 "TLS handshake timeout"; 4.7.21 adds the 1 a.m.
76
+ * "error connecting to api.github.com" and GitHub's 502/503/504). Anything else — auth, 404, bad JSON — is real. */
77
+ export function isTransientGhError(text) {
78
+ return /TLS handshake|unexpected EOF|timeout|ECONNRESET|ETIMEDOUT|EAI_AGAIN|connection refused|temporarily unavailable|error connecting to api\.github\.com|HTTP 50[234]/i.test(String(text || ''));
79
+ }
80
+ /** Seconds to wait before attempts 2 and 3. Bounded: three attempts, then the failure is loud. */
81
+ export const GH_RETRY_DELAYS_S = [10, 30];
82
+
83
+ /** `gh … --json` with backoff on transient failures. Shared by issue-fix.mjs (one rule, one place). */
84
+ export function ghJsonWithRetry(args, { bin = GH_BIN, who = 'issue-watch' } = {}) {
61
85
  let lastErr;
62
- for (let attempt = 1; attempt <= 2; attempt++) {
63
- const res = spawnSync(GH_BIN, args, { encoding: 'utf8' });
86
+ for (let attempt = 1; attempt <= GH_RETRY_DELAYS_S.length + 1; attempt++) {
87
+ const res = spawnSync(bin, args, { encoding: 'utf8' });
64
88
  if (res.status === 0) return JSON.parse(res.stdout);
65
89
  const err = (res.stderr || res.stdout || '').trim();
66
90
  lastErr = new Error(`gh ${args.join(' ')} failed (exit ${res.status}): ${err}`);
67
- const transient = /TLS handshake|unexpected EOF|timeout|ECONNRESET|ETIMEDOUT|EAI_AGAIN|connection refused|temporarily unavailable/i.test(err);
68
- if (attempt === 1 && transient) {
69
- console.error(`issue-watch: transient gh/network failure (${err.slice(0, 90)}) — retrying once in 20s`);
70
- spawnSync('sleep', ['20']);
91
+ if (attempt <= GH_RETRY_DELAYS_S.length && isTransientGhError(err)) {
92
+ const wait = GH_RETRY_DELAYS_S[attempt - 1];
93
+ console.error(`${who}: transient gh/network failure (${err.slice(0, 90)}) — retry ${attempt} in ${wait}s`);
94
+ spawnSync('sleep', [String(wait)]);
71
95
  continue;
72
96
  }
73
97
  break;
74
98
  }
75
99
  throw lastErr;
76
100
  }
101
+ const ghJson = (args) => ghJsonWithRetry(args);
77
102
 
78
103
  /** Resolve the ntfy topic the same way the rest of the repo does: env, then the machine-wide
79
104
  * cache file, then the repo .env — see scripts/notify.sh / scripts/job-heartbeat.sh / scripts/nightly-watchdog.mjs. */
80
105
  function resolveTopic() {
81
- if (process.env.NTFY_TOPIC) return process.env.NTFY_TOPIC;
106
+ // Set-but-EMPTY is an explicit opt-out (job-heartbeat.sh's convention): tests use it, and it must never fall
107
+ // through to the repo's real .env topic (2026-10-25: a unit test read the owner's topic that way).
108
+ if (process.env.NTFY_TOPIC !== undefined) return process.env.NTFY_TOPIC || null;
82
109
  try {
83
110
  const t = fs.readFileSync(path.join(os.homedir(), '.cache', 'ruvnet-brain', 'ntfy-topic'), 'utf8').trim();
84
111
  if (t) return t;
@@ -92,11 +119,15 @@ function resolveTopic() {
92
119
  }
93
120
 
94
121
  async function pushNtfy(topic, { title, body, priority = 'urgent', tags = 'rotating_light' }) {
122
+ const base = (process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/+$/, '');
95
123
  try {
96
- const res = await fetch(`https://ntfy.sh/${topic}`, {
124
+ // Title is ASCII only: a non-ByteString header makes fetch() throw before sending (4.7.21). Issue titles never
125
+ // go in the header — they are public, untrusted text — only the body.
126
+ const res = await fetch(`${base}/${encodeURIComponent(topic)}`, {
97
127
  method: 'POST',
98
- headers: { Title: title, Priority: priority, Tags: tags },
128
+ headers: { Title: String(title).replace(/[^\x20-\x7e]+/g, ' ').trim(), Priority: priority, Tags: tags },
99
129
  body,
130
+ signal: AbortSignal.timeout(10_000),
100
131
  });
101
132
  return res.ok;
102
133
  } catch {
@@ -104,8 +135,15 @@ async function pushNtfy(topic, { title, body, priority = 'urgent', tags = 'rotat
104
135
  }
105
136
  }
106
137
 
138
+ /** A missing or unreadable state file is an empty one. That is safe since 4.7.21 (a reset state replayed 17
139
+ * "SLA breach" pages in 11 s): every breach due in one run is ONE page, and an acknowledgment already on the
140
+ * thread is recognised from the thread itself, never posted twice. */
107
141
  function loadState() {
108
- try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch { return {}; }
142
+ try {
143
+ const v = JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
144
+ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
145
+ } catch { /* fall through */ }
146
+ return {};
109
147
  }
110
148
 
111
149
  function saveState(state) {
@@ -133,28 +171,71 @@ export function judgeIssue(issue, comments, now) {
133
171
 
134
172
  export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}) {
135
173
  const issues = ghJson(['issue', 'list', '--repo', repo, '--state', 'open', '--json',
136
- 'number,title,createdAt,comments,updatedAt']);
174
+ 'number,title,createdAt,comments,updatedAt,author']);
137
175
 
138
176
  const state = loadState();
139
177
  const results = [];
140
178
  const alertsSent = [];
179
+ const degraded = [];
180
+ const dueBreaches = [];
181
+ const unreadable = []; // person issues unreadable for UNREADABLE_RUNS runs and no other way to judge them: fail once
141
182
 
142
183
  for (const issue of issues) {
143
- const detail = ghJson(['issue', 'view', String(issue.number), '--repo', repo, '--json', 'comments']);
144
- const { ageHours, ownerComment, breach } = judgeIssue(issue, detail.comments || [], now);
145
184
  const url = `https://github.com/${repo}/issues/${issue.number}`;
146
185
  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);
186
+ // One issue whose detail cannot be read does not stop the run (4.7.21). It is judged from the comments the list
187
+ // call already returned (review fix: before, it was skipped forever and never got its SLA page); with no list
188
+ // comments either it is skipped, and once it has been unreadable on UNREADABLE_RUNS runs in a row the run fails
189
+ // ONCE so the heartbeat wrapper pages. Never acknowledged from the list copy (it may be incomplete, and a second
190
+ // public acknowledgment is worse than none).
191
+ let detail; let fromList = false;
192
+ try { detail = ghJson(['issue', 'view', String(issue.number), '--repo', repo, '--json', 'comments']); }
193
+ catch (err) {
194
+ degraded.push(issue.number);
195
+ const prev = state[key] || {};
196
+ const runs = (Number(prev.unreadableRuns) || 0) + 1;
197
+ state[key] = { ...prev, unreadableRuns: runs, unreadableSince: prev.unreadableSince || new Date(now).toISOString() };
198
+ if (Array.isArray(issue.comments)) {
199
+ detail = { comments: issue.comments }; fromList = true;
200
+ console.error(`issue-watch: #${issue.number} detail unreadable — judged from the list's comments — ${err.message.slice(0, 160)}`);
201
+ } else {
202
+ console.error(`issue-watch: #${issue.number} skipped this run (${runs} in a row) — ${err.message.slice(0, 160)}`);
203
+ if (isPersonIssue(issue) && runs >= UNREADABLE_RUNS && !prev.unreadableEscalatedAt) {
204
+ unreadable.push(issue.number);
205
+ state[key].unreadableEscalatedAt = new Date(now).toISOString();
206
+ }
207
+ continue;
208
+ }
209
+ }
210
+ if (!fromList && state[key]?.unreadableRuns) { // readable again: the next unreadable streak counts from zero
211
+ state[key] = { ...state[key] };
212
+ for (const f of ['unreadableRuns', 'unreadableSince', 'unreadableEscalatedAt']) delete state[key][f];
213
+ }
214
+ const comments = detail.comments || [];
215
+ const { ageHours, ownerComment, breach } = judgeIssue(issue, comments, now);
216
+ // A person's issue pages ONCE when it first passes the SLA, never again (it used to repeat every 4 h, forever,
217
+ // which with ~14 old issues and an hourly run was most of the 10,747 notifications). The morning digest below
218
+ // carries whatever is still waiting. An existing lastAlertAt means that one page already went out.
219
+ const person = isPersonIssue(issue);
220
+ const dueForAlert = person && breach && !state[key]?.lastAlertAt;
149
221
  // First sighting → page once, immediately (rule 2 in the header). Delivery-derived like
150
222
  // lastAlertAt: state is only written when the push actually went out, so a failed push
151
223
  // retries next run instead of burying the sighting.
152
224
  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;
225
+ // Acknowledgment delivery is not page delivery. Retry each missing channel independently. Neither applies to an
226
+ // issue the owner's automation opened.
227
+ // Arrival is paged once, instantly, by .github/workflows/ntfy-alerts.yml (the `issues: opened` event). Paging it
228
+ // here too sent every new issue twice (review 2026-10-25), so this watcher owns only the SLA page and the digest.
229
+ const needsNewPage = false;
230
+ // Our acknowledgment already on the thread (a lost or reset state file) is never posted twice in public.
231
+ const alreadyAcked = comments.some((c) => c.author?.login === OWNER_LOGIN
232
+ && /^🤖 Automated acknowledgment/.test(String(c.body || '').trimStart()));
233
+ if (person && !state[key]?.ackAt && alreadyAcked) {
234
+ state[key] = { ...(state[key] || {}), ackAt: new Date(now).toISOString(), ackBaselined: true, title: issue.title, url };
235
+ }
236
+ const needsAck = person && !fromList && !state[key]?.ackAt;
156
237
 
157
- results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url });
238
+ results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url, person });
158
239
 
159
240
  if (needsNewPage || needsAck) {
160
241
  if (dryRun) {
@@ -191,33 +272,62 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
191
272
  }
192
273
  }
193
274
 
194
- if (dueForAlert) {
195
- const title = `SLA breach: issue #${issue.number}`;
196
- const body = `${issue.title}\nopen ${fmtAge(ageHours)}, no response from ${OWNER_LOGIN}\n${url}`;
197
- if (dryRun) {
198
- alertsSent.push({ number: issue.number, sent: false, reason: 'dry-run' });
199
- } else {
200
- const topic = resolveTopic();
201
- let sent = false;
202
- if (topic) sent = await pushNtfy(topic, { title, body, priority: 'urgent', tags: 'rotating_light,warning' });
203
- // DERIVED, not asserted (F5, 2026-07-18): lastAlertAt may only be written when the page was
204
- // actually DELIVERED (sent===true). The old line stamped it unconditionally, so a breach whose
205
- // push failed (ntfy down, no topic) was suppressed for the whole 4h cooldown — the alert ledger
206
- // asserted a delivery it never verified. A failed attempt records itself as failed and the next
207
- // hourly run retries; the ledger can no longer claim a page that didn't happen.
208
- // Spread-merge, never overwrite: the record may already carry firstSeenAt/newAlertAt from
209
- // the first-sighting page above — clobbering them would re-page "new" forever (the exact
210
- // state-erasure class that broke issue-fix's comment dedup, 2026-07-24).
211
- if (sent) state[key] = { ...(state[key] || {}), lastAlertAt: new Date(now).toISOString(), title: issue.title, url };
212
- else state[key] = { ...(state[key] || {}), lastAttemptAt: new Date(now).toISOString(), sent: false, title: issue.title, url };
213
- alertsSent.push({ number: issue.number, sent, kind: 'sla-breach', reason: topic ? null : 'no ntfy topic configured' });
275
+ if (dueForAlert) dueBreaches.push({ issue, ageHours, url, key });
276
+ }
277
+
278
+ // ONE page per run for every breach that is due (4.7.21), oldest first, body bounded to 8 lines. The title keeps
279
+ // the "SLA breach: issue #N" shape scripts/ntfy-inbox.mjs classifies. DERIVED, not asserted (F5, 2026-07-18):
280
+ // lastAlertAt is written only for a delivered page; a failed page retries next run as the same single page.
281
+ // Spread-merge, never overwrite (2026-07-24: clobbering firstSeenAt/newAlertAt re-paged forever).
282
+ let breachPaged = new Set();
283
+ if (dueBreaches.length) {
284
+ dueBreaches.sort((a, b) => b.ageHours - a.ageHours);
285
+ const [first, ...rest] = dueBreaches;
286
+ const title = rest.length
287
+ ? `SLA breach: issue #${first.issue.number} (+${rest.length} more waiting > ${SLA_HOURS}h)`
288
+ : `SLA breach: issue #${first.issue.number}`;
289
+ const lines = dueBreaches.slice(0, 8).map((b) => `#${b.issue.number} (${fmtAge(b.ageHours)}) ${String(b.issue.title).slice(0, 70)}`);
290
+ // Past 8 lines the rest are still NAMED ("…and K more: #a #b"): scripts/ntfy-inbox.mjs files one alert per issue,
291
+ // so the session banner counts the breach truly (a 17-issue page used to count as 1).
292
+ const body = `${dueBreaches.length} contributor issue(s) past ${SLA_HOURS}h with no response from ${OWNER_LOGIN}:\n${lines.join('\n')}`
293
+ + (dueBreaches.length > 8 ? `\n…and ${dueBreaches.length - 8} more: ${dueBreaches.slice(8).map((b) => `#${b.issue.number}`).join(' ')}` : '')
294
+ + `\nhttps://github.com/${repo}/issues`;
295
+ if (dryRun) {
296
+ for (const b of dueBreaches) alertsSent.push({ number: b.issue.number, sent: false, kind: 'sla-breach', reason: 'dry-run' });
297
+ } else {
298
+ const topic = resolveTopic();
299
+ const sent = topic ? await pushNtfy(topic, { title, body, priority: 'urgent', tags: 'rotating_light,warning' }) : false;
300
+ for (const b of dueBreaches) {
301
+ if (sent) state[b.key] = { ...(state[b.key] || {}), lastAlertAt: new Date(now).toISOString(), title: b.issue.title, url: b.url };
302
+ else state[b.key] = { ...(state[b.key] || {}), lastAttemptAt: new Date(now).toISOString(), sent: false, title: b.issue.title, url: b.url };
303
+ alertsSent.push({ number: b.issue.number, sent, kind: 'sla-breach', reason: topic ? null : 'no ntfy topic configured' });
214
304
  }
305
+ if (sent) breachPaged = new Set(dueBreaches.map((b) => b.issue.number));
306
+ }
307
+ }
308
+
309
+ // The morning digest: one notification naming every person's issue still unanswered, oldest first.
310
+ const waiting = results.filter((r) => r.person && r.breach).sort((a, b) => b.ageHours - a.ageHours);
311
+ const digest = digestDue(state._digest?.day, now);
312
+ // Everything still waiting was named in this run's breach page: that page IS today's summary.
313
+ if (waiting.length && digest.due && waiting.every((r) => breachPaged.has(r.number))) {
314
+ state._digest = { day: digest.day, sentAt: new Date(now).toISOString(), count: waiting.length, viaBreachPage: true };
315
+ } else if (waiting.length && digest.due) {
316
+ const title = `Waiting on a reply: ${waiting.length} contributor issue${waiting.length === 1 ? '' : 's'}`;
317
+ const body = waiting.slice(0, 8).map((r) => `#${r.number} (${fmtAge(r.ageHours)}) ${r.title.slice(0, 70)}`).join('\n')
318
+ + (waiting.length > 8 ? `\n…and ${waiting.length - 8} more` : '') + `\nhttps://github.com/${repo}/issues`;
319
+ if (dryRun) alertsSent.push({ kind: 'digest', sent: false, reason: 'dry-run', count: waiting.length });
320
+ else {
321
+ const topic = resolveTopic();
322
+ const sent = topic ? await pushNtfy(topic, { title, body, priority: 'default', tags: 'clipboard' }) : false;
323
+ if (sent) state._digest = { day: digest.day, sentAt: new Date(now).toISOString(), count: waiting.length };
324
+ alertsSent.push({ kind: 'digest', sent, count: waiting.length, reason: topic ? null : 'no ntfy topic configured' });
215
325
  }
216
326
  }
217
327
 
218
328
  if (!dryRun) saveState(state);
219
329
 
220
- return { results, alertsSent, checkedAt: new Date(now).toISOString() };
330
+ return { results, alertsSent, degraded, unreadable, checkedAt: new Date(now).toISOString() };
221
331
  }
222
332
 
223
333
  /**
@@ -353,8 +463,10 @@ async function main() {
353
463
  if (r.dueForAlert) {
354
464
  const alert = output.alertsSent.find((a) => a.number === r.number && a.kind === 'sla-breach');
355
465
  console.log(` ${dryRun ? '[DRY-RUN] would push ntfy alert' : alert?.sent ? 'ntfy alert pushed' : `ntfy alert NOT sent (${alert?.reason})`}`);
466
+ } else if (r.breach && !r.person) {
467
+ console.log(' opened by the owner\'s own automation — never paged');
356
468
  } else if (r.breach) {
357
- console.log(` already alerted within the last ${SLA_HOURS}h — not repeating`);
469
+ console.log(' already paged once — listed in the morning summary until answered');
358
470
  }
359
471
  console.log('');
360
472
  }
@@ -389,7 +501,12 @@ async function main() {
389
501
  // heartbeat records the failure and the wrapper's own channel escalates — that duplicate-looking
390
502
  // page IS the correct behavior when the primary page provably never left the building.
391
503
  const undeliveredAlert = !dryRun && (output.alertsSent || []).some((a) => !a.sent && a.reason !== 'dry-run');
392
- process.exit(undeliveredAlert ? 1 : 0);
504
+ // Skipping one unreadable issue is a degraded run, not a failed one; reading NONE of them is a failure.
505
+ const allDegraded = output.degraded.length > 0 && output.results.length === 0;
506
+ if (output.degraded.length) console.error(`issue-watch: ${output.degraded.length} issue(s) not fully readable this run: #${output.degraded.join(', #')}`);
507
+ const stuck = !dryRun && (output.unreadable || []).length > 0;
508
+ if (stuck) console.error(`issue-watch: FAILING this run once — a person's issue has been unreadable ${UNREADABLE_RUNS} runs in a row and cannot be judged: #${output.unreadable.join(', #')}`);
509
+ process.exit(undeliveredAlert || allDegraded || stuck ? 1 : 0);
393
510
  }
394
511
 
395
- if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) await main();
512
+ if (isDirectInvocation(import.meta.url)) await main();
@@ -78,22 +78,41 @@ RUN_ID="$$-$START_EPOCH"
78
78
  # SIGKILLed, the watchdog's started-and-never-finished detection had nothing left to see.
79
79
  PREV_HB=""
80
80
  [ -f "$HB" ] && PREV_HB="$(cat "$HB" 2>/dev/null)"
81
+ # The failing streak and the last page time ride in EVERY receipt, including "running" (4.7.21): a run that is
82
+ # SIGKILLed mid-streak must not erase them, or the next failure would page as a brand-new streak.
83
+ field_str() { printf '%s' "$PREV_HB" | sed -n "s/.*\"$1\":\"\([^\"]*\)\".*/\1/p" | head -1; }
84
+ field_num() { printf '%s' "$PREV_HB" | sed -n "s/.*\"$1\":\([0-9][0-9]*\).*/\1/p" | head -1; }
85
+ prev_state="$(field_str state)"
86
+ prev_since="$(field_str failing_since)"
87
+ prev_page_epoch="$(field_num last_page_epoch)"
88
+ prev_page_at="$(field_str last_page_at)"
89
+ prev_paged="$(printf '%s' "$PREV_HB" | sed -nE 's/.*"paged":("[a-z]*"|true|false).*/\1/p' | head -1)"
90
+ CARRY=""
91
+ [ -n "$prev_page_epoch" ] && CARRY="$CARRY,\"last_page_epoch\":$prev_page_epoch"
92
+ [ -n "$prev_page_at" ] && CARRY="$CARRY,\"last_page_at\":\"$prev_page_at\""
93
+ case "$prev_state" in failed|killed|running)
94
+ [ -n "$prev_since" ] && CARRY="$CARRY,\"failing_since\":\"$prev_since\""
95
+ [ -n "$prev_since" ] && [ -n "$prev_paged" ] && CARRY="$CARRY,\"paged\":$prev_paged" ;;
96
+ esac
81
97
 
82
98
  # Start receipt. If the job vanishes without ever writing an end receipt, THIS is the evidence that
83
99
  # it started and never finished — a state the watchdog reports as FAILING, not as silence.
84
- write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"state\":\"running\",\"pid\":$$,\"run_id\":\"$RUN_ID\",\"command\":\"$(echo "$@" | sed 's/"/\\"/g')\"}"
100
+ write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"state\":\"running\",\"pid\":$$,\"run_id\":\"$RUN_ID\",\"command\":\"$(printf '%s %s' "$1" "${2:-}" | tr -d '"\\\n\r' | cut -c1-200)\"$CARRY}"
85
101
 
86
- notify() { # notify <title> <body> <priority>
102
+ notify() { # notify <title> <body> <priority> — 0 delivered · 1 not delivered · 2 paging disabled (opt-out)
87
103
  # NTFY_TOPIC set-but-EMPTY is an explicit opt-out (2026-07-18): unit tests wrap this script around
88
104
  # deliberately-failing fixture commands (t-fail, t.bad, exit 7), and the topic-file fallback below
89
105
  # meant every test run PAGED Stuart's real phone — 16+ fixture pages in one morning, indistinguishable
90
106
  # from real job failures. Tests set NTFY_TOPIC="" and stay silent; production (unset) still falls
91
- # through to the topic file.
92
- if [ "${NTFY_TOPIC+set}" = "set" ] && [ -z "$NTFY_TOPIC" ]; then return 0; fi
107
+ # through to the topic file. An opt-out is NOT a delivered page: it returns 2 and the receipt records
108
+ # "paged":"disabled", never true (4.7.21).
109
+ if [ "${NTFY_TOPIC+set}" = "set" ] && [ -z "$NTFY_TOPIC" ]; then return 2; fi
93
110
  topic="${NTFY_TOPIC:-}"
94
111
  [ -z "$topic" ] && [ -f "$HOME/.cache/ruvnet-brain/ntfy-topic" ] && topic="$(cat "$HOME/.cache/ruvnet-brain/ntfy-topic")"
95
- [ -z "$topic" ] && return 0
96
- curl -sS -m 10 -H "Title: $1" -H "Priority: $3" -H "Tags: rotating_light" -d "$2" "https://ntfy.sh/$topic" >/dev/null 2>&1 || true
112
+ [ -z "$topic" ] && return 1
113
+ # --fail: an HTTP error is a failed delivery. The caller records whether the page landed, so a page that
114
+ # never arrived is retried on the next run instead of muting the whole failing streak (review 2026-10-25).
115
+ curl -sS --fail -m 10 -H "Title: $1" -H "Priority: $3" -H "Tags: ${4:-rotating_light}" -d "$2" "${NTFY_URL:-https://ntfy.sh}/$topic" >/dev/null 2>&1
97
116
  }
98
117
 
99
118
  # The run_id currently on disk for this label, or empty if the receipt is missing/unreadable/has
@@ -104,6 +123,19 @@ current_run_id() {
104
123
 
105
124
  finish() {
106
125
  code=${FORCED_CODE:-$?}
126
+ # Stop the run-time timer FIRST, on every exit path (skip, stale writer, normal), so it can never outlive
127
+ # this wrapper (Opus review 2026-10-25).
128
+ [ -n "${TIMER:-}" ] && kill "$TIMER" 2>/dev/null
129
+ timed_out=0
130
+ if [ -f "$HB.timeout-$RUN_ID" ]; then
131
+ # Anything the overrunning job left behind (started during its TERM handling, say) goes now.
132
+ timed_out=1; stop_job
133
+ fi
134
+ # Always, on every exit path: the timer may have written the marker after the check above (4.7.21).
135
+ rm -f "$HB.timeout-$RUN_ID"
136
+ # An overrun is a failure however the job chose to exit on TERM, including 0 and 75 (the skip code), so this
137
+ # comes before the skip branch (GPT-6.1 review round 2).
138
+ [ "$timed_out" -eq 1 ] && [ "$code" -le 128 ] && code=143
107
139
  ended="$(ts)"
108
140
  dur=$(( $(date +%s) - START_EPOCH ))
109
141
  # Exit 75 = SKIP (lock held by a live run). Restore the pre-fire receipt so the live run's evidence
@@ -132,15 +164,52 @@ finish() {
132
164
  elif [ "$code" -gt 128 ]; then
133
165
  sig=$((code - 128))
134
166
  state="killed"; extra=",\"signal\":$sig"
167
+ [ "$timed_out" -eq 1 ] && extra="$extra,\"timed_out_after_sec\":$MAX_SECONDS"
135
168
  else
136
169
  state="failed"; extra=""
137
170
  fi
138
- write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"ended_at\":\"$ended\",\"state\":\"$state\",\"exit_code\":$code,\"duration_sec\":$dur,\"run_id\":\"$RUN_ID\"$extra}"
139
- # Gong on failure, immediately — not at the next watchdog sweep. A failing nightly should reach the
140
- # phone while it is still tonight's problem.
171
+ # PAGE ON THE TRANSITION, NOT ON EVERY RUN (owner, 2026-10-25: 10,747 notifications on one topic). A job that
172
+ # starts failing pages once, urgently, while it is still tonight's problem; while it KEEPS failing it stays
173
+ # quiet (failing_since carries the first failure; the nightly watchdog's digest still lists it); the run
174
+ # that passes again sends one low-priority "recovered" note — only if that streak was actually paged.
175
+ # FLAP DAMPING (4.7.21): a NEW streak within 6 h of the last delivered page is recorded "paged":"suppressed"
176
+ # instead of paging (4 fail/ok cycles were 8 notifications). A suppressed streak still failing after 6 h pages.
177
+ # A prior "running" receipt that carries failing_since (a SIGKILLed run) continues its streak.
178
+ was_failing=0
179
+ case "$prev_state" in failed|killed) was_failing=1 ;; running) [ -n "$prev_since" ] && was_failing=1 ;; esac
180
+ # A failing streak counts as already paged only if that page was PROVEN delivered ("paged":true). false (retry),
181
+ # "suppressed", "disabled", or a receipt from before this field existed are paged (again) when allowed.
182
+ prev_unpaged=1; [ "$prev_paged" = "true" ] && prev_unpaged=0
183
+ QUIET_SECONDS=21600
184
+ now_epoch="$(date +%s)"
185
+ page_epoch="$prev_page_epoch"; page_at="$prev_page_at"
186
+ since=""
141
187
  if [ "$code" -ne 0 ]; then
142
- notify "🔴 SCHEDULED JOB FAILED: $LABEL" "exit $code after ${dur}s — see the job's log. Receipt: $HB" "urgent"
188
+ if [ "$was_failing" -eq 1 ] && [ -n "$prev_since" ]; then since="$prev_since"; else since="$STARTED"; fi
189
+ extra="$extra,\"failing_since\":\"$since\""
143
190
  fi
191
+ if [ "$code" -ne 0 ] && { [ "$was_failing" -eq 0 ] || [ "$prev_unpaged" -eq 1 ]; }; then
192
+ if [ -n "$prev_page_epoch" ] && [ $((now_epoch - prev_page_epoch)) -lt "$QUIET_SECONDS" ] \
193
+ && ! { [ "$was_failing" -eq 1 ] && [ "$prev_paged" = "false" ]; }; then
194
+ # Inside the quiet window. (A streak whose own page simply failed to deliver is retried, not damped.)
195
+ extra="$extra,\"paged\":\"suppressed\""
196
+ else
197
+ reason="exit $code after ${dur}s"; [ "$timed_out" -eq 1 ] && reason="stopped after running past its ${MAX_SECONDS}s limit"
198
+ notify "SCHEDULED JOB FAILED: $LABEL" "$reason — see the job's log. Receipt: $HB. Further failures stay quiet until it recovers." "urgent"
199
+ rc=$?
200
+ if [ "$rc" -eq 0 ]; then
201
+ extra="$extra,\"paged\":true"; page_epoch="$now_epoch"; page_at="$(ts)"
202
+ elif [ "$rc" -eq 2 ]; then extra="$extra,\"paged\":\"disabled\""
203
+ else extra="$extra,\"paged\":false"; fi
204
+ fi
205
+ elif [ "$code" -ne 0 ]; then
206
+ extra="$extra,\"paged\":true"
207
+ elif [ "$was_failing" -eq 1 ] && [ "$prev_paged" = "true" ]; then
208
+ notify "Recovered: $LABEL" "passed again after failing since ${prev_since:-an earlier run}" "low" "white_check_mark" || true
209
+ fi
210
+ [ -n "$page_epoch" ] && extra="$extra,\"last_page_epoch\":$page_epoch"
211
+ [ -n "$page_at" ] && extra="$extra,\"last_page_at\":\"$page_at\""
212
+ write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"ended_at\":\"$ended\",\"state\":\"$state\",\"exit_code\":$code,\"duration_sec\":$dur,\"run_id\":\"$RUN_ID\"$extra}"
144
213
  exit "$code"
145
214
  }
146
215
  trap finish EXIT
@@ -148,8 +217,46 @@ trap finish EXIT
148
217
  # The exit codes (143 = 128+15 TERM, 130 = 128+2 INT) intentionally reuse the same 128+signal
149
218
  # convention `wait` uses for a directly-signalled child, so finish() classifies BOTH as "killed"
150
219
  # with the right signal number, whether the signal reached the child directly or via this wrapper.
151
- trap 'FORCED_CODE=143; kill -TERM "$CHILD" 2>/dev/null; exit 143' TERM
152
- trap 'FORCED_CODE=130; kill -TERM "$CHILD" 2>/dev/null; exit 130' INT
220
+ # The job stays in this wrapper's process group, so launchd's own cleanup of a dead job (it kills the job's whole
221
+ # group) still covers everything, even if this wrapper is SIGKILLed (Opus review round 3: a separate group escaped it).
222
+ # job_pids: every process the job owns. Under launchd this wrapper leads its group, so that is every group member
223
+ # except this wrapper, its timer, and this wrapper's own helper processes (ps/awk/command substitutions) — which
224
+ # catches processes the job started late or that were reparented. Run any other way (a terminal, a test harness),
225
+ # it falls back to the job's current process tree.
226
+ OWN_GROUP=0; [ "$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')" = "$$" ] && OWN_GROUP=1
227
+ job_pids() {
228
+ if [ "$OWN_GROUP" = 1 ]; then
229
+ ps -A -o pid= -o ppid= -o pgid= 2>/dev/null | awk -v me=$$ -v t="${TIMER:-0}" -v c="${CHILD:-0}" '
230
+ { pid[NR]=$1; pp[NR]=$2; pg[NR]=$3 }
231
+ END { helper[t]=1
232
+ for (i=1;i<=NR;i++) if (pp[i]==me && pid[i]!=c) helper[pid[i]]=1
233
+ for (i=1;i<=NR;i++) if (pg[i]==me && pid[i]!=me && !(pid[i] in helper) && !(pp[i] in helper)) print pid[i] }'
234
+ else
235
+ tree_of "$CHILD"
236
+ fi
237
+ }
238
+ tree_of() { kill -0 "$1" 2>/dev/null && printf '%s ' "$1"
239
+ for k in $(ps -A -o pid= -o ppid= 2>/dev/null | awk -v p="$1" '$2==p {print $1}'); do tree_of "$k"; done; }
240
+ # stop_job: TERM everything the job owns, give it up to 10 s (inside launchd's 20 s exit allowance), then KILL.
241
+ # Every pid seen during the stop is remembered, so a descendant that outlives its parent (and so drops out of the
242
+ # process tree in the fallback mode) still gets the final KILL. The full guarantee, including processes started
243
+ # later, holds when this wrapper leads its group, as under launchd.
244
+ stop_job() {
245
+ seen="$(job_pids)"; [ -n "$seen" ] && kill -TERM $seen 2>/dev/null
246
+ i=0; while [ "$i" -lt 10 ]; do
247
+ now_pids="$(job_pids)"; seen="$seen $now_pids"
248
+ alive=""; for p in $seen; do kill -0 "$p" 2>/dev/null && alive="$alive $p"; done
249
+ [ -z "$alive" ] && return 0
250
+ sleep 1; i=$((i + 1))
251
+ done
252
+ for p in $seen $(job_pids); do kill -KILL "$p" 2>/dev/null; done
253
+ return 0
254
+ }
255
+ trap 'FORCED_CODE=143; [ -n "${TIMER:-}" ] && kill "$TIMER" 2>/dev/null; stop_job; exit 143' TERM
256
+ trap 'FORCED_CODE=130; [ -n "${TIMER:-}" ] && kill "$TIMER" 2>/dev/null; stop_job; exit 130' INT
257
+ # The timer never kills anything itself: it marks the overrun and signals this wrapper, which stops the job.
258
+ OVERRUN=0
259
+ trap 'OVERRUN=1' USR1
153
260
 
154
261
  # Run the job in the BACKGROUND and `wait` for it — do NOT run it in the foreground.
155
262
  # Break-test finding (2026-07-13): a POSIX shell blocked on a FOREGROUND child does not run its trap
@@ -160,4 +267,29 @@ trap 'FORCED_CODE=130; kill -TERM "$CHILD" 2>/dev/null; exit 130' INT
160
267
  # ("started and never finished"). Trap for catchable deaths, watchdog for uncatchable ones.
161
268
  "$@" &
162
269
  CHILD=$!
163
- wait "$CHILD"
270
+ # RUN-TIME LIMIT (2026-10-25: five jobs finished their work and then never exited, each holding its own
271
+ # next run off for 7-9 hours). After JOB_MAX_SECONDS (default 2 h; a plist may set less) the job is
272
+ # stopped: TERM, then KILL 10 s later. It is recorded as killed and pages once like any other failure.
273
+ MAX_SECONDS="${JOB_MAX_SECONDS:-7200}"
274
+ WRAPPER_PID=$$
275
+ case "$MAX_SECONDS" in ''|*[!0-9]*) MAX_SECONDS=7200 ;; esac
276
+ [ "$MAX_SECONDS" -lt 2 ] && MAX_SECONDS=2
277
+ (
278
+ # Short sleeps, not one long one: killing this shell then leaves at most a 2 s sleep behind, never a 2 h one
279
+ # holding the job's output open (review round 2).
280
+ # A wrapper that is gone (SIGKILLed: launchd then cleans the group itself) ends the timer at the next tick.
281
+ n=0; while [ "$n" -lt "$MAX_SECONDS" ]; do sleep 2; n=$((n + 2)); kill -0 "$WRAPPER_PID" 2>/dev/null || exit 0; done
282
+ # Only for OUR child: still alive and still parented by this wrapper. If the PID was reused, do nothing.
283
+ [ "$(ps -o ppid= -p "$CHILD" 2>/dev/null | tr -d ' ')" = "$WRAPPER_PID" ] || exit 0
284
+ : > "$HB.timeout-$RUN_ID"
285
+ kill -USR1 "$WRAPPER_PID" 2>/dev/null
286
+ ) </dev/null >/dev/null 2>&1 &
287
+ TIMER=$!
288
+ # `wait` returns early when USR1 arrives; stop the job, then wait again for its real exit status.
289
+ while :; do
290
+ if [ "$OVERRUN" = 1 ]; then OVERRUN=0; stop_job; fi
291
+ wait "$CHILD"; RC=$?
292
+ if [ "$OVERRUN" = 1 ]; then OVERRUN=0; stop_job; continue; fi
293
+ break
294
+ done
295
+ exit "$RC"
@@ -53,10 +53,14 @@ async function probe(p, key) {
53
53
  }
54
54
  }
55
55
 
56
+ /** true only when notify.sh reports a delivered page (exit 0). A failed or opted-out page (NTFY_TOPIC="") is not
57
+ * delivered, so the key's previous state is kept and the transition fires again next run (4.7.21). Titles are
58
+ * plain ASCII; the emoji rides in Tags. */
56
59
  function notify(title, message, priority, tags) {
57
60
  try {
58
61
  execFileSync('sh', [path.join(REPO, 'scripts', 'notify.sh'), title, message, priority, tags], { cwd: REPO, stdio: 'ignore', timeout: 15000 });
59
- } catch { /* notification failure must not break the canary */ }
62
+ return true;
63
+ } catch { return false; /* notification failure must not break the canary */ }
60
64
  }
61
65
 
62
66
  const prev = (() => { try { return JSON.parse(fs.readFileSync(STATE, 'utf8')); } catch { return {}; } })();
@@ -73,13 +77,15 @@ for (const p of PROBES) {
73
77
  if (status === 'DEAD') {
74
78
  anyDead = true;
75
79
  if (NOTIFY && prev[p.env]?.status !== 'DEAD') {
76
- notify(`🚨 ${p.provider} API key is DEAD`,
80
+ const sent = notify(`${p.provider} API key is DEAD`,
77
81
  `${p.env} was rejected (401/403) by ${p.provider} just now. Every script using it is silently failing. `
78
82
  + `Rotate it: edit ~/Code/openclaw-stack/secrets.env, then run secrets-sync.sh seal.`,
79
83
  'urgent', 'rotating_light,key');
84
+ if (!sent) now[p.env] = prev[p.env] ?? { status: 'unpaged', ts: now[p.env].ts };
80
85
  }
81
86
  } else if (status === 'alive' && NOTIFY && prev[p.env]?.status === 'DEAD') {
82
- notify(`✅ ${p.provider} API key recovered`, `${p.env} is working again.`, 'default', 'white_check_mark,key');
87
+ const sent = notify(`${p.provider} API key recovered`, `${p.env} is working again.`, 'default', 'white_check_mark,key');
88
+ if (!sent) now[p.env] = prev[p.env];
83
89
  }
84
90
  }
85
91
 
@@ -43,6 +43,7 @@
43
43
  import fs from 'node:fs';
44
44
  import os from 'node:os';
45
45
  import path from 'node:path';
46
+ import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
46
47
 
47
48
  const HOME = os.homedir();
48
49
 
@@ -206,7 +207,7 @@ export function humanMs(ms) {
206
207
  }
207
208
 
208
209
  // ── CLI ────────────────────────────────────────────────────────────────────────────────────────────
209
- if (import.meta.url === `file://${process.argv[1]}`) {
210
+ if (isDirectInvocation(import.meta.url)) {
210
211
  const { loadOutcomes } = await import('./advocacy-outcomes.mjs');
211
212
  const rows = computeLatencies({ outcomes: loadOutcomes() });
212
213
  const s = summarize(rows);