ruvnet-brain 4.7.20 → 4.7.22

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 (260) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +618 -104
  3. package/console/activity.js +122 -38
  4. package/kb/download-retry.mjs +93 -21
  5. package/kb/forge-guard-injection.mjs +277 -0
  6. package/kb/forge-update.mjs +166 -38
  7. package/kb/lifecycle-evidence-retention.mjs +151 -14
  8. package/kb/model-requirements.mjs +4 -1
  9. package/kb/refresh-run.mjs +175 -27
  10. package/kb/verify-citation.mjs +15 -2
  11. package/package.json +3 -1
  12. package/plugin/.claude-plugin/plugin.json +1 -1
  13. package/plugin/.codex-plugin/plugin.json +1 -1
  14. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  15. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  16. package/plugin/scripts/advisory-contract.mjs +21 -0
  17. package/plugin/scripts/advocacy-route.mjs +5 -4
  18. package/plugin/scripts/agentdb-recall-process.mjs +115 -5
  19. package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
  20. package/plugin/scripts/agentdb-recall.mjs +49 -17
  21. package/plugin/scripts/automatic-update.mjs +9 -1
  22. package/plugin/scripts/brain-confirmation.mjs +2 -1
  23. package/plugin/scripts/brain-footprint.mjs +72 -11
  24. package/plugin/scripts/capability-registry.mjs +1 -1
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  26. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  27. package/plugin/scripts/completion-claim-evidence.mjs +241 -23
  28. package/plugin/scripts/continuation-gate.mjs +75 -11
  29. package/plugin/scripts/decision-gate.mjs +13 -63
  30. package/plugin/scripts/decision-outcomes.mjs +36 -0
  31. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  32. package/plugin/scripts/decision-record.mjs +8 -3
  33. package/plugin/scripts/degradation-watch.mjs +2 -1
  34. package/plugin/scripts/developer-update-lock.mjs +172 -33
  35. package/plugin/scripts/developer-update-owner.mjs +8 -5
  36. package/plugin/scripts/developer-update-policy.mjs +4 -1
  37. package/plugin/scripts/developer-update.mjs +155 -53
  38. package/plugin/scripts/doc-currency.mjs +2 -16
  39. package/plugin/scripts/duplicate-gate.mjs +2 -1
  40. package/plugin/scripts/entrypoint.mjs +34 -0
  41. package/plugin/scripts/footprint-io.mjs +135 -8
  42. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  43. package/plugin/scripts/ground-ruvnet.sh +6 -3
  44. package/plugin/scripts/grounding-answer.mjs +2 -1
  45. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  46. package/plugin/scripts/grounding-substance.mjs +2 -1
  47. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  48. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  49. package/plugin/scripts/hook-context-budget.mjs +2 -1
  50. package/plugin/scripts/hook-input.mjs +2 -2
  51. package/plugin/scripts/hook-shim.mjs +9 -2
  52. package/plugin/scripts/host-update.mjs +29 -5
  53. package/plugin/scripts/identifier-preflight.mjs +2 -1
  54. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  55. package/plugin/scripts/learn-capture.mjs +2 -1
  56. package/plugin/scripts/learning-store.mjs +62 -16
  57. package/plugin/scripts/lesson-bridge.mjs +2 -2
  58. package/plugin/scripts/lesson-gate.mjs +7 -39
  59. package/plugin/scripts/lesson-promote.mjs +83 -22
  60. package/plugin/scripts/lesson-scope.mjs +51 -0
  61. package/plugin/scripts/lesson-store.mjs +1 -1
  62. package/plugin/scripts/md-stamp.mjs +2 -1
  63. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  64. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  65. package/plugin/scripts/package-recommender.mjs +2 -2
  66. package/plugin/scripts/project-store-resolver.mjs +19 -0
  67. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  68. package/plugin/scripts/ruflo-bin.mjs +14 -6
  69. package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
  70. package/plugin/scripts/session-snapshot-hook.mjs +25 -5
  71. package/plugin/scripts/session-start-core.mjs +7 -16
  72. package/plugin/scripts/session-start-health.mjs +98 -10
  73. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  74. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  75. package/plugin/scripts/signal-watch.mjs +2 -1
  76. package/plugin/scripts/spend-guard.mjs +2 -1
  77. package/plugin/scripts/substitution-wall.mjs +2 -1
  78. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  79. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  80. package/plugin/scripts/update-apply.mjs +186 -15
  81. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  82. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  83. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  84. package/scripts/adr-072-completion.mjs +2 -1
  85. package/scripts/agentdb-context.mjs +2 -1
  86. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  87. package/scripts/approved-runtime.mjs +2 -1
  88. package/scripts/architecture-review-lock.mjs +2 -1
  89. package/scripts/ascii-drift.mjs +2 -1
  90. package/scripts/brain-latency-50.mjs +2 -1
  91. package/scripts/brain-novice-50.mjs +2 -16
  92. package/scripts/brain-score.mjs +13 -2
  93. package/scripts/build-bundle.mjs +2 -1
  94. package/scripts/build-concepts.mjs +2 -1
  95. package/scripts/candidate-ci-receipt.mjs +2 -1
  96. package/scripts/candidate-host-evidence.mjs +2 -1
  97. package/scripts/card-from-source.mjs +2 -1
  98. package/scripts/ci/warm-brain-models.mjs +2 -1
  99. package/scripts/claims-verify.mjs +7 -3
  100. package/scripts/claude-controlled-terminal.mjs +2 -1
  101. package/scripts/claude-terminal-mod.mjs +2 -1
  102. package/scripts/code-release-corpus.mjs +2 -1
  103. package/scripts/codex-boot-surface-proof.mjs +208 -0
  104. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  105. package/scripts/codex-hook-trust.mjs +40 -1
  106. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  107. package/scripts/codex-host-execution-proof.mjs +2 -1
  108. package/scripts/completion-claim-replay.mjs +2 -1
  109. package/scripts/console-engine.mjs +8 -3
  110. package/scripts/corpus-canary.mjs +2 -1
  111. package/scripts/corpus-candidate.mjs +2 -2
  112. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  113. package/scripts/corpus-dispatch-decision.mjs +2 -1
  114. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  115. package/scripts/corpus-next-seed.mjs +2 -1
  116. package/scripts/corpus-qa.mjs +2 -1
  117. package/scripts/corpus-reconcile.mjs +2 -2
  118. package/scripts/corpus-watchdog.mjs +2 -1
  119. package/scripts/count-chunks.mjs +2 -1
  120. package/scripts/customer-state-matrix.mjs +2 -1
  121. package/scripts/derive-passage-content-map.mjs +2 -1
  122. package/scripts/development-push-check.mjs +2 -1
  123. package/scripts/dispatch-receipt.mjs +2 -1
  124. package/scripts/doc-currency.mjs +2 -1
  125. package/scripts/dream-issue-gate.mjs +2 -1
  126. package/scripts/dual-host-deliberation.mjs +2 -16
  127. package/scripts/dual-host-suggest.mjs +2 -16
  128. package/scripts/eval-brain.mjs +7 -3
  129. package/scripts/execution-policy.mjs +2 -1
  130. package/scripts/execution-preflight.mjs +2 -1
  131. package/scripts/falsify.mjs +2 -1
  132. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  133. package/scripts/fix-workstream.mjs +2 -1
  134. package/scripts/full-hints.mjs +2 -1
  135. package/scripts/full-suite-gate.mjs +2 -1
  136. package/scripts/grok-host.mjs +68 -0
  137. package/scripts/hook-qualify-core.mjs +3 -1
  138. package/scripts/hook-qualify.mjs +2 -1
  139. package/scripts/hook-retirement-check.mjs +2 -1
  140. package/scripts/host-registry.mjs +2 -1
  141. package/scripts/independent-review-receipt.mjs +2 -1
  142. package/scripts/ingest-new-repos.mjs +2 -1
  143. package/scripts/ingest-repo.mjs +12 -2
  144. package/scripts/integration-evidence.mjs +2 -1
  145. package/scripts/issue-fix.mjs +7 -24
  146. package/scripts/issue-watch.mjs +116 -40
  147. package/scripts/job-heartbeat.sh +54 -19
  148. package/scripts/key-canary.mjs +9 -3
  149. package/scripts/latency-to-surface.mjs +2 -1
  150. package/scripts/learning-replay-cli.mjs +97 -19
  151. package/scripts/learning-replay-execution.mjs +2 -0
  152. package/scripts/learning-replay-fixture.mjs +3 -0
  153. package/scripts/learning-replay-proof.mjs +14 -13
  154. package/scripts/learning-replay.mjs +3 -3
  155. package/scripts/learnings.mjs +2 -1
  156. package/scripts/lesson-ratify.mjs +88 -40
  157. package/scripts/loop-checkpoint.mjs +2 -1
  158. package/scripts/metaharness-receipts.mjs +2 -1
  159. package/scripts/model-catalog.mjs +2 -1
  160. package/scripts/model-currency-evidence.mjs +6 -4
  161. package/scripts/model-currency.mjs +33 -3
  162. package/scripts/model-native-catalog.mjs +2 -1
  163. package/scripts/model-router-agent-hook.mjs +2 -1
  164. package/scripts/model-router-dispatch.mjs +2 -1
  165. package/scripts/model-router-engine.mjs +2 -1
  166. package/scripts/model-router-outcome.mjs +2 -1
  167. package/scripts/model-router-setup.mjs +2 -1
  168. package/scripts/model-routing-eval.mjs +2 -1
  169. package/scripts/model-routing-gateway.mjs +2 -1
  170. package/scripts/model-routing-launchers.mjs +4 -1
  171. package/scripts/model-terminal-gateway.mjs +2 -1
  172. package/scripts/model-terminal-launchers.mjs +2 -1
  173. package/scripts/model-weekly-analyst.mjs +2 -1
  174. package/scripts/model-weekly-cycle.mjs +2 -1
  175. package/scripts/model-weekly-qualification.mjs +2 -1
  176. package/scripts/nightly-gists.sh +26 -20
  177. package/scripts/nightly-two-run-proof.mjs +2 -1
  178. package/scripts/nightly-watchdog.mjs +158 -47
  179. package/scripts/no-silent-substitution.mjs +2 -1
  180. package/scripts/notify.sh +11 -6
  181. package/scripts/ntfy-inbox.mjs +41 -18
  182. package/scripts/onboarding-console.mjs +123 -23
  183. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  184. package/scripts/oracle/abstain-trace.mjs +2 -1
  185. package/scripts/oracle/doc2query-generate.mjs +2 -1
  186. package/scripts/oracle/doc2query-reach.mjs +2 -1
  187. package/scripts/oracle/judge-train.mjs +2 -1
  188. package/scripts/oracle/need-set-split.mjs +2 -1
  189. package/scripts/oracle/produce-questions.mjs +2 -16
  190. package/scripts/oracle/repo-recall.mjs +2 -2
  191. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  192. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  193. package/scripts/oracle/source-tree.mjs +2 -15
  194. package/scripts/oracle/source-units.mjs +2 -16
  195. package/scripts/oracle/spike-run.mjs +2 -16
  196. package/scripts/oracle/validate-labels.mjs +2 -16
  197. package/scripts/org-repo-count.mjs +2 -1
  198. package/scripts/package-cards.mjs +2 -1
  199. package/scripts/prepublication-evidence.mjs +2 -1
  200. package/scripts/private-overlay.mjs +2 -1
  201. package/scripts/product-integrity-contract.mjs +2 -1
  202. package/scripts/protected-release-invocation.mjs +2 -1
  203. package/scripts/public-verification-abandon.mjs +2 -1
  204. package/scripts/public-verification-aggregate.mjs +2 -1
  205. package/scripts/public-verification-finalizer.mjs +2 -1
  206. package/scripts/public-verification-inputs.mjs +2 -1
  207. package/scripts/public-verification-lane.mjs +2 -1
  208. package/scripts/publication-receipt.mjs +2 -1
  209. package/scripts/published-surface-probe.mjs +2 -1
  210. package/scripts/qe/card-lane-gate.mjs +2 -16
  211. package/scripts/qe/session-start-gate.mjs +2 -16
  212. package/scripts/qe/ux-suite.mjs +2 -1
  213. package/scripts/qualified-candidate-check.mjs +2 -1
  214. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  215. package/scripts/recall-latency-probe.mjs +48 -0
  216. package/scripts/recommendation-eval.mjs +2 -1
  217. package/scripts/recommendation-floor.mjs +2 -1
  218. package/scripts/recommendation-judge-score.mjs +2 -1
  219. package/scripts/recommendation-real-host-score.mjs +2 -1
  220. package/scripts/recommendation-real-host.mjs +2 -1
  221. package/scripts/refresh-capability-only-store.mjs +2 -1
  222. package/scripts/refresh-model-catalog.mjs +2 -1
  223. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  224. package/scripts/release-authority.mjs +2 -1
  225. package/scripts/release-evidence-aggregate.mjs +2 -1
  226. package/scripts/release-payload.mjs +2 -1
  227. package/scripts/release-projection.mjs +2 -1
  228. package/scripts/release-proof.mjs +2 -1
  229. package/scripts/release-qualification-contract.mjs +82 -28
  230. package/scripts/release-qualification.mjs +8 -1
  231. package/scripts/release-vector.mjs +2 -1
  232. package/scripts/restore-local-ingests.mjs +2 -1
  233. package/scripts/retrieval-canary.mjs +2 -1
  234. package/scripts/route-cheap.mjs +2 -1
  235. package/scripts/route-gold-rank.mjs +2 -1
  236. package/scripts/route-index-memory.mjs +2 -1
  237. package/scripts/route-latency-warm.mjs +2 -1
  238. package/scripts/router-utilization.mjs +2 -1
  239. package/scripts/routing-flywheel.mjs +2 -1
  240. package/scripts/run-operational-benchmark.mjs +2 -1
  241. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  242. package/scripts/rvf-index-audit.mjs +2 -16
  243. package/scripts/seal-gist-receipt.mjs +2 -1
  244. package/scripts/security-guidance-codex-compat.mjs +2 -1
  245. package/scripts/selfcheck.mjs +2 -1
  246. package/scripts/signal-watch.mjs +2 -1
  247. package/scripts/source-coverage.mjs +2 -1
  248. package/scripts/stabilization-receipt.mjs +2 -1
  249. package/scripts/stamp-sweep.mjs +2 -1
  250. package/scripts/status-honesty.mjs +2 -1
  251. package/scripts/sync-commands.mjs +2 -2
  252. package/scripts/top100-benchmark.mjs +2 -1
  253. package/scripts/top100-corpus.mjs +2 -1
  254. package/scripts/user-model-hook-install.mjs +2 -1
  255. package/scripts/user-model-prompt-hook.mjs +2 -1
  256. package/scripts/verify-bundle.mjs +3 -1
  257. package/scripts/verify-model-catalog.mjs +2 -1
  258. package/scripts/version.mjs +2 -1
  259. package/scripts/wired-check.mjs +13 -5
  260. package/scripts/worktree-integrity.mjs +2 -2
