ruvnet-brain 4.7.20 → 4.7.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +494 -97
  3. package/kb/download-retry.mjs +93 -21
  4. package/kb/forge-guard-injection.mjs +277 -0
  5. package/kb/forge-update.mjs +166 -38
  6. package/kb/lifecycle-evidence-retention.mjs +151 -14
  7. package/kb/model-requirements.mjs +4 -1
  8. package/kb/refresh-run.mjs +175 -27
  9. package/kb/verify-citation.mjs +15 -2
  10. package/package.json +3 -1
  11. package/plugin/.claude-plugin/plugin.json +1 -1
  12. package/plugin/.codex-plugin/plugin.json +1 -1
  13. package/plugin/mcp/managed-cli-interface.mjs +16 -8
  14. package/plugin/scripts/adr-currency-gate.mjs +2 -1
  15. package/plugin/scripts/advisory-contract.mjs +21 -0
  16. package/plugin/scripts/advocacy-route.mjs +5 -4
  17. package/plugin/scripts/agentdb-recall.mjs +15 -5
  18. package/plugin/scripts/automatic-update.mjs +9 -1
  19. package/plugin/scripts/brain-confirmation.mjs +2 -1
  20. package/plugin/scripts/brain-footprint.mjs +72 -11
  21. package/plugin/scripts/capability-registry.mjs +1 -1
  22. package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
  23. package/plugin/scripts/codex-overnight-notice.mjs +33 -0
  24. package/plugin/scripts/completion-claim-evidence.mjs +56 -12
  25. package/plugin/scripts/continuation-gate.mjs +28 -9
  26. package/plugin/scripts/decision-gate.mjs +13 -63
  27. package/plugin/scripts/decision-outcomes.mjs +36 -0
  28. package/plugin/scripts/decision-recall-gate.mjs +40 -5
  29. package/plugin/scripts/decision-record.mjs +8 -3
  30. package/plugin/scripts/degradation-watch.mjs +2 -1
  31. package/plugin/scripts/developer-update-lock.mjs +172 -33
  32. package/plugin/scripts/developer-update-owner.mjs +8 -5
  33. package/plugin/scripts/developer-update-policy.mjs +4 -1
  34. package/plugin/scripts/developer-update.mjs +155 -53
  35. package/plugin/scripts/doc-currency.mjs +2 -16
  36. package/plugin/scripts/duplicate-gate.mjs +2 -1
  37. package/plugin/scripts/entrypoint.mjs +34 -0
  38. package/plugin/scripts/footprint-io.mjs +135 -8
  39. package/plugin/scripts/grok-hook-adapter.mjs +2 -1
  40. package/plugin/scripts/ground-ruvnet.sh +6 -3
  41. package/plugin/scripts/grounding-answer.mjs +2 -1
  42. package/plugin/scripts/grounding-code-projection.mjs +2 -1
  43. package/plugin/scripts/grounding-substance.mjs +2 -1
  44. package/plugin/scripts/grounding-turn-gate.mjs +2 -2
  45. package/plugin/scripts/grounding-turn-mark.mjs +2 -2
  46. package/plugin/scripts/hook-context-budget.mjs +2 -1
  47. package/plugin/scripts/hook-input.mjs +2 -2
  48. package/plugin/scripts/hook-shim.mjs +9 -2
  49. package/plugin/scripts/host-update.mjs +29 -5
  50. package/plugin/scripts/identifier-preflight.mjs +2 -1
  51. package/plugin/scripts/kb-copy-proof.mjs +57 -9
  52. package/plugin/scripts/learn-capture.mjs +2 -1
  53. package/plugin/scripts/learning-store.mjs +62 -16
  54. package/plugin/scripts/lesson-bridge.mjs +2 -2
  55. package/plugin/scripts/lesson-promote.mjs +83 -22
  56. package/plugin/scripts/md-stamp.mjs +2 -1
  57. package/plugin/scripts/native-turn-transcript.mjs +98 -6
  58. package/plugin/scripts/nightly-scheduler.mjs +10 -5
  59. package/plugin/scripts/package-recommender.mjs +2 -2
  60. package/plugin/scripts/project-store-resolver.mjs +19 -0
  61. package/plugin/scripts/routing-outcome-capture.mjs +2 -1
  62. package/plugin/scripts/session-snapshot-hook.mjs +24 -4
  63. package/plugin/scripts/session-start-core.mjs +5 -15
  64. package/plugin/scripts/session-start-health.mjs +97 -10
  65. package/plugin/scripts/session-start-issue-alert.mjs +63 -0
  66. package/plugin/scripts/session-start-update-plane.mjs +15 -1
  67. package/plugin/scripts/signal-watch.mjs +2 -1
  68. package/plugin/scripts/spend-guard.mjs +2 -1
  69. package/plugin/scripts/substitution-wall.mjs +2 -1
  70. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  71. package/plugin/scripts/unprompted-runtime.mjs +20 -3
  72. package/plugin/scripts/update-apply.mjs +186 -15
  73. package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
  74. package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
  75. package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
  76. package/scripts/adr-072-completion.mjs +2 -1
  77. package/scripts/agentdb-context.mjs +2 -1
  78. package/scripts/agentdb-fleet-doctor.mjs +30 -8
  79. package/scripts/approved-runtime.mjs +2 -1
  80. package/scripts/architecture-review-lock.mjs +2 -1
  81. package/scripts/ascii-drift.mjs +2 -1
  82. package/scripts/brain-latency-50.mjs +2 -1
  83. package/scripts/brain-novice-50.mjs +2 -16
  84. package/scripts/brain-score.mjs +13 -2
  85. package/scripts/build-bundle.mjs +2 -1
  86. package/scripts/build-concepts.mjs +2 -1
  87. package/scripts/candidate-ci-receipt.mjs +2 -1
  88. package/scripts/candidate-host-evidence.mjs +2 -1
  89. package/scripts/card-from-source.mjs +2 -1
  90. package/scripts/ci/warm-brain-models.mjs +2 -1
  91. package/scripts/claims-verify.mjs +7 -3
  92. package/scripts/claude-controlled-terminal.mjs +2 -1
  93. package/scripts/claude-terminal-mod.mjs +2 -1
  94. package/scripts/code-release-corpus.mjs +2 -1
  95. package/scripts/codex-hook-trust-reconcile.mjs +67 -1
  96. package/scripts/codex-hook-trust.mjs +40 -1
  97. package/scripts/codex-hook-wrapper-history.mjs +2 -1
  98. package/scripts/codex-host-execution-proof.mjs +2 -1
  99. package/scripts/completion-claim-replay.mjs +2 -1
  100. package/scripts/console-engine.mjs +8 -3
  101. package/scripts/corpus-canary.mjs +2 -1
  102. package/scripts/corpus-candidate.mjs +2 -2
  103. package/scripts/corpus-coverage-sidecar.mjs +2 -1
  104. package/scripts/corpus-dispatch-decision.mjs +2 -1
  105. package/scripts/corpus-dispatch-receipt.mjs +2 -1
  106. package/scripts/corpus-next-seed.mjs +2 -1
  107. package/scripts/corpus-qa.mjs +2 -1
  108. package/scripts/corpus-reconcile.mjs +2 -2
  109. package/scripts/corpus-watchdog.mjs +2 -1
  110. package/scripts/count-chunks.mjs +2 -1
  111. package/scripts/customer-state-matrix.mjs +2 -1
  112. package/scripts/derive-passage-content-map.mjs +2 -1
  113. package/scripts/development-push-check.mjs +2 -1
  114. package/scripts/dispatch-receipt.mjs +2 -1
  115. package/scripts/doc-currency.mjs +2 -1
  116. package/scripts/dream-issue-gate.mjs +2 -1
  117. package/scripts/dual-host-deliberation.mjs +2 -16
  118. package/scripts/dual-host-suggest.mjs +2 -16
  119. package/scripts/eval-brain.mjs +7 -3
  120. package/scripts/execution-policy.mjs +2 -1
  121. package/scripts/execution-preflight.mjs +2 -1
  122. package/scripts/falsify.mjs +2 -1
  123. package/scripts/fix-metaharness-memretrieve.mjs +2 -1
  124. package/scripts/fix-workstream.mjs +2 -1
  125. package/scripts/full-hints.mjs +2 -1
  126. package/scripts/full-suite-gate.mjs +2 -1
  127. package/scripts/grok-host.mjs +68 -0
  128. package/scripts/hook-qualify-core.mjs +3 -1
  129. package/scripts/hook-qualify.mjs +2 -1
  130. package/scripts/hook-retirement-check.mjs +2 -1
  131. package/scripts/host-registry.mjs +2 -1
  132. package/scripts/independent-review-receipt.mjs +2 -1
  133. package/scripts/ingest-new-repos.mjs +2 -1
  134. package/scripts/ingest-repo.mjs +12 -2
  135. package/scripts/integration-evidence.mjs +2 -1
  136. package/scripts/issue-fix.mjs +7 -24
  137. package/scripts/issue-watch.mjs +116 -40
  138. package/scripts/job-heartbeat.sh +54 -19
  139. package/scripts/key-canary.mjs +9 -3
  140. package/scripts/latency-to-surface.mjs +2 -1
  141. package/scripts/learning-replay-cli.mjs +97 -19
  142. package/scripts/learning-replay-execution.mjs +2 -0
  143. package/scripts/learning-replay-fixture.mjs +3 -0
  144. package/scripts/learning-replay-proof.mjs +14 -13
  145. package/scripts/learning-replay.mjs +3 -3
  146. package/scripts/learnings.mjs +2 -1
  147. package/scripts/lesson-ratify.mjs +88 -40
  148. package/scripts/loop-checkpoint.mjs +2 -1
  149. package/scripts/metaharness-receipts.mjs +2 -1
  150. package/scripts/model-catalog.mjs +2 -1
  151. package/scripts/model-currency-evidence.mjs +6 -4
  152. package/scripts/model-currency.mjs +33 -3
  153. package/scripts/model-native-catalog.mjs +2 -1
  154. package/scripts/model-router-agent-hook.mjs +2 -1
  155. package/scripts/model-router-dispatch.mjs +2 -1
  156. package/scripts/model-router-engine.mjs +2 -1
  157. package/scripts/model-router-outcome.mjs +2 -1
  158. package/scripts/model-router-setup.mjs +2 -1
  159. package/scripts/model-routing-eval.mjs +2 -1
  160. package/scripts/model-routing-gateway.mjs +2 -1
  161. package/scripts/model-routing-launchers.mjs +4 -1
  162. package/scripts/model-terminal-gateway.mjs +2 -1
  163. package/scripts/model-terminal-launchers.mjs +2 -1
  164. package/scripts/model-weekly-analyst.mjs +2 -1
  165. package/scripts/model-weekly-cycle.mjs +2 -1
  166. package/scripts/model-weekly-qualification.mjs +2 -1
  167. package/scripts/nightly-gists.sh +26 -20
  168. package/scripts/nightly-two-run-proof.mjs +2 -1
  169. package/scripts/nightly-watchdog.mjs +158 -47
  170. package/scripts/no-silent-substitution.mjs +2 -1
  171. package/scripts/notify.sh +11 -6
  172. package/scripts/ntfy-inbox.mjs +41 -18
  173. package/scripts/onboarding-console.mjs +47 -8
  174. package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
  175. package/scripts/oracle/abstain-trace.mjs +2 -1
  176. package/scripts/oracle/doc2query-generate.mjs +2 -1
  177. package/scripts/oracle/doc2query-reach.mjs +2 -1
  178. package/scripts/oracle/judge-train.mjs +2 -1
  179. package/scripts/oracle/need-set-split.mjs +2 -1
  180. package/scripts/oracle/produce-questions.mjs +2 -16
  181. package/scripts/oracle/repo-recall.mjs +2 -2
  182. package/scripts/oracle/retrieval-accuracy.mjs +2 -2
  183. package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
  184. package/scripts/oracle/source-tree.mjs +2 -15
  185. package/scripts/oracle/source-units.mjs +2 -16
  186. package/scripts/oracle/spike-run.mjs +2 -16
  187. package/scripts/oracle/validate-labels.mjs +2 -16
  188. package/scripts/org-repo-count.mjs +2 -1
  189. package/scripts/package-cards.mjs +2 -1
  190. package/scripts/prepublication-evidence.mjs +2 -1
  191. package/scripts/private-overlay.mjs +2 -1
  192. package/scripts/product-integrity-contract.mjs +2 -1
  193. package/scripts/protected-release-invocation.mjs +2 -1
  194. package/scripts/public-verification-abandon.mjs +2 -1
  195. package/scripts/public-verification-aggregate.mjs +2 -1
  196. package/scripts/public-verification-finalizer.mjs +2 -1
  197. package/scripts/public-verification-inputs.mjs +2 -1
  198. package/scripts/public-verification-lane.mjs +2 -1
  199. package/scripts/publication-receipt.mjs +2 -1
  200. package/scripts/published-surface-probe.mjs +2 -1
  201. package/scripts/qe/card-lane-gate.mjs +2 -16
  202. package/scripts/qe/session-start-gate.mjs +2 -16
  203. package/scripts/qe/ux-suite.mjs +2 -1
  204. package/scripts/qualified-candidate-check.mjs +2 -1
  205. package/scripts/rebuild-gists-from-receipts.mjs +2 -1
  206. package/scripts/recommendation-eval.mjs +2 -1
  207. package/scripts/recommendation-floor.mjs +2 -1
  208. package/scripts/recommendation-judge-score.mjs +2 -1
  209. package/scripts/recommendation-real-host-score.mjs +2 -1
  210. package/scripts/recommendation-real-host.mjs +2 -1
  211. package/scripts/refresh-capability-only-store.mjs +2 -1
  212. package/scripts/refresh-model-catalog.mjs +2 -1
  213. package/scripts/rehearse-corpus-pipeline.mjs +2 -1
  214. package/scripts/release-authority.mjs +2 -1
  215. package/scripts/release-evidence-aggregate.mjs +2 -1
  216. package/scripts/release-payload.mjs +2 -1
  217. package/scripts/release-projection.mjs +2 -1
  218. package/scripts/release-proof.mjs +2 -1
  219. package/scripts/release-qualification-contract.mjs +64 -28
  220. package/scripts/release-qualification.mjs +8 -1
  221. package/scripts/release-vector.mjs +2 -1
  222. package/scripts/restore-local-ingests.mjs +2 -1
  223. package/scripts/retrieval-canary.mjs +2 -1
  224. package/scripts/route-cheap.mjs +2 -1
  225. package/scripts/route-gold-rank.mjs +2 -1
  226. package/scripts/route-index-memory.mjs +2 -1
  227. package/scripts/route-latency-warm.mjs +2 -1
  228. package/scripts/router-utilization.mjs +2 -1
  229. package/scripts/routing-flywheel.mjs +2 -1
  230. package/scripts/run-operational-benchmark.mjs +2 -1
  231. package/scripts/run-operational-benchmark.v3.mjs +2 -1
  232. package/scripts/rvf-index-audit.mjs +2 -16
  233. package/scripts/seal-gist-receipt.mjs +2 -1
  234. package/scripts/security-guidance-codex-compat.mjs +2 -1
  235. package/scripts/selfcheck.mjs +2 -1
  236. package/scripts/signal-watch.mjs +2 -1
  237. package/scripts/source-coverage.mjs +2 -1
  238. package/scripts/stabilization-receipt.mjs +2 -1
  239. package/scripts/stamp-sweep.mjs +2 -1
  240. package/scripts/status-honesty.mjs +2 -1
  241. package/scripts/sync-commands.mjs +2 -2
  242. package/scripts/top100-benchmark.mjs +2 -1
  243. package/scripts/top100-corpus.mjs +2 -1
  244. package/scripts/user-model-hook-install.mjs +2 -1
  245. package/scripts/user-model-prompt-hook.mjs +2 -1
  246. package/scripts/verify-bundle.mjs +3 -1
  247. package/scripts/verify-model-catalog.mjs +2 -1
  248. package/scripts/version.mjs +2 -1
  249. package/scripts/wired-check.mjs +11 -5
  250. package/scripts/worktree-integrity.mjs +2 -2