@@ -46,7 +46,8 @@ import path from 'node:path';
46
46
  import os from 'node:os';
47
47
  import { spawn, spawnSync } from 'node:child_process';
48
48
  import { fileURLToPath, pathToFileURL } from 'node:url';
49
- import { BOT_MARKER, OWNER_LOGIN } from './issue-watch.mjs';
49
+ import { BOT_MARKER, OWNER_LOGIN, ghJsonWithRetry } from './issue-watch.mjs';
50
+ import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
50
51
 
51
52
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
52
53
  const REPO = 'stuinfla/ruvnet-brain';
@@ -118,28 +119,10 @@ export function executionPolicy(mode = 'unattended') {
118
119
  throw new Error(`unsupported issue-fix mode: ${mode}`);
119
120
  }
120
121
 
121
- function ghJson(args) {
122
- // Retry ONCE on a transient network-shaped failure (2026-07-19: a 1am GitHub API blip — "TLS
123
- // handshake timeout" / "unexpected EOF" — failed the whole run and gonged the phone, when 20s of
124
- // patience was the honest fix). Same bounded philosophy as nightly-wrapper's retry: blind retries
125
- // fix exactly one class (transient network), so retry exactly once, log the first failure, and
126
- // still fail LOUD if it happens twice. Never a silent swallow.
127
- let lastErr;
128
- for (let attempt = 1; attempt <= 2; attempt++) {
129
- const res = spawnSync(GH_BIN, args, { encoding: 'utf8' });
130
- if (res.status === 0) return JSON.parse(res.stdout);
131
- const err = (res.stderr || res.stdout || '').trim();
132
- lastErr = new Error(`gh ${args.join(' ')} failed (exit ${res.status}): ${err}`);
133
- const transient = /TLS handshake|unexpected EOF|timeout|ECONNRESET|ETIMEDOUT|EAI_AGAIN|connection refused|temporarily unavailable/i.test(err);
134
- if (attempt === 1 && transient) {
135
- console.error(`issue-fix: transient gh/network failure (${err.slice(0, 90)}) — retrying once in 20s`);
136
- spawnSync('sleep', ['20']);
137
- continue;
138
- }
139
- break;
140
- }
141
- throw lastErr;
142
- }
122
+ // Transient gh failures (TLS handshake, "error connecting to api.github.com", HTTP 502/503/504) are retried with
123
+ // backoff by the watcher's shared helper — one rule in one place (4.7.21: the old copy here missed the 1 a.m.
124
+ // "error connecting" blip and paged). Anything else still fails LOUD on the first attempt.
125
+ const ghJson = (args) => ghJsonWithRetry(args, { bin: GH_BIN, who: 'issue-fix' });
143
126
 
144
127
  function loadState() {
145
128
  try { return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')); } catch { return {}; }
@@ -555,4 +538,4 @@ async function main() {
555
538
  process.exit(failedAttempt ? 1 : 0);
556
539
  }
557
540
 
558
- if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) await main();
541
+ if (isDirectInvocation(import.meta.url)) await main();
@@ -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,8 @@ 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;
49
52
  // WHO IS WAITING (owner, 2026-10-25: "10,747 notifications"). An issue opened by the owner's own account or a bot is
50
53
  // work the owner's automation filed (Dream Cycle, dependabot), not a person waiting for an answer: it never pages
51
54
  // and gets no acknowledgment. Only a person's issue pages.
@@ -69,26 +72,33 @@ const STATE_PATH = process.env.ISSUE_WATCH_STATE
69
72
  const STATUS_PATH = path.join(os.homedir(), '.cache', 'ruvnet-brain', 'open-issues.json');
70
73
  const GH_BIN = process.env.GH_BIN || 'gh';
71
74
 
72
- function ghJson(args) {
73
- // Retry ONCE on a transient network-shaped failure (2026-07-19, same class as issue-fix's 1am
74
- // "TLS handshake timeout" page): 20s of patience absorbs a blip; a second failure still fails
75
- // 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' } = {}) {
76
85
  let lastErr;
77
- for (let attempt = 1; attempt <= 2; attempt++) {
78
- 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' });
79
88
  if (res.status === 0) return JSON.parse(res.stdout);
80
89
  const err = (res.stderr || res.stdout || '').trim();
81
90
  lastErr = new Error(`gh ${args.join(' ')} failed (exit ${res.status}): ${err}`);
82
- const transient = /TLS handshake|unexpected EOF|timeout|ECONNRESET|ETIMEDOUT|EAI_AGAIN|connection refused|temporarily unavailable/i.test(err);
83
- if (attempt === 1 && transient) {
84
- console.error(`issue-watch: transient gh/network failure (${err.slice(0, 90)}) — retrying once in 20s`);
85
- 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)]);
86
95
  continue;
87
96
  }