@@ -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);
@@ -40,13 +40,17 @@ import { GPT_5_6_SOL_ID } from './review-model-defaults.mjs';
40
40
  const usage = () => `Usage:
41
41
  node scripts/learning-replay.mjs [--trap ${TRAP.MEMORY_SEARCH}|${TRAP.POST_TASK}] [--n N] [--host codex|claude-code] [--model MODEL]
42
42
  node scripts/learning-replay.mjs --check
43
- node scripts/learning-replay.mjs --check-portfolio
43
+ node scripts/learning-replay.mjs --check-portfolio [--tolerate-stale]
44
44
  node scripts/learning-replay.mjs --measure-portfolio --host codex --model MODEL
45
45
  node scripts/learning-replay.mjs --check-mutants
46
+ node scripts/learning-replay.mjs --check-wire (token-free; needs the global ruflo)
46
47
  node scripts/learning-replay.mjs --dry-run
47
48
  node scripts/learning-replay.mjs --mutant <${Object.keys(MUTANTS).join('|')}>
48
49
 
49
- Exit: 0=PASS, 1=FAIL, 3=INCONCLUSIVE, 4=UNKNOWN.`;
50
+ Exit: 0=PASS, 1=FAIL, 3=INCONCLUSIVE, 4=UNKNOWN. --tolerate-stale exits 0 for an UNKNOWN caused only by
51
+ out-of-date evidence (load-bearing files changed since it was measured, or past the age limit) and prints
52
+ STALE-TOLERATED, and only when every trap and mutant artifact still verifies and recomputes to its expected
53
+ result; every FAIL, INCONCLUSIVE and other UNKNOWN in any artifact keeps its exit code.`;
50
54
 
51
55
  function parse(argv) {
52
56
  const has = (flag) => argv.includes(flag);
@@ -57,29 +61,85 @@ function parse(argv) {
57
61
  return { has, arg };
58
62
  }
59
63
 
60
- function wireProbe(dirs, spec, stateDir) {
61
- const result = spawnSync(process.execPath, [
62
- path.join(ROOT, 'plugin', 'scripts', 'hook-shim.mjs'),
63
- 'unprompted-speech', 'UserPromptSubmit',
64
- ], {
64
+ // Fire project B's prompt hook exactly as each host registers it: Claude through hook-shim.mjs
65
+ // (plugin/hooks/hooks.json), Codex through the Brain home's stable codex-hook.mjs wrapper. The user's
66
+ // nudge setting and the runtime's test seams are pinned to the DEFAULT install, so an ambient
67
+ // RUVNET_STOP_NUDGES=full on the measuring machine can never hide a regression that every user has.
68
+ export function wireOutput(dirs, spec, stateDir, host = 'claude-code') {
69
+ const argv = host === 'codex'
70
+ ? [path.join(dirs.brainHome, 'codex-hook.mjs'), 'unprompted-speech', 'UserPromptSubmit']
71
+ : [path.join(ROOT, 'plugin', 'scripts', 'hook-shim.mjs'), 'unprompted-speech', 'UserPromptSubmit'];
72
+ const env = {
73
+ ...process.env,
74
+ RUVNET_BRAIN_HOME: dirs.brainHome,
75
+ RUVNET_BRAIN_STATE_DIR: stateDir,
76
+ RUVNET_CONFIG_ROOT: path.join(dirs.base, 'config'),
77
+ RUVNET_LESSON_STORE: dirs.lessons,
78
+ RUVNET_LESSON_GATE_STATE: dirs.gateState,
79
+ CLAUDE_PLUGIN_ROOT: path.join(ROOT, 'plugin'),
80
+ RUVNET_STOP_NUDGES: '',
81
+ };
82
+ for (const seam of ['RUVNET_UNPROMPTED_CHANNELS', 'RUVNET_UNPROMPTED_PRODUCERS', 'RUVNET_REPLAY_SEQUENCE_FILE']) delete env[seam];
83
+ const result = spawnSync(process.execPath, argv, {
65
84
  input: JSON.stringify({
85
+ hook_event_name: 'UserPromptSubmit',
66
86
  prompt: spec.prompt,
67
- session_id: `dry-${Date.now()}`,
87
+ session_id: `wire-${Date.now()}`,
68
88
  cwd: dirs.projectB,
69
89
  }),
70
90
  encoding: 'utf8',
71
91
  cwd: dirs.projectB,
72
- env: {
73
- ...process.env,
74
- RUVNET_BRAIN_HOME: dirs.brainHome,
75
- RUVNET_BRAIN_STATE_DIR: stateDir,
76
- RUVNET_CONFIG_ROOT: path.join(dirs.base, 'config'),
77
- RUVNET_LESSON_STORE: dirs.lessons,
78
- RUVNET_LESSON_GATE_STATE: dirs.gateState,
79
- CLAUDE_PLUGIN_ROOT: path.join(ROOT, 'plugin'),
80
- },
92
+ timeout: 60_000,
93
+ env,
81
94
  });
82
- return (result.stdout || '').length;
95
+ return result.stdout || '';
96
+ }
97
+
98
+ const wireProbe = (dirs, spec, stateDir) => wireOutput(dirs, spec, stateDir).length;
99
+
100
+ // THE TOKEN-FREE HALF OF D4, measured on every schedule. No model is called, so this does NOT prove a
101
+ // model changed its behaviour (that is --check-portfolio, priced in tokens). It proves the brain's half
102
+ // of the loop on the current code with the real global ruflo: a lesson recorded independently in two
103
+ // projects is promoted, survives a refresh, and reaches project B's prompt on BOTH hosts — while the
104
+ // brain-off control never receives it and deleting the lesson removes it (the causal mutant).
105
+ export function checkWire({ fire = wireOutput, premise = verifyRufloFlag, hosts = ['claude-code', 'codex'] } = {}) {
106
+ const ruflo = premise();
107
+ if (!ruflo.ok) return { status: VERDICT.UNKNOWN, why: `ruflo premise not verified: ${ruflo.why}` };
108
+ const checked = [];
109
+ for (const trap of [TRAP.MEMORY_SEARCH, TRAP.POST_TASK]) {
110
+ const spec = trapSpec(trap);
111
+ const probe = spec.lesson.slice(0, 60);
112
+ const base = allocateRunBase();
113
+ const dirs = buildFixtures(base);
114
+ try {
115
+ const record = recordInProjectA(dirs, { trap });
116
+ if (!record.sources.every((source) => source.recorded)) {
117
+ return { status: VERDICT.UNKNOWN, why: `${trap}: ruflo could not record the lesson in project A`, checked };
118
+ }
119
+ if (!record.ok || !record.promoted) return { status: VERDICT.FAIL, why: `${trap}: lesson was not promoted across two projects`, checked };
120
+ const refresh = nightlyRefresh(dirs);
121
+ if (!refresh.lessonSurvived) return { status: VERDICT.FAIL, why: `${trap}: lesson did not survive refresh`, checked };
122
+ for (const host of hosts) {
123
+ if (fire(dirs, spec, dirs.stateOff, host).includes(probe)) {
124
+ return { status: VERDICT.INCONCLUSIVE, why: `${trap}/${host}: the brain-off control also received the lesson`, checked };
125
+ }
126
+ if (!fire(dirs, spec, dirs.stateOn, host).includes(probe)) {
127
+ return { status: VERDICT.FAIL, why: `${trap}/${host}: the promoted lesson did not reach project B's prompt`, checked };
128
+ }
129
+ }
130
+ saveLessons([], dirs.lessons);
131
+ for (const host of hosts) {
132
+ if (fire(dirs, spec, dirs.stateOn, host).includes(probe)) {
133
+ return { status: VERDICT.FAIL, why: `${trap}/${host}: delete-lesson mutant still delivered it — delivery is not caused by the lesson`, checked };
134
+ }
135
+ }
136
+ checked.push(trap);
137
+ } finally {
138
+ cleanupFixtureDaemons(dirs);
139
+ removeFixture(base);
140
+ }
141
+ }
142
+ return { status: VERDICT.PASS, why: `both lessons reached project B on ${hosts.join(' and ')}; control and delete-lesson mutant stayed silent`, checked };
83
143
  }