88
97
  break;
89
98
  }
90
99
  throw lastErr;
91
100
  }
101
+ const ghJson = (args) => ghJsonWithRetry(args);
92
102
 
93
103
  /** Resolve the ntfy topic the same way the rest of the repo does: env, then the machine-wide
94
104
  * cache file, then the repo .env — see scripts/notify.sh / scripts/job-heartbeat.sh / scripts/nightly-watchdog.mjs. */
@@ -109,11 +119,15 @@ function resolveTopic() {
109
119
  }
110
120
 
111
121
  async function pushNtfy(topic, { title, body, priority = 'urgent', tags = 'rotating_light' }) {
122
+ const base = (process.env.NTFY_URL || 'https://ntfy.sh').replace(/\/+$/, '');
112
123
  try {
113
- 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)}`, {
114
127
  method: 'POST',
115
- headers: { Title: title, Priority: priority, Tags: tags },
128
+ headers: { Title: String(title).replace(/[^\x20-\x7e]+/g, ' ').trim(), Priority: priority, Tags: tags },
116
129
  body,
130
+ signal: AbortSignal.timeout(10_000),
117
131
  });
118
132
  return res.ok;
119
133
  } catch {
@@ -121,8 +135,15 @@ async function pushNtfy(topic, { title, body, priority = 'urgent', tags = 'rotat
121
135
  }
122
136
  }
123
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. */
124
141
  function loadState() {
125
- 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 {};
126
147
  }
127
148
 
128
149
  function saveState(state) {
@@ -155,12 +176,43 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
155
176
  const state = loadState();
156
177
  const results = [];
157
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
158
182
 
159
183
  for (const issue of issues) {
160
- const detail = ghJson(['issue', 'view', String(issue.number), '--repo', repo, '--json', 'comments']);
161
- const { ageHours, ownerComment, breach } = judgeIssue(issue, detail.comments || [], now);
162
184
  const url = `https://github.com/${repo}/issues/${issue.number}`;
163
185
  const key = String(issue.number);
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);
164
216
  // A person's issue pages ONCE when it first passes the SLA, never again (it used to repeat every 4 h, forever,
165
217
  // which with ~14 old issues and an hourly run was most of the 10,747 notifications). The morning digest below
166
218
  // carries whatever is still waiting. An existing lastAlertAt means that one page already went out.
@@ -175,7 +227,13 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
175
227
  // Arrival is paged once, instantly, by .github/workflows/ntfy-alerts.yml (the `issues: opened` event). Paging it
176
228
  // here too sent every new issue twice (review 2026-10-25), so this watcher owns only the SLA page and the digest.
177
229
  const needsNewPage = false;
178
- const needsAck = person && !state[key]?.ackAt;
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;
179
237
 
180
238
  results.push({ number: issue.number, title: issue.title, ageHours, ownerComment, breach, dueForAlert, firstSighting, url, person });
181
239
 
@@ -214,34 +272,47 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
214
272
  }
215
273
  }
216
274
 
217
- if (dueForAlert) {
218
- const title = `SLA breach: issue #${issue.number}`;
219
- const body = `${issue.title}\nopen ${fmtAge(ageHours)}, no response from ${OWNER_LOGIN}\n${url}`;
220
- if (dryRun) {
221
- alertsSent.push({ number: issue.number, sent: false, reason: 'dry-run' });
222
- } else {
223
- const topic = resolveTopic();
224
- let sent = false;
225
- if (topic) sent = await pushNtfy(topic, { title, body, priority: 'urgent', tags: 'rotating_light,warning' });
226
- // DERIVED, not asserted (F5, 2026-07-18): lastAlertAt may only be written when the page was
227
- // actually DELIVERED (sent===true). The old line stamped it unconditionally, so a breach whose
228
- // push failed (ntfy down, no topic) was suppressed for the whole 4h cooldown — the alert ledger
229
- // asserted a delivery it never verified. A failed attempt records itself as failed and the next
230
- // hourly run retries; the ledger can no longer claim a page that didn't happen.
231
- // Spread-merge, never overwrite: the record may already carry firstSeenAt/newAlertAt from
232
- // the first-sighting page above — clobbering them would re-page "new" forever (the exact
233
- // state-erasure class that broke issue-fix's comment dedup, 2026-07-24).
234
- if (sent) state[key] = { ...(state[key] || {}), lastAlertAt: new Date(now).toISOString(), title: issue.title, url };
235
- else state[key] = { ...(state[key] || {}), lastAttemptAt: new Date(now).toISOString(), sent: false, title: issue.title, url };
236
- 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' });
237
304
  }
305
+ if (sent) breachPaged = new Set(dueBreaches.map((b) => b.issue.number));
238
306
  }