84
144
 
85
145
  function printed(label, result) {
@@ -87,6 +147,23 @@ function printed(label, result) {
87
147
  return EXIT[result.status] ?? EXIT.UNKNOWN;
88
148
  }
89
149
 
150
+ // The scheduled workflow reports out-of-date model evidence without paging on it (nothing scheduled can
151
+ // re-measure it); a recomputed FAIL, a forged or missing artifact, or any other UNKNOWN stays non-zero.
152
+ // The strict check stops at the first stale artifact, so tolerance re-checks the WHOLE portfolio (both traps
153
+ // and all four mutants, every transcript verified and recomputed) with only staleness waived, and softens
154
+ // nothing unless that passes: staleness never hides a defect behind it (Codex 4.7.21 r4 #2, Opus r2 #3).
155
+ function portfolioExit(result, tolerateStale) {
156
+ if (!tolerateStale || result.status !== VERDICT.UNKNOWN || result.stale !== true) {
157
+ return printed(`${INVARIANT}-PORTFOLIO`, result);
158
+ }
159
+ const waived = checkPortfolio({ tolerateStale: true });
160
+ if (waived.status !== VERDICT.PASS) return printed(`${INVARIANT}-PORTFOLIO`, waived);
161
+ printed(`${INVARIANT}-PORTFOLIO`, result);
162
+ console.log(' STALE-TOLERATED: every artifact verified and recomputed, but the committed model evidence predates'
163
+ + ' the current code; re-measure to restore a verdict.\n');
164
+ return EXIT.PASS;
165
+ }
166
+
90
167
  // A portfolio includes both positive traps AND all four causal mutants. Run every scenario even
91
168
  // after a failure, then let the unchanged evidence checker decide the result; child exit 1 is the
92
169
  // expected outcome of a causal mutant, and is never by itself a successful portfolio measurement.
@@ -125,8 +202,9 @@ export async function main(argv = process.argv.slice(2)) {
125
202
  if (has('--check')) {
126
203
  return printed(INVARIANT, checkArtifact({ file: PORTFOLIO_RESULT_FILES[trap] }));
127
204
  }
128
- if (has('--check-portfolio')) return printed(`${INVARIANT}-PORTFOLIO`, checkPortfolio());
205
+ if (has('--check-portfolio')) return portfolioExit(checkPortfolio(), has('--tolerate-stale'));
129
206
  if (has('--check-mutants')) return printed(`${INVARIANT}-MUTANTS`, checkMutantArtifacts());
207
+ if (has('--check-wire')) return printed(`${INVARIANT}-WIRE`, checkWire());
130
208
  if (has('--measure-portfolio')) return measurePortfolio({ host, model });
131
209
 
132
210
  const source = checkSourceIdentity();
@@ -151,6 +151,8 @@ export function verifyRufloFlag(bin = RUFLO_BIN) {
151
151
  const result = spawnSync(bin, ['memory', 'search', '--help'], {
152
152
  encoding: 'utf8',
153
153
  timeout: 30_000,
154
+ cwd: os.tmpdir(), // ruflo writes .claude-flow/ into its cwd; never the checkout
155
+
154
156
  env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_AUTO_UPDATE: 'false' },
155
157
  });
156
158
  const output = `${result.stdout || ''}${result.stderr || ''}`;
@@ -31,6 +31,9 @@ const sh = (cmd, args, options = {}) => {
31
31
  return spawnSync(binary, argv, {
32
32
  encoding: 'utf8',
33
33
  timeout: 120_000,
34
+ // Never the checkout: ruflo writes `.claude-flow/` and `agentdb.rvf.idmap.json` into its cwd, and release
35
+ // qualification requires the checkout to stay byte-identical.
36
+ cwd: os.tmpdir(),
34
37
  ...options,
35
38
  // A ruflo started here would auto-start a daemon and run its in-cwd self-update (`npm install @claude-flow/cli`,
36
39
  // 1.3 GB measured 2026-10-22) in the fixture project; a caller's own env still wins.