239
307
  }
240
308
 
241
309
  // The morning digest: one notification naming every person's issue still unanswered, oldest first.
242
310
  const waiting = results.filter((r) => r.person && r.breach).sort((a, b) => b.ageHours - a.ageHours);
243
311
  const digest = digestDue(state._digest?.day, now);
244
- if (waiting.length && digest.due) {
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) {
245
316
  const title = `Waiting on a reply: ${waiting.length} contributor issue${waiting.length === 1 ? '' : 's'}`;
246
317
  const body = waiting.slice(0, 8).map((r) => `#${r.number} (${fmtAge(r.ageHours)}) ${r.title.slice(0, 70)}`).join('\n')
247
318
  + (waiting.length > 8 ? `\n…and ${waiting.length - 8} more` : '') + `\nhttps://github.com/${repo}/issues`;
@@ -256,7 +327,7 @@ export async function run({ dryRun = false, now = Date.now(), repo = REPO } = {}
256
327
 
257
328
  if (!dryRun) saveState(state);
258
329
 
259
- return { results, alertsSent, checkedAt: new Date(now).toISOString() };
330
+ return { results, alertsSent, degraded, unreadable, checkedAt: new Date(now).toISOString() };
260
331
  }
261
332
 
262
333
  /**
@@ -430,7 +501,12 @@ async function main() {
430
501
  // heartbeat records the failure and the wrapper's own channel escalates — that duplicate-looking
431
502
  // page IS the correct behavior when the primary page provably never left the building.
432
503
  const undeliveredAlert = !dryRun && (output.alertsSent || []).some((a) => !a.sent && a.reason !== 'dry-run');
433
- 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);
434
510
  }
435
511
 
436
- 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,24 +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\":\"$(printf '%s %s' "$1" "${2:-}" | tr -d '"\\\n\r' | cut -c1-200)\"}"
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
112
  [ -z "$topic" ] && return 1
96
113
  # --fail: an HTTP error is a failed delivery. The caller records whether the page landed, so a page that
97
114
  # never arrived is retried on the next run instead of muting the whole failing streak (review 2026-10-25).
98
- curl -sS --fail -m 10 -H "Title: $1" -H "Priority: $3" -H "Tags: ${4:-rotating_light}" -d "$2" "https://ntfy.sh/$topic" >/dev/null 2>&1
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
99
116
  }
100
117
 
101
118
  # The run_id currently on disk for this label, or empty if the receipt is missing/unreadable/has
@@ -112,8 +129,10 @@ finish() {
112
129
  timed_out=0
113
130
  if [ -f "$HB.timeout-$RUN_ID" ]; then
114
131
  # Anything the overrunning job left behind (started during its TERM handling, say) goes now.
115
- timed_out=1; stop_job; rm -f "$HB.timeout-$RUN_ID"
132
+ timed_out=1; stop_job
116
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"
117
136
  # An overrun is a failure however the job chose to exit on TERM, including 0 and 75 (the skip code), so this
118
137
  # comes before the skip branch (GPT-6.1 review round 2).
119
138
  [ "$timed_out" -eq 1 ] && [ "$code" -le 128 ] && code=143
@@ -152,28 +171,44 @@ finish() {
152
171
  # PAGE ON THE TRANSITION, NOT ON EVERY RUN (owner, 2026-10-25: 10,747 notifications on one topic). A job that
153
172
  # starts failing pages once, urgently, while it is still tonight's problem; while it KEEPS failing it stays
154
173
  # quiet (failing_since carries the first failure; the nightly watchdog's digest still lists it); the run
155
- # that passes again sends one low-priority "recovered" note. The previous run's state comes from PREV_HB.
156
- prev_state="$(printf '%s' "$PREV_HB" | sed -n 's/.*"state":"\([^"]*\)".*/\1/p' | head -1)"
157
- prev_since="$(printf '%s' "$PREV_HB" | sed -n 's/.*"failing_since":"\([^"]*\)".*/\1/p' | head -1)"
158
- was_failing=0; case "$prev_state" in failed|killed) was_failing=1 ;; esac
159
- # A failing streak counts as already paged only if that page was delivered ("paged":false = retry it).
160
- # Only a page PROVEN delivered ("paged":true) keeps the streak quiet; a receipt from before this field existed
161
- # counts as unknown and is paged once more (review round 2).
162
- prev_unpaged=1; case "$PREV_HB" in *'"paged":true'*) prev_unpaged=0 ;; esac
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"
163
186
  since=""
164
187
  if [ "$code" -ne 0 ]; then
165
188
  if [ "$was_failing" -eq 1 ] && [ -n "$prev_since" ]; then since="$prev_since"; else since="$STARTED"; fi
166
189
  extra="$extra,\"failing_since\":\"$since\""
167
190
  fi
168
191
  if [ "$code" -ne 0 ] && { [ "$was_failing" -eq 0 ] || [ "$prev_unpaged" -eq 1 ]; }; then
169
- reason="exit $code after ${dur}s"; [ "$timed_out" -eq 1 ] && reason="stopped after running past its ${MAX_SECONDS}s limit"
170
- if notify "🔴 SCHEDULED JOB FAILED: $LABEL" "$reason — see the job's log. Receipt: $HB. Further failures stay quiet until it recovers." "urgent"; then
171
- extra="$extra,\"paged\":true"; else extra="$extra,\"paged\":false"; fi
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
172
205
  elif [ "$code" -ne 0 ]; then
173
206
  extra="$extra,\"paged\":true"
174
- elif [ "$was_failing" -eq 1 ]; then
175
- notify "✅ Recovered: $LABEL" "passed again after failing since ${prev_since:-an earlier run}" "low" "white_check_mark" || 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
176
209
  fi
210
+ [ -n "$page_epoch" ] && extra="$extra,\"last_page_epoch\":$page_epoch"
211
+ [ -n "$page_at" ] && extra="$extra,\"last_page_at\":\"$page_at\""
177
212
  write_receipt "{\"label\":\"$LABEL\",\"started_at\":\"$STARTED\",\"ended_at\":\"$ended\",\"state\":\"$state\",\"exit_code\":$code,\"duration_sec\":$dur,\"run_id\":\"$RUN_ID\"$extra}"
178
213
  exit "$code"
179
214
  }
@@ -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);