ruvnet-brain 4.7.16 → 4.7.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,7 +9,7 @@ Created: 2026-06-29 22:36:38 EDT
9
9
 
10
10
  ### Discover the stack. Read the source. Build with confidence.
11
11
 
12
- [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.16-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
12
+ [![RuvNet Brain version 4.6.2 — README updated 2026-10-10 EDT](https://img.shields.io/badge/version_4.7.17-updated_2026--10--10_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](plugin/.claude-plugin/plugin.json)
13
13
 
14
14
  **A source-grounded companion for Claude Code and Codex that helps you find and use Reuven Cohen's (rUv's) RuvNet ecosystem.**
15
15
 
@@ -255,7 +255,7 @@ uses the protected release workflow; a consumer update downloads an available ge
255
255
  does not rebuild upstream knowledge. Check `--doctor`, `SOURCE.json` and refresh receipts
256
256
  before treating an installation as current.
257
257
 
258
- The **4.6.0 published-corpus baseline** is **203 public stores · 164,863 public source chunks**.
258
+ The **4.6.0 published-corpus baseline** is **205 public stores · 165,153 public source chunks**.
259
259
  Those are bundle census values, not a claim that every upstream repository is fresh today.
260
260
  Later corpus publication may advance the knowledge generation independently of code changes.
261
261
 
package/bin/install.mjs CHANGED
@@ -54,6 +54,8 @@ import {
54
54
  installNightlyRunner,
55
55
  installScheduler,
56
56
  nightlyArtifact,
57
+ nightlyModuleCurrency,
58
+ refreshNightlyModules,
57
59
  removeScheduler,
58
60
  readNightlyRegistration,
59
61
  resolveNightlyProofBundle,
@@ -3179,8 +3181,14 @@ async function doctorRun({ json }) {
3179
3181
  const brainHome = process.env.RUVNET_BRAIN_HOME || path.dirname(cacheDir);
3180
3182
  const nightlyHealth = schedulerStatus({ platform: process.platform, env: process.env,
3181
3183
  brainHome, kbDir: cacheDir });
3184
+ // Does the scheduled job run the code installed now? Its module snapshot is refreshed by --update, its runner only
3185
+ // by --enable-nightly (measured stale 2026-10-22). Read-only; `unregistered` is already covered by the state lines.
3186
+ let nightlyCode = { state: 'unregistered' };
3187
+ try { nightlyCode = nightlyModuleCurrency({ brainHome, runnerSource: path.join(REPO_ROOT, 'bin', 'nightly-refresh.mjs') }); } catch (error) { nightlyCode = { state: 'unknown', why: error.message }; }
3182
3188
  if (nightlyHealth.state === 'on') {
3183
3189
  ok(`nightly scheduler: ${nightlyHealth.evidence}`);
3190
+ if (nightlyCode.state === 'current') ok('nightly runs current code: the registered update modules are the installed set');
3191
+ else if (nightlyCode.fix) warn(`nightly runs ${nightlyCode.state === 'runner-changed' ? 'an older runner' : 'stale update modules'} — fix: ${c.bold(nightlyCode.fix)}`);
3184
3192
  if (nightlyHealth.runHealth?.state === 'ok' || nightlyHealth.runHealth?.state === 'running') {
3185
3193
  ok(`nightly execution: ${nightlyHealth.runHealth.evidence}`);
3186
3194
  } else if (nightlyHealth.runHealth?.state === 'failed') {
@@ -3436,6 +3444,10 @@ async function doctorRun({ json }) {
3436
3444
  : codexReadinessFailed ? 'MCP readiness blocked' : 'lifecycle hooks unhealthy') : 'wired', 'npx ruvnet-brain')] : []),
3437
3445
  check('nightly', 'Nightly', nightlyFailed, `${nightlyHealth.state}${nightlyHealth.runHealth?.state ? `, last run ${nightlyHealth.runHealth.state}` : ''}`,
3438
3446
  nightlyHealth.state === 'degraded' ? 'npx ruvnet-brain --enable-nightly' : 'npx ruvnet-brain --update'),
3447
+ // Scheduler ON but running yesterday's code: an advisory naming the exact fix, never a failing verdict.
3448
+ ...(nightlyHealth.state === 'on' && nightlyCode.state !== 'unregistered' ? [check('nightly-code', 'Nightly code', nightlyCode.state !== 'current',
3449
+ nightlyCode.state === 'current' ? 'registered update modules match the installed set' : `${nightlyCode.state}: registered ${String(nightlyCode.registered || '?').slice(0, 12)}, installed ${String(nightlyCode.current || '?').slice(0, 12)}`,
3450
+ nightlyCode.fix || 'npx ruvnet-brain@latest --update', { advisory: true })] : []),
3439
3451
  check('host-convergence', 'Hosts sync', !hostConvergence.healthy, hostConvergence.state, 'npx ruvnet-brain --update'),
3440
3452
  ...(rufloOperational ? [rufloCheckLine(rufloOperational)] : []),
3441
3453
  ...(agentdbLine ? [agentdbLine] : []),
@@ -4404,6 +4416,7 @@ async function runUpdate() {
4404
4416
  info(c.dim("\n--auto set: enrolling in Evergreen auto-update so you never run this again…\n"));
4405
4417
  enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
4406
4418
  }
4419
+ if (updateStatus === 0) reconcileNightlyModules({ brainHome, refreshReceipt });
4407
4420
  let retention;
4408
4421
  try {
4409
4422
  retention = pruneLifecycleEvidence({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
@@ -4443,6 +4456,31 @@ async function runUpdate() {
4443
4456
  await printConfirmation({ footprint: footprint?.after || null });
4444
4457
  }
4445
4458
 
4459
+ /**
4460
+ * After a successful --update: the nightly runs a content-addressed SNAPSHOT of the update modules (installNightlyRunner),
4461
+ * which until now only --enable-nightly refreshed, so a fix shipped by --update stayed invisible to the 03:30 job
4462
+ * (measured 2026-10-22: the 4.7.15 developer-update fix). A stale registration is re-pointed at the installed modules;
4463
+ * a changed RUNNER would change the LaunchAgent command, which only explicit enablement may do, so that case is an
4464
+ * advisory naming the fix. Advisory and fail-open: it never fails an otherwise good update.
4465
+ */
4466
+ export function reconcileNightlyModules({ brainHome, refreshReceipt = null, runnerSource = path.join(REPO_ROOT, 'bin', 'nightly-refresh.mjs') } = {}) {
4467
+ const advise = (status, detail) => { // the advisory write itself must never escape this step
4468
+ try { if (refreshReceipt) recordRefreshAdvisory(refreshReceipt, 'nightly-modules', status, detail); } catch { /* the receipt is evidence, not the gate */ }
4469
+ return { status, ...detail };
4470
+ };
4471
+ try {
4472
+ const nightly = refreshNightlyModules({ brainHome, runnerSource });
4473
+ if (nightly.state === 'refreshed') ok(`nightly scheduler now runs the installed update modules (${nightly.registered.slice(0, 16)}…)`);
4474
+ else if (nightly.state === 'runner-changed') warn(`nightly scheduler runs an older runner; the LaunchAgent changes only on explicit enablement — run ${c.bold(nightly.fix)}`);
4475
+ else if (nightly.state === 'refused') warn(`nightly module snapshot was not refreshed: ${nightly.reason} — run ${c.bold(nightly.fix)}`);
4476
+ return advise(['runner-changed', 'refused'].includes(nightly.state) ? 'SKIP' : 'PASS',
4477
+ { state: nightly.state, registered: nightly.registered ?? null, current: nightly.current ?? null, ...(nightly.reason ? { reason: nightly.reason } : {}), ...(nightly.fix ? { fix: nightly.fix } : {}) });
4478
+ } catch (error) {
4479
+ warn(`nightly module snapshot was not refreshed (${error.message}); the scheduler registration was left as it was`);
4480
+ return advise('FAIL', { state: 'error', reason: error.message });
4481
+ }
4482
+ }
4483
+
4446
4484
  function enableNightly() {
4447
4485
  printBanner('enable nightly updates');
4448
4486
  const kbDir = resolvedKbDir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.16",
3
+ "version": "4.7.17",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.7.16",
4
+ "version": "4.7.17",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.7.16",
3
+ "version": "4.7.17",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "RuvNet Brain lifecycle plane for Grok CLI. Grok selects this file through plugin/.grok-plugin/plugin.json (measured on grok 1.0.50: a plugin directory carrying that manifest registers ONLY this file, so the Claude registry plugin/hooks/hooks.json is not also run). Every command enters plugin/scripts/grok-hook-adapter.mjs, which normalizes the Grok payload, withholds Grok's non-Claude transcript, skips the session-end Stop fire, runs the SAME hook-shim.mjs bodies as Claude and Codex, and returns Grok-valid output. Registered: decision-gate write route (PreToolUse, the only refuser), session-snapshot and grounding-stamp (PostToolUse), grounding-turn-mark (UserPromptSubmit, a marker file only), continuation-gate + session-snapshot + grounding-turn-gate (Stop), session-snapshot (PreCompact, SessionEnd). Deliberately NOT registered on Grok: session-start, ground-ruvnet, unprompted-speech and capacity-aware-parallel-work. Grok ignores SessionStart stdout and discards an allowing UserPromptSubmit hook's stdout (~/.grok/docs/user-guide/10-hooks.md:113, :507, :531), so they would spend time on every session or prompt and deliver nothing, and session-start would consume the once-only hook-failure summary where nobody sees it. UNVERIFIED (docs silent, not measured): whether Grok honours a SessionEnd timeout above its 1.5 s default (10-hooks.md:477-478); the payload tool_name of an MCP search_ruvnet call and whether grounding-stamp accepts Grok's toolResult shape; PreCompact delivery (no compaction was driven); Windows expansion of ${GROK_PLUGIN_ROOT} (10-hooks.md:581 describes it; not measured).",
2
+ "description": "RuvNet Brain lifecycle plane for Grok CLI. Grok selects this file through plugin/.grok-plugin/plugin.json (measured on grok 1.0.50: a plugin directory carrying that manifest registers ONLY this file, so the Claude registry plugin/hooks/hooks.json is not also run). Every command enters plugin/scripts/grok-hook-adapter.mjs, which normalizes the Grok payload, withholds Grok's non-Claude transcript, skips the session-end Stop fire, runs the SAME hook-shim.mjs bodies as Claude and Codex, and returns Grok-valid output. Registered (9 handlers): decision-gate write route (PreToolUse, the only refuser registered on Grok), session-snapshot and grounding-stamp (PostToolUse), grounding-turn-mark (UserPromptSubmit, a marker file only), continuation-gate + session-snapshot + grounding-turn-gate (Stop), session-snapshot (PreCompact, SessionEnd). Deliberately NOT registered on Grok: session-start, ground-ruvnet, unprompted-speech and capacity-aware-parallel-work. Grok ignores SessionStart stdout and discards an allowing UserPromptSubmit hook's stdout (~/.grok/docs/user-guide/10-hooks.md:113, :507, :531), so they would spend time on every session or prompt and deliver nothing, and session-start would consume the once-only hook-failure summary where nobody sees it. SessionEnd: \"A plugin's SessionEnd hook runs for at most that default [1.5 s], whatever its timeout\" (10-hooks.md:644-645), so the 3 s timeout below is not honoured; the adapter budgets 1400 ms and hands it to the body as RUVNET_CODEX_BUDGET_MS, which detaches the checkpoint (the Codex 3 s SessionEnd path) instead of an inline store+readback Grok would kill partway. UNVERIFIED (docs silent, not measured): the payload tool_name of an MCP search_ruvnet call and whether grounding-stamp accepts Grok's toolResult shape; PreCompact delivery (no compaction was driven); Windows expansion of ${GROK_PLUGIN_ROOT} (10-hooks.md:581 describes it; not measured).",
3
3
  "hooks": {
4
4
  "PreToolUse": [
5
5
  {
@@ -28,7 +28,8 @@
28
28
  "class": "continuation nudge",
29
29
  "hosts": [
30
30
  "claude",
31
- "codex"
31
+ "codex",
32
+ "grok"
32
33
  ],
33
34
  "responsibility": "May request one continuation for one explicitly authorized, project-scoped objective read from the user's own work ledger (ADR-043). Never a refusal, never a second task store. Amended 2026-09-30 (ADR-074 class `completion`, no new registration): the same Stop body also requests ONE correction when the final answer claims work is done without a verification command run after the last state change this turn (Claude transcript), a named check, and a NOT-verified disclosure — on Codex, whose rollout format is not parsed, only the answer-side half is enforced and the transcript half is reported UNKNOWN. It also records first-person promises from the final answer into the SAME per-project ledger (Claude only; capped; closed only by a later evidence-passing completion claim, never by --done) and requests continuation on them under the same loop guards and cooldown. Amended 2026-10-11 (ADR-106, no new registration): the same body asks ONCE when a Claude answer ends by asking permission for, or postponing, work a standing instruction or open promise already authorized and names no genuine blocker class (no-deferral); Codex is not enforced because its prompts are not parsed."
34
35
  },
@@ -38,7 +39,8 @@
38
39
  "class": "continuity capture",
39
40
  "hosts": [
40
41
  "claude",
41
- "codex"
42
+ "codex",
43
+ "grok"
42
44
  ],
43
45
  "responsibility": "Write the redacted turn outcome with one bounded `ruflo memory store` in namespace turns and prove it by exact-key readback; a refusal is a hook-failures row. No journal, outbox or replay."
44
46
  },
@@ -48,7 +50,8 @@
48
50
  "class": "continuity capture",
49
51
  "hosts": [
50
52
  "claude",
51
- "codex"
53
+ "codex",
54
+ "grok"
52
55
  ],
53
56
  "responsibility": "Store one append-only project-state-auto-<ms> checkpoint through `ruflo memory store` and read it back by exact key; start turn distillation (`ruflo memory distill run`)."
54
57
  },
@@ -58,7 +61,8 @@
58
61
  "class": "continuity capture",
59
62
  "hosts": [
60
63
  "claude",
61
- "codex"
64
+ "codex",
65
+ "grok"
62
66
  ],
63
67
  "responsibility": "Store one append-only project-state-auto-<ms> checkpoint through `ruflo memory store` and read it back by exact key; start turn distillation (`ruflo memory distill run`)."
64
68
  },
@@ -88,7 +92,8 @@
88
92
  "class": "consequential-action authorization",
89
93
  "hosts": [
90
94
  "claude",
91
- "codex"
95
+ "codex",
96
+ "grok"
92
97
  ],
93
98
  "responsibility": "The ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067): composes protect-state, hijack-ruvnet, ground-before-write (ADR-0012), adr-currency and duplicate-code (2026-09-30: a new code file or large new export duplicating existing code in this checkout, refused at most once per path per session) into one decision with one reason. Fails open on its own errors. apply_patch is Codex's raw write-tool name, added to the shared matcher 2026-09-12 after a real apply_patch call was measured to fire PreToolUse live (codex-cli 0.154.0); codex-hook-adapter.mjs normalizes it to the same Edit shape decision-gate.mjs's policies already read on Claude."
94
99
  },
@@ -98,7 +103,8 @@
98
103
  "class": "grounding receipt",
99
104
  "hosts": [
100
105
  "claude",
101
- "codex"
106
+ "codex",
107
+ "grok"
102
108
  ],
103
109
  "responsibility": "After a SUCCESSFUL search_ruvnet, records which rUv products were consulted (a 24h stamp per term named in the query). It is the key that opens ground-before-write; without it the write gate would refuse every rUv-product write forever. Extended to Codex 2026-09-12: a real MCP search_ruvnet call fired PostToolUse with tool_name mcp__ruvnet_brain__search_ruvnet, which the existing matcher already recognized unchanged."
104
110
  },
@@ -108,7 +114,8 @@
108
114
  "class": "grounding turn marker",
109
115
  "hosts": [
110
116
  "claude",
111
- "codex"
117
+ "codex",
118
+ "grok"
112
119
  ],
113
120
  "responsibility": "Records that ground-ruvnet's Gate 1 (the same regex, proven byte-identical by test) matched this turn's prompt, so grounding-turn-gate has something to check at Stop — Stop's own payload carries no prompt text. Amended 2026-09-30: also arms when the prompt asks a capability/feasibility/architecture question about any tool or platform, recording the subjects it named; a marker not yet consumed is merged without moving its mtime (a queued mid-turn prompt re-dating it was a measured false-alarm cause). Writes only a marker file under ~/.cache/ruvnet-brain/grounding-turn/; never blocks, never speaks to the model."
114
121
  },
@@ -118,7 +125,8 @@
118
125
  "class": "answered-without-search enforcement",
119
126
  "hosts": [
120
127
  "claude",
121
- "codex"
128
+ "codex",
129
+ "grok"
122
130
  ],
123
131
  "responsibility": "The 'answered without searching' gate (2026-09-12): if grounding-turn-mark's marker shows Gate 1 fired this turn and grounding-stamp's own stamp evidence shows no search_ruvnet call happened since, forces continuation via the same hookSpecificOutput.additionalContext contract continuation-gate already uses on both hosts. A separate registration from continuation-gate — see grounding-turn-gate.mjs's header for why folding it into that file's ledger/cooldown semantics would corrupt them rather than extend them. Consumes its marker unconditionally so a stale one can never pressure an unrelated later turn. Amended 2026-09-30 (ADR-0030 #1): on Claude, 'was search_ruvnet called' is read from the transcript, and on an armed turn each capability claim in the final answer must be bound to a relevant strong source read this turn after the last weak one, else ONE correction naming the claim and what was read; on Codex (rollout not parsed) only rUv-term claims are judged, from stamps, and the rest are UNKNOWN. Gates #2/#3 are shadow-logged, never delivered."
124
132
  },
@@ -128,7 +136,8 @@
128
136
  "class": "normalized continuity observation",
129
137
  "hosts": [
130
138
  "claude",
131
- "codex"
139
+ "codex",
140
+ "grok"
132
141
  ],
133
142
  "responsibility": "When learning is opted in, write one minimized workflow observation with `ruflo memory store` + exact-key readback; a refusal is a hook-failures row. No queue."
134
143
  }
@@ -204,7 +213,8 @@
204
213
  "event": "Stop",
205
214
  "hosts": [
206
215
  "claude",
207
- "codex"
216
+ "codex",
217
+ "grok"
208
218
  ],
209
219
  "mode": "advisory",
210
220
  "offBehavior": "run",
@@ -216,7 +226,8 @@
216
226
  "event": "Stop",
217
227
  "hosts": [
218
228
  "claude",
219
- "codex"
229
+ "codex",
230
+ "grok"
220
231
  ],
221
232
  "mode": "advisory",
222
233
  "offBehavior": "run",
@@ -228,7 +239,8 @@
228
239
  "event": "PreCompact",
229
240
  "hosts": [
230
241
  "claude",
231
- "codex"
242
+ "codex",
243
+ "grok"
232
244
  ],
233
245
  "mode": "advisory",
234
246
  "offBehavior": "run",
@@ -240,7 +252,8 @@
240
252
  "event": "SessionEnd",
241
253
  "hosts": [
242
254
  "claude",
243
- "codex"
255
+ "codex",
256
+ "grok"
244
257
  ],
245
258
  "mode": "advisory",
246
259
  "offBehavior": "run",
@@ -276,7 +289,8 @@
276
289
  "event": "PreToolUse",
277
290
  "hosts": [
278
291
  "claude",
279
- "codex"
292
+ "codex",
293
+ "grok"
280
294
  ],
281
295
  "mode": "blocking",
282
296
  "offBehavior": "run",
@@ -288,7 +302,8 @@
288
302
  "event": "PostToolUse",
289
303
  "hosts": [
290
304
  "claude",
291
- "codex"
305
+ "codex",
306
+ "grok"
292
307
  ],
293
308
  "mode": "advisory",
294
309
  "offBehavior": "silence",
@@ -300,7 +315,8 @@
300
315
  "event": "UserPromptSubmit",
301
316
  "hosts": [
302
317
  "claude",
303
- "codex"
318
+ "codex",
319
+ "grok"
304
320
  ],
305
321
  "mode": "advisory",
306
322
  "offBehavior": "silence",
@@ -312,7 +328,8 @@
312
328
  "event": "Stop",
313
329
  "hosts": [
314
330
  "claude",
315
- "codex"
331
+ "codex",
332
+ "grok"
316
333
  ],
317
334
  "mode": "advisory",
318
335
  "offBehavior": "silence",
@@ -324,7 +341,8 @@
324
341
  "event": "PostToolUse",
325
342
  "hosts": [
326
343
  "claude",
327
- "codex"
344
+ "codex",
345
+ "grok"
328
346
  ],
329
347
  "mode": "advisory",
330
348
  "offBehavior": "run",
@@ -1,5 +1,5 @@
1
1
  {
2
- "description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. Lifecycle memory only CALLS ruflo (owner ruling 2026-10-12, ADR-106): there is no hand-rolled store, queue, journal or outbox. SessionStart restores the newest project-state-current-* checkpoint (and a newer project-state-auto-* one) and lists lesson keys by exact-key `ruflo memory list|retrieve`, and summarizes hook failures once. UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection (with one bounded AgentDB recall search), a capacity-aware context hint, and grounding-turn-mark. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate, and session-snapshot PostToolUse writes an opted-in learning observation with `ruflo memory store`. Stop runs the continuation gate, writes the turn outcome with `ruflo memory store` in namespace turns proven by exact-key readback, and runs grounding-turn-gate. PreCompact and SessionEnd store one append-only project-state-auto-<ms> checkpoint with exact-key readback and start `ruflo memory distill run`. Every body is advisory and fails open except decision-gate; a refused or unverified ruflo write, or a body stopped by the shim watchdog, is one row in hook-failures.jsonl, summarized at the next SessionStart.",
2
+ "description": "RuvNet Brain lifecycle plane. The broad legacy routing, learning, and release interceptors remain retired. Lifecycle memory only CALLS ruflo (owner ruling 2026-10-12, ADR-106): there is no hand-rolled store, queue, journal or outbox. SessionStart restores the newest project-state-current-* checkpoint (and a newer project-state-auto-* one) and lists lesson keys by exact-key `ruflo memory list|retrieve`, and summarizes hook failures once. UserPromptSubmit runs the single unprompted-speech chokepoint, ground-ruvnet grounding injection (with one bounded AgentDB recall search), a capacity-aware context hint, and grounding-turn-mark. PreToolUse runs decision-gate's write route, the ONE process that may refuse a Write/Edit/MultiEdit/NotebookEdit/apply_patch (ADR-067, ADR-0012); PostToolUse on a successful search_ruvnet mints the grounding stamp that opens that gate, and session-snapshot PostToolUse writes an opted-in learning observation with `ruflo memory store`. Stop runs the continuation gate, writes the turn outcome with `ruflo memory store` in namespace turns proven by exact-key readback, and runs grounding-turn-gate. PreCompact and SessionEnd store one append-only project-state-auto-<ms> checkpoint with exact-key readback and start `ruflo memory distill run`. Every body is advisory and fails open except decision-gate (refuses a write) and unprompted-speech (exit 2 for an opted-in lesson block at UserPromptSubmit); a refused or unverified ruflo write, or a body stopped by the shim watchdog, is one row in hook-failures.jsonl, summarized at the next SessionStart.",
3
3
  "hooks": {
4
4
  "SessionStart": [
5
5
  {
@@ -134,7 +134,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
134
134
  // The "answered without searching" gate, half 1 of 2 (2026-09-12) — see grounding-turn-gate.mjs's
135
135
  // header for the full rationale. Records that ground-ruvnet's Gate 1 fired for this turn, since
136
136
  // Stop's own payload carries no prompt text for grounding-turn-gate to test.
137
- registration('grounding-turn-mark', '*', ['claude', 'codex']),
137
+ registration('grounding-turn-mark', '*', ['claude', 'codex', 'grok']),
138
138
  ]),
139
139
  // The write gate and its key (ADR-0012 / ADR-067), re-registered 2026-09-11 — see the header.
140
140
  // Extended to Codex 2026-09-12: a real `apply_patch` write was measured to fire PreToolUse on
@@ -143,7 +143,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
143
143
  // Claude's `Edit` shape for decision-gate.mjs's own policies (protect-state, hijack-ruvnet,
144
144
  // ground-before-write, adr-currency all then see tool_name:"Edit" exactly as on Claude).
145
145
  PreToolUse: Object.freeze([
146
- registration('decision-gate', '^(Write|Edit|MultiEdit|NotebookEdit|apply_patch)$', ['claude', 'codex']),
146
+ registration('decision-gate', '^(Write|Edit|MultiEdit|NotebookEdit|apply_patch)$', ['claude', 'codex', 'grok']),
147
147
  ]),
148
148
  // Extended to Codex 2026-09-12: a real MCP `search_ruvnet` call was measured to fire PostToolUse
149
149
  // on codex-cli 0.154.0 with tool_name "mcp__ruvnet_brain__search_ruvnet" — the matcher below
@@ -153,18 +153,18 @@ export const CONTINUITY_EVENTS = Object.freeze({
153
153
  // "Searched N RuvNet repos" banner and query text the script already looks for).
154
154
  PostToolUse: Object.freeze([
155
155
  // Learning observation (consent-gated `ruflo memory store`) runs inside session-snapshot's PostToolUse body.
156
- registration('session-snapshot', '*', ['claude', 'codex']),
157
- registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex']),
156
+ registration('session-snapshot', '*', ['claude', 'codex', 'grok']),
157
+ registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex', 'grok']),
158
158
  ]),
159
159
  Stop: Object.freeze([
160
- registration('continuation-gate', '*', ['claude', 'codex']),
160
+ registration('continuation-gate', '*', ['claude', 'codex', 'grok']),
161
161
  // Codex added 2026-09-29 (owner requirement: every turn's outcome recorded on BOTH hosts —
162
162
  // turn-outcome-capture.mjs). Codex SessionEnd carries no last_assistant_message, so Stop is the
163
163
  // only boundary that can record a Codex turn. Evidence: codex-cli 0.158.0's own
164
164
  // stop.command.input schema (last_assistant_message, turn_id) and the two Codex Stop handlers
165
165
  // already registered above/below. NOT yet live-observed firing — see the probe box: the capture
166
166
  // fails open, so an unfired registration costs nothing but must not be read as proof.
167
- registration('session-snapshot', '*', ['claude', 'codex']),
167
+ registration('session-snapshot', '*', ['claude', 'codex', 'grok']),
168
168
  // The "answered without searching" gate, half 2 of 2 (2026-09-12). Forces continuation
169
169
  // (hookSpecificOutput.additionalContext — the same contract continuation-gate.mjs already uses
170
170
  // and codex-hook-adapter.mjs already translates to Codex's decision:block on both hosts) when
@@ -172,13 +172,13 @@ export const CONTINUITY_EVENTS = Object.freeze({
172
172
  // (grounding-stamp.sh) postdates it. A separate registration from continuation-gate on purpose —
173
173
  // see grounding-turn-gate.mjs's header for why folding it in would corrupt that file's ledger
174
174
  // semantics rather than extend them.
175
- registration('grounding-turn-gate', '*', ['claude', 'codex']),
175
+ registration('grounding-turn-gate', '*', ['claude', 'codex', 'grok']),
176
176
  ]),
177
177
  PreCompact: Object.freeze([
178
- registration('session-snapshot', '*', ['claude', 'codex']),
178
+ registration('session-snapshot', '*', ['claude', 'codex', 'grok']),
179
179
  ]),
180
180
  SessionEnd: Object.freeze([
181
- registration('session-snapshot', '*', ['claude', 'codex']),
181
+ registration('session-snapshot', '*', ['claude', 'codex', 'grok']),
182
182
  ]),
183
183
  });
184
184
 
@@ -48,11 +48,14 @@ export const GROK_OUTPUT_EVENTS = Object.freeze(new Set(['PreToolUse', 'PostTool
48
48
 
49
49
  /**
50
50
  * This adapter's own kill budget, below the registration timeout in grok-hooks.json so the adapter,
51
- * not Grok, ends a slow body and records it. SessionEnd: Grok bounds each SessionEnd hook by its own
52
- * timeout (default 1.5 s, 10-hooks.md:477-478); 2200 ms sits under the 3 s registration.
51
+ * not Grok, ends a slow body and records it. SessionEnd: "A plugin's SessionEnd hook runs for at most
52
+ * that default [1.5 s], whatever its timeout" (10-hooks.md:644-645), so the budget sits under 1500 ms and
53
+ * is handed to the body (RUVNET_CODEX_BUDGET_MS, the host-budget seam the Codex wrapper already uses), which
54
+ * then detaches its checkpoint instead of starting an inline store+readback Grok would kill partway.
53
55
  */
56
+ export const SESSION_END_BUDGET_MS = 1_400;
54
57
  export function budgetFor(hookId, event) {
55
- if (event === 'SessionEnd') return 2_200;
58
+ if (event === 'SessionEnd') return SESSION_END_BUDGET_MS;
56
59
  if (hookId === 'decision-gate') return 6_500;
57
60
  if (hookId === 'grounding-stamp' || hookId === 'grounding-turn-mark') return 4_000;
58
61
  return 8_500;
@@ -133,6 +136,7 @@ async function main() {
133
136
  CLAUDE_PROJECT_DIR: projectDir,
134
137
  CLAUDE_SESSION_ID: String(payload.session_id || process.env.GROK_SESSION_ID || ''),
135
138
  RUVNET_HOOK_HOST: 'grok',
139
+ ...(event === 'SessionEnd' ? { RUVNET_CODEX_BUDGET_MS: String(budget) } : {}), // the body plans inside Grok's 1.5 s
136
140
  };
137
141
  const r = spawnSync(process.execPath, [path.join(HERE, 'hook-shim.mjs'), hookId, ...extra], {
138
142
  input: JSON.stringify(payload), encoding: 'utf8', env, cwd, timeout: budget, killSignal: 'SIGKILL',
@@ -9,16 +9,21 @@
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
  import os from 'node:os';
12
+ import { createHash } from 'node:crypto';
12
13
 
13
14
  export const FAILURE_FILE = 'hook-failures.jsonl';
14
15
  export const FAILURE_LINE_PREFIX = '[RuvNet Brain — HOOK FAILURES]';
15
16
  const CURSOR_FILE = 'hook-failures.cursor';
17
+ const WINDOW_FILE = 'hook-failures.window.json';
16
18
  const MAX_READ = 256 * 1024;
19
+ export const REPEAT_WINDOW_MS = 10 * 60_000;
20
+ const MAX_WINDOWS = 64;
17
21
 
18
22
  /** Summarize failures recorded after the cursor; advance it. Returns '' when there is nothing new. */
19
- export function failureLine(brainHome, { advance = true } = {}) {
23
+ export function failureLine(brainHome, { advance = true, now = Date.now() } = {}) {
20
24
  const file = path.join(brainHome, FAILURE_FILE);
21
25
  const cursorFile = path.join(brainHome, CURSOR_FILE);
26
+ try { flushRepeats(brainHome, now, { all: true }); } catch { /* the rows already on disk are still reported */ } // the report is now
22
27
  let size;
23
28
  try { size = fs.statSync(file).size; } catch { return ''; }
24
29
  let offset = 0;
@@ -33,29 +38,92 @@ export function failureLine(brainHome, { advance = true } = {}) {
33
38
  if (advance) { try { fs.writeFileSync(cursorFile, JSON.stringify({ offset: size })); } catch { /* re-reported next session */ } }
34
39
  if (!rows.length) return '';
35
40
  const byHook = new Map();
41
+ let total = 0;
36
42
  for (const r of rows) {
37
43
  const id = String(r.hook || 'unknown').slice(0, 40);
38
- const why = r.note ? String(r.note).split(':')[0].slice(0, 48) : r.error ? String(r.error).slice(0, 40) : r.signal ? String(r.signal) : `exit ${r.status}`;
44
+ const why = r.note ? String(r.note).split(':')[0].slice(0, 48) : r.error ? String(r.error).replace(/ \(repeated\)$/, '').slice(0, 40) : r.signal ? String(r.signal) : `exit ${r.status}`;
39
45
  const e = byHook.get(id) || { n: 0, why: new Set() };
40
- e.n += 1; e.why.add(why); byHook.set(id, e);
46
+ const n = Number(r.count) > 0 ? Number(r.count) : 1; // an aggregated row stands for `count` runs
47
+ e.n += n; total += n; e.why.add(why); byHook.set(id, e);
41
48
  }
42
49
  const parts = [...byHook.entries()].sort((a, b) => b[1].n - a[1].n)
43
50
  .map(([id, e]) => `${id} ×${e.n} (${[...e.why].slice(0, 3).join(', ')})`);
44
- return `${FAILURE_LINE_PREFIX} ${rows.length} advisory hook run(s) did not complete or did not resolve since ${rows[0].at}: ${parts.join('; ')}. `
51
+ return `${FAILURE_LINE_PREFIX} ${total} advisory hook run(s) did not complete or did not resolve since ${rows[0].at}: ${parts.join('; ')}. `
45
52
  + `They failed open. Details: ${file}`;
46
53
  }
47
54
 
55
+ function appendRow(brainHome, row) {
56
+ const file = path.join(brainHome, FAILURE_FILE);
57
+ fs.mkdirSync(brainHome, { recursive: true });
58
+ try { if (fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`); } catch { /* absent */ }
59
+ fs.appendFileSync(file, `${JSON.stringify(row)}\n`, { mode: 0o600 });
60
+ }
61
+
62
+ /** Hashes and long numbers vary per row (`workflow-<sha>`, pids, ms, timestamps); short ones (exit codes, counts) are the error. */
63
+ export const normalizeFailure = (text) => String(text || '').replace(/[0-9a-f]{16,}/gi, '*').replace(/\d{5,}/g, 'N').slice(0, 160);
64
+ const windowFile = (brainHome) => path.join(brainHome, WINDOW_FILE);
65
+ /**
66
+ * A window entry must be one this module could have written: a real epoch `at` no later than one window ahead (a
67
+ * Date cannot hold -1e300 or 1e300, and 8.64e15+1 never expires), an integer count ≥ 1, string hook and error.
68
+ * Entries failing that are dropped, the rest kept; any other file shape is an empty window file.
69
+ */
70
+ const validWindow = (w, now) => w && typeof w === 'object' && !Array.isArray(w) && Number.isFinite(w.at) && w.at >= 0 && w.at <= now + REPEAT_WINDOW_MS
71
+ && Number.isInteger(w.count) && w.count >= 1 && typeof w.hook === 'string' && typeof w.error === 'string';
72
+ function loadWindows(brainHome, now) {
73
+ try {
74
+ const parsed = JSON.parse(fs.readFileSync(windowFile(brainHome), 'utf8'));
75
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
76
+ return Object.fromEntries(Object.entries(parsed).filter(([, w]) => validWindow(w, now)));
77
+ } catch { return {}; }
78
+ }
79
+ function saveWindows(brainHome, windows) { // rename, so a concurrent reader never sees a torn file
80
+ const tmp = `${windowFile(brainHome)}.${process.pid}`;
81
+ fs.writeFileSync(tmp, JSON.stringify(windows), { mode: 0o600 }); fs.renameSync(tmp, windowFile(brainHome));
82
+ }
83
+
84
+ /** A closed window whose repeats were held back becomes ONE row carrying their count. */
85
+ function closeWindow(brainHome, w, now) {
86
+ if (w.count > 1) appendRow(brainHome, { at: new Date(now).toISOString(), hook: w.hook, event: w.event, error: `${w.error} (repeated)`, count: w.count - 1, since: new Date(w.at).toISOString() });
87
+ }
88
+
89
+ /** Close the expired windows; `all` closes the open ones too (SessionStart reports now). Returns the windows left open. */
90
+ function flushRepeats(brainHome, now, { all = false } = {}) {
91
+ const windows = loadWindows(brainHome, now);
92
+ let changed = false;
93
+ for (const [sig, w] of Object.entries(windows)) {
94
+ if (!all && now - w.at < REPEAT_WINDOW_MS) continue;
95
+ try { closeWindow(brainHome, w, now); } catch { /* one bad window must not stop the others being reported */ }
96
+ delete windows[sig]; changed = true;
97
+ }
98
+ if (changed) try { saveWindows(brainHome, windows); } catch { /* the rows are written; the window file is a cache */ }
99
+ return windows;
100
+ }
101
+
48
102
  /**
49
103
  * The writer for bodies that run OUTSIDE the shim's watchdog (a refused `ruflo memory store`, a failed
50
104
  * exact readback). Same file, same row shape as hook-shim.mjs's inlined recordFailure, so the next
51
105
  * SessionStart's failureLine() reports it. Never throws: an unrecordable failure must not become a second one.
106
+ *
107
+ * REPEATS ARE COUNTED, NOT APPENDED (measured 2026-10-21: 1,162 rows, 826 of them one learn-capture failure
108
+ * repeated, so the real failures were invisible). The first occurrence of (hook, event, normalized error)
109
+ * is a row at once; repeats inside REPEAT_WINDOW_MS only increment a count in hook-failures.window.json,
110
+ * and the window closes as ONE row carrying `count`. Distinct errors keep distinct rows. The window file has no
111
+ * lock: two writers racing can lose an increment (under-count), and two processes closing the same expired window
112
+ * can both write its count row (over-count). Tolerated: the ledger is a signal, never an audit. The first row is
113
+ * appended BEFORE its window is saved, so a failed append never leaves a window claiming a row that is not there.
52
114
  */
53
- export function recordHookFailure(fields, { brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain') } = {}) {
115
+ export function recordHookFailure(fields, { brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), now = Date.now() } = {}) {
54
116
  try {
55
- const file = path.join(brainHome, FAILURE_FILE);
56
117
  fs.mkdirSync(brainHome, { recursive: true });
57
- try { if (fs.statSync(file).size > 256 * 1024) fs.renameSync(file, `${file}.1`); } catch { /* absent */ }
58
- fs.appendFileSync(file, `${JSON.stringify({ at: new Date().toISOString(), ...fields })}\n`, { mode: 0o600 });
118
+ const windows = flushRepeats(brainHome, now);
119
+ const error = normalizeFailure(fields.error ?? fields.note ?? (fields.signal || `exit ${fields.status}`));
120
+ const sig = createHash('sha256').update(`${fields.hook}\0${fields.event || ''}\0${error}`).digest('hex').slice(0, 16);
121
+ if (windows[sig]) { windows[sig].count += 1; saveWindows(brainHome, windows); return true; }
122
+ appendRow(brainHome, { at: new Date(now).toISOString(), ...fields });
123
+ windows[sig] = { at: now, count: 1, hook: fields.hook, event: fields.event || '', error };
124
+ const oldest = Object.keys(windows).sort((a, b) => windows[a].at - windows[b].at).slice(0, Math.max(0, Object.keys(windows).length - MAX_WINDOWS));
125
+ for (const old of oldest) { closeWindow(brainHome, windows[old], now); delete windows[old]; } // bounded: the oldest close early
126
+ try { saveWindows(brainHome, windows); } catch { /* the row is on disk; the next repeat starts a fresh window */ }
59
127
  return true;
60
128
  } catch { return false; }
61
129
  }
@@ -1,14 +1,66 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
+ import path from 'node:path';
4
5
  import { spawn } from 'node:child_process';
5
6
  import { fileURLToPath } from 'node:url';
6
7
  import { readStdinBounded } from './hook-input.mjs';
7
8
  import { learningContext } from './runtime-preferences.mjs';
8
- import { recordLearningObservation, distillObservations } from './learning-store.mjs';
9
+ import { recordLearningObservation, distillObservations, observationKey } from './learning-store.mjs';
9
10
  import { resolveRuflo } from './ruflo-bin.mjs';
10
11
  import { recordHookFailure } from './hook-failures.mjs';
11
12
 
13
+ // LOAD BOUNDS (measured 2026-10-21: one busy session spawned hundreds of concurrent detached `ruflo memory store`
14
+ // children, which timed out against each other and wrote 826 ledger rows in a day). Owner ruling 2026-10-12 stands:
15
+ // no local queue, no replay — an observation that finds no slot is DROPPED and counted.
16
+ export const OBSERVE_SLOTS = 2; // detached --observe children alive at once, per Brain home
17
+ export const SLOT_STALE_MS = 60_000; // a slot older than this, or held by a dead pid, is reclaimed
18
+ export const COALESCE_MS = 10 * 60_000; // an identical observation key inside this window does not spawn again
19
+ const defaultBrainHome = (env) => env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
20
+ const learnDir = (brainHome, sub) => { const dir = path.join(brainHome, 'learn-capture', sub); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); return dir; };
21
+
22
+ /** Parent-side coalescing: marker file per key under `learn-capture/recent/`, pruned when it grows past 256 entries. */
23
+ export function recentlyCaptured(brainHome, key, now = Date.now()) {
24
+ try { return now - fs.statSync(path.join(learnDir(brainHome, 'recent'), key)).mtimeMs < COALESCE_MS; } catch { return false; }
25
+ }
26
+ export function markCaptured(brainHome, key, now = Date.now()) {
27
+ const dir = learnDir(brainHome, 'recent');
28
+ try { fs.writeFileSync(path.join(dir, key), '', { mode: 0o600 }); } catch { return; }
29
+ const names = fs.readdirSync(dir);
30
+ if (names.length > 256) for (const name of names) { try { if (now - fs.statSync(path.join(dir, name)).mtimeMs >= COALESCE_MS) fs.unlinkSync(path.join(dir, name)); } catch { /* raced */ } }
31
+ }
32
+
33
+ function slotStale(slot, now) {
34
+ let held;
35
+ try { held = JSON.parse(fs.readFileSync(slot, 'utf8')); } catch { try { return now - fs.statSync(slot).mtimeMs > SLOT_STALE_MS; } catch { return false; } }
36
+ if (!Number.isInteger(held?.pid) || now - Number(held.at) > SLOT_STALE_MS) return true;
37
+ try { process.kill(held.pid, 0); return false; } catch (error) { return error.code === 'ESRCH'; }
38
+ }
39
+ /** O_EXCL slot files `learn-capture/slots/slot-N`; a stale one is reclaimed by rename, so only one claimant wins it. */
40
+ export function acquireObserveSlot(brainHome, now = Date.now()) {
41
+ const dir = learnDir(brainHome, 'slots');
42
+ for (let i = 0; i < OBSERVE_SLOTS; i += 1) {
43
+ const slot = path.join(dir, `slot-${i}`);
44
+ for (let attempt = 0; attempt < 2; attempt += 1) {
45
+ try { fs.writeFileSync(slot, JSON.stringify({ pid: process.pid, at: now }), { flag: 'wx', mode: 0o600 }); return slot; } catch (error) { if (error.code !== 'EEXIST') break; }
46
+ if (!slotStale(slot, now)) break;
47
+ const claim = `${slot}.reclaim-${process.pid}`;
48
+ try { fs.renameSync(slot, claim); fs.unlinkSync(claim); } catch { break; } // another claimant took it
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+ export function releaseObserveSlot(slot, pid = process.pid) {
54
+ try { if (JSON.parse(fs.readFileSync(slot, 'utf8')).pid === pid) fs.unlinkSync(slot); } catch { /* reclaimed already */ }
55
+ }
56
+
57
+ /** Every shed is one fixed-string ledger call; hook-failures.mjs's own window counts the repeats on one row. */
58
+ export const SHED_ERROR = 'shed observation (load)';
59
+ export function shedObservation(brainHome) {
60
+ recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: SHED_ERROR }, { brainHome });
61
+ return false;
62
+ }
63
+
12
64
  // Fixed vocabulary: arbitrary executable names, arguments, basenames and paths never persist.
13
65
  const verbs = new Set('git npm node python python3 cargo rustc go gh cd ls rg cat export mkdir rm cp mv bash sh curl wget npx ruflo'.split(' '));
14
66
  const subs = new Set('status diff log show add commit push pull fetch checkout switch branch test run install ci build check fmt clippy auth workflow release memory hooks'.split(' '));
@@ -30,7 +82,7 @@ export function safeAction(tool, value) {
30
82
  * session-snapshot's PostToolUse boundary. learningContext() owns consent and brain-off. A refused or
31
83
  * unverified write is LOUD: one row in the hook-failures ledger, summarized at the next SessionStart.
32
84
  */
33
- export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome, detach = false } = {}) {
85
+ export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome = defaultBrainHome(env), detach = false } = {}) {
34
86
  let tool; let action;
35
87
  try {
36
88
  const context = learningContext();
@@ -48,24 +100,37 @@ export function captureLearning(input, { env = process.env, binary = resolveRufl
48
100
  if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) return false;
49
101
  action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
50
102
  if (!action) return false;
51
- if (!binary) throw new Error('ruflo not found');
103
+ if (!binary) throw new Error('ruflo not found'); // one ledger row per 10 min: recordHookFailure counts the repeats
52
104
  const observation = { tool, action, session: String(input.session_id || '') };
105
+ const key = observationKey(observation);
106
+ if (recentlyCaptured(brainHome, key)) return true; // identical observation already captured this window: no process
53
107
  // Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
54
108
  // tool call returns at once; that child writes the ledger row if the write is refused or unverified.
55
- if (detach) return detachLearning('--observe', { env, brainHome, observation });
109
+ if (detach) {
110
+ const slot = acquireObserveSlot(brainHome);
111
+ if (!slot) return shedObservation(brainHome);
112
+ markCaptured(brainHome, key);
113
+ return detachLearning('--observe', { env: { ...env, RUFLO_BIN: binary }, brainHome, observation, slot });
114
+ }
56
115
  recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
116
+ markCaptured(brainHome, key);
57
117
  return true;
58
118
  } catch (error) {
59
- if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) }, brainHome ? { brainHome } : undefined);
119
+ if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) }, { brainHome });
60
120
  return false;
61
121
  }
62
122
  }
63
123
 
64
- /** Detached, bounded child (`--observe` one observation, `--distill` the namespace). Only fixed-vocabulary fields cross. */
65
- export function detachLearning(mode, { env = process.env, brainHome, observation } = {}) {
124
+ /**
125
+ * Detached, bounded child (`--observe` one observation, `--distill` the namespace). Only fixed-vocabulary fields
126
+ * cross. The parent's resolved ruflo travels as RUFLO_BIN (resolveRuflo's authoritative seam), so the child never
127
+ * re-resolves it; the slot it holds travels as RUVNET_LEARN_SLOT and is re-stamped with the child's pid.
128
+ */
129
+ export function detachLearning(mode, { env = process.env, brainHome, observation, slot } = {}) {
66
130
  const child = spawn(process.execPath, [fileURLToPath(import.meta.url), mode], { cwd: process.cwd(), detached: true, stdio: 'ignore', windowsHide: true,
67
- env: { ...env, ...(observation ? { RUVNET_LEARN_OBSERVATION: JSON.stringify(observation) } : {}), ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}) } });
131
+ env: { ...env, ...(observation ? { RUVNET_LEARN_OBSERVATION: JSON.stringify(observation) } : {}), ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(slot ? { RUVNET_LEARN_SLOT: slot } : {}) } });
68
132
  child.unref();
133
+ if (slot) { try { fs.writeFileSync(slot, JSON.stringify({ pid: child.pid, at: Date.now() }), { mode: 0o600 }); } catch { /* reclaimed by age */ } }
69
134
  return true;
70
135
  }
71
136
 
@@ -80,7 +145,7 @@ function runDetached(mode) {
80
145
  const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
81
146
  if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
82
147
  recordLearningObservation(binary, context, { tool: o.tool, action: o.action, session: String(o.session || '') }, { env: process.env, deadline: Date.now() + 14_000, allowed: () => learningContext().enabled });
83
- } catch (error) { fail(error); }
148
+ } catch (error) { fail(error); } finally { if (process.env.RUVNET_LEARN_SLOT) releaseObserveSlot(process.env.RUVNET_LEARN_SLOT); }
84
149
  }
85
150
 
86
151
  const isMain = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
@@ -42,22 +42,32 @@ export function runLearningRuflo(binary, args, db, { env = process.env, deadline
42
42
  * No local queue, no worker, no replay (owner ruling 2026-10-12): a failure throws to the caller, which
43
43
  * records it in the hook-failures ledger so SessionStart reports it.
44
44
  */
45
+ // DETERMINISTIC per (session, tool, action) (review N1): with --no-upsert, rUv's store itself dedupes, so a
46
+ // session holds at most one row per distinct pair and concurrent writers of the same pair collapse.
47
+ export const observationKey = ({ session = '', tool, action }) => 'workflow-' + createHash('sha256').update(JSON.stringify([String(session), tool, action])).digest('hex');
48
+
45
49
  export function recordLearningObservation(binary, context, { tool, action, session = '' }, options = {}) {
46
50
  const db = learningTarget(context, options);
47
- // DETERMINISTIC per (session, tool, action) (review N1): with --no-upsert, rUv's store itself dedupes, so a
48
- // session holds at most one row per distinct pair and concurrent writers of the same pair collapse.
49
- const key = 'workflow-' + createHash('sha256').update(JSON.stringify([session, tool, action])).digest('hex');
51
+ const key = observationKey({ session, tool, action });
50
52
  const value = JSON.stringify({ schemaVersion: 1, tool, action, scope: context.scope,
51
53
  authoritative: false, provenance: 'system-observation', outcome: 'host-reported-success' });
52
54
  const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
53
55
  const stored = run(['memory', 'store', '--key', key, '--value', value, '--namespace', LEARNING_NAMESPACE,
54
56
  '--no-upsert', '--provenance', 'system_observation', '--path', db]);
55
57
  const retrieved = run(['memory', 'retrieve', '--key', key, '--namespace', LEARNING_NAMESPACE, '--value-only', '--path', db]);
56
- const exact = !retrieved.error && retrieved.status === 0 && String(retrieved.stdout || '').trim() === value;
57
- // A refused store whose key already holds these exact bytes is the dedupe working, not a failure.
58
+ const readBack = !retrieved.error && retrieved.status === 0 ? String(retrieved.stdout || '').trim() : null;
59
+ const exact = readBack === value;
60
+ // A refused store whose key already holds these exact bytes is the dedupe working, not a failure (idempotent).
61
+ // Measured 2026-10-21: 141 ledger rows said "already exists" because the readback itself had timed out, so the
62
+ // row blamed the store; the reason names which step failed, and a key holding DIFFERENT bytes stays a failure.
58
63
  if (stored.error || stored.status !== 0) {
59
64
  if (exact) return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true, alreadyStored: true };
60
- throw new Error(`ruflo memory store refused the observation: ${captureFailureReason(stored, stored.status)}`);
65
+ const reason = captureFailureReason(stored, stored.status);
66
+ if (/already exists/i.test(reason)) {
67
+ if (readBack !== null) throw new Error('duplicate key holds a different observation');
68
+ throw new Error(`exact readback of the existing key failed: ${captureFailureReason(retrieved, retrieved.status)}`);
69
+ }
70
+ throw new Error(`ruflo memory store refused the observation: ${reason}`);
61
71
  }
62
72
  if (!exact) throw new Error('canonical exact CLI readback failed');
63
73
  return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true };
@@ -147,17 +147,23 @@ const BRIDGED_UPDATE_MODULES = [...HISTORICAL_UPDATE_MODULES, 'developer-update-
147
147
  const LEGACY_UPDATE_MODULES = [...BRIDGED_UPDATE_MODULES, 'developer-update-policy.mjs'];
148
148
  const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs')];
149
149
  const DEVELOPER_UPDATE_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES])];
150
- function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url)), developerSuite = true) {
150
+ /** The shipped module set and the content digest it is registered under (`update-modules-<digest>`); reads only. */
151
+ function readModuleSet(sourceDir, developerSuite) {
151
152
  const files = developerSuite ? DEVELOPER_UPDATE_MODULES : LEGACY_UPDATE_MODULES;
152
153
  const bytes = Object.fromEntries(files.map(file => [file, fs.readFileSync(path.join(sourceDir, file))]));
153
- const digest = sha256(Buffer.concat(files.flatMap(file => [Buffer.from(file), bytes[file]])));
154
+ return { files, bytes, digest: sha256(Buffer.concat(files.flatMap(file => [Buffer.from(file), bytes[file]]))) };
155
+ }
156
+ const registeredModuleDigest = (record) => /^update-modules-([a-f0-9]{64})$/.exec(path.basename(path.dirname(record.updateModules?.['automatic-update.mjs']?.path || '')))?.[1] || null;
157
+
158
+ function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url)), developerSuite = true, { write = true } = {}) {
159
+ const { files, bytes, digest } = readModuleSet(sourceDir, developerSuite);
154
160
  const target = path.join(directory, `update-modules-${digest}`);
155
- fs.mkdirSync(target, { recursive: true });
161
+ if (write) fs.mkdirSync(target, { recursive: true });
156
162
  return Object.fromEntries(files.map(file => {
157
163
  const dest = path.join(target, file);
158
164
  const hash = sha256(bytes[file]);
159
- if (fs.existsSync(dest) && sha256(fs.readFileSync(dest)) !== hash) throw new Error('immutable update module was changed');
160
- if (!fs.existsSync(dest)) fs.writeFileSync(dest, bytes[file], { mode: 0o600 });
165
+ if (write && fs.existsSync(dest) && sha256(fs.readFileSync(dest)) !== hash) throw new Error('immutable update module was changed');
166
+ if (write && !fs.existsSync(dest)) fs.writeFileSync(dest, bytes[file], { mode: 0o600 });
161
167
  return [file, { path: dest, sha256: hash }];
162
168
  }));
163
169
  }
@@ -174,31 +180,80 @@ function verifyUpdateModules(modules) {
174
180
  }
175
181
  }
176
182
 
183
+ /**
184
+ * The ONE writer of a nightly registration. `dryRun` builds the exact record it WOULD write (runner, modules, record)
185
+ * and writes nothing — the refresh path compares that candidate before any rename. `environment`: undefined derives
186
+ * the allowlisted values from env; an object is kept verbatim; null omits the field (an older registration without it).
187
+ */
177
188
  export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
178
- identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity) } = {}) {
189
+ identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, environment, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity), dryRun = false } = {}) {
179
190
  validateIdentity(identity);
180
191
  if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
181
192
  const bytes = fs.readFileSync(source);
182
193
  const digest = sha256(bytes);
183
194
  const dir = path.join(brainHome, 'scheduler');
184
195
  const runnerPath = path.join(dir, `nightly-refresh-${digest}.mjs`);
185
- fs.mkdirSync(dir, { recursive: true });
186
- if (fs.existsSync(runnerPath) && sha256(fs.readFileSync(runnerPath)) !== digest) {
187
- throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
196
+ if (!dryRun) {
197
+ fs.mkdirSync(dir, { recursive: true });
198
+ if (fs.existsSync(runnerPath) && sha256(fs.readFileSync(runnerPath)) !== digest) {
199
+ throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
200
+ }
201
+ if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
188
202
  }
189
- if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
190
- const modules = updateModules(dir, moduleSource, developerSuite);
203
+ const modules = updateModules(dir, moduleSource, developerSuite, { write: !dryRun });
191
204
  const record = { updateModules: modules, ...(developerSuite ? { mode: 'developer-suite' } : {}), schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
192
205
  nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
193
- environment: Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])),
206
+ ...(environment === null ? {} : { environment: environment ?? Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])) }),
194
207
  packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity) };
195
208
  const recordPath = path.join(dir, registrationName(identity));
209
+ if (dryRun) return { ...record, recordPath };
196
210
  const tmp = `${recordPath}.tmp-${process.pid}`;
197
211
  fs.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`);
198
212
  fs.renameSync(tmp, recordPath);
199
213
  return { ...record, recordPath };
200
214
  }
201
215
 
216
+ export const NIGHTLY_ENABLE_FIX = 'npx ruvnet-brain@latest --enable-nightly';
217
+ export const NIGHTLY_UPDATE_FIX = 'npx ruvnet-brain@latest --update';
218
+ /**
219
+ * Does the registered nightly run the code installed now? The scheduler executes a content-addressed snapshot of
220
+ * the update modules that only --enable-nightly used to refresh (measured 2026-10-22: the 4.7.15 developer-update
221
+ * fix shipped to disk and the 03:30 job still ran the old module). Pure read: `current`, `stale` (modules only;
222
+ * --update refreshes them), `runner-changed` (the LaunchAgent command would change: explicit enablement only),
223
+ * or `unregistered`.
224
+ */
225
+ export function nightlyModuleCurrency({ brainHome, identity = NIGHTLY_LABEL, moduleSource = path.dirname(fileURLToPath(import.meta.url)), runnerSource } = {}) {
226
+ const registration = readNightlyRegistration({ brainHome, identity });
227
+ if (!registration.ok) return { state: 'unregistered', why: registration.why };
228
+ const record = registration.record;
229
+ const registered = registeredModuleDigest(record);
230
+ if (!registered) return { state: 'unregistered', why: 'registration carries no module snapshot', fix: NIGHTLY_ENABLE_FIX };
231
+ const current = readModuleSet(moduleSource, record.mode === 'developer-suite').digest;
232
+ if (runnerSource && sha256(fs.readFileSync(runnerSource)) !== record.runnerSha256) return { state: 'runner-changed', registered, current, record, fix: NIGHTLY_ENABLE_FIX };
233
+ return registered === current ? { state: 'current', registered, current, record } : { state: 'stale', registered, current, record, fix: NIGHTLY_UPDATE_FIX };
234
+ }
235
+
236
+ /**
237
+ * After a successful --update: re-point a stale registration at the installed module set. Same identity, node,
238
+ * runner bytes, environment and targets, so only registration.json and a new update-modules-<digest> dir change
239
+ * and the LaunchAgent command stays exactly what it was; anything else is refused and left as it was.
240
+ */
241
+ export const SCHEDULER_VISIBLE_FIELDS = Object.freeze(['identity', 'nodePath', 'runnerPath', 'runnerSha256', 'argv', 'environment', 'packageTarget', 'bundleTarget', 'mode']);
242
+ export function refreshNightlyModules(options = {}) {
243
+ const currency = nightlyModuleCurrency(options);
244
+ if (currency.state !== 'stale') return currency;
245
+ const { record } = currency;
246
+ const plan = { brainHome: options.brainHome, identity: record.identity, source: record.runnerPath, nodePath: record.nodePath, packageTarget: record.packageTarget,
247
+ bundleTarget: record.bundleTarget, environment: record.environment ?? null, moduleSource: options.moduleSource, developerSuite: record.mode === 'developer-suite' };
248
+ // Compare the candidate BEFORE anything is written (reviewer proof 2026-10-22: an older registration without
249
+ // `environment` was rewritten with {} and only then refused). `build` is the dry-run of the one writer.
250
+ const candidate = (options.build || ((p) => installNightlyRunner({ ...p, dryRun: true })))(plan);
251
+ const differs = SCHEDULER_VISIBLE_FIELDS.filter((key) => JSON.stringify(candidate[key] ?? null) !== JSON.stringify(record[key] ?? null));
252
+ if (differs.length) return { ...currency, state: 'refused', reason: `refresh would change ${differs.join(', ')}; registration left as it was`, fix: NIGHTLY_ENABLE_FIX };
253
+ const refreshed = installNightlyRunner(plan);
254
+ return { ...currency, state: 'refreshed', registered: registeredModuleDigest(refreshed), fix: undefined };
255
+ }
256
+
202
257
  export function readNightlyRegistration({ brainHome, identity = NIGHTLY_LABEL,
203
258
  recordPath = path.join(brainHome, 'scheduler', registrationName(identity)) }) {
204
259
  try {
@@ -239,20 +239,18 @@ export function launchDetached(steps, { receipts, brainHome, projectDir, env = p
239
239
  return { launched: true, pid: child.pid };
240
240
  }
241
241
 
242
- function readTurnState(stateFile) {
243
- try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; }
244
- }
242
+ const readTurnState = (stateFile) => { try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; } };
243
+
244
+ /** The newest store receipt for this exact key, while the receipt tail still holds it. */
245
+ const storeReceipt = (receipts, key) => { try { return readTail(receipts, 128 * 1024).flatMap((l) => { try { return [JSON.parse(l)]; } catch { return []; } }).findLast((r) => r.kind === 'store' && r.key === key) || null; } catch { return null; } };
246
+ const verifiedReceipt = (r) => r?.status === 0 && r.verified === true;
245
247
 
246
248
  function sameTurnSeen(stateFile, sessionKey, fingerprint, receipts) {
247
249
  const previous = readTurnState(stateFile)[sessionKey];
248
250
  if (previous?.fingerprint !== fingerprint) return false;
249
- try {
250
- const rows = readTail(receipts, 128 * 1024).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
251
- const receipt = rows.findLast((r) => r.kind === 'store' && r.key === previous.key);
252
- if (receipt) return receipt.status === 0 && receipt.verified === true;
253
- } catch { /* queued worker has no receipt yet */ }
254
- // An in-flight worker gets a bounded grace period; a killed/lost worker cannot consume the turn forever.
255
- return Date.now() - previous.at < 2 * STEP_TIMEOUT_MS + 10_000;
251
+ const receipt = storeReceipt(receipts, previous.key);
252
+ // No receipt yet: an in-flight worker gets a bounded grace period; a killed/lost worker cannot consume the turn forever.
253
+ return receipt ? verifiedReceipt(receipt) : Date.now() - previous.at < 2 * STEP_TIMEOUT_MS + 10_000;
256
254
  }
257
255
 
258
256
  function markTurnQueued(stateFile, sessionKey, fingerprint, key) {
@@ -266,9 +264,8 @@ function markTurnQueued(stateFile, sessionKey, fingerprint, key) {
266
264
  }
267
265
 
268
266
  /**
269
- * Capture this turn's outcome (Stop) and/or queue distillation (SessionEnd, PreCompact).
270
- * Returns a plain report; `queued` means a worker request, never proof the store committed.
271
- * `skipped` / `distill.skipped` carry the reason whenever nothing is requested.
267
+ * Capture this turn's outcome (Stop) and/or start distillation (SessionEnd, PreCompact). Returns a plain report:
268
+ * `queued` means a request, never proof the store committed; `skipped` / `distill.skipped` carry the reason.
272
269
  */
273
270
  export function captureTurnOutcome({
274
271
  projectDir, event, payload = {}, host = 'claude',
@@ -322,10 +319,12 @@ export function captureTurnOutcome({
322
319
  const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
323
320
  const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
324
321
  const identity = crypto.createHash('sha256').update(`${host}:${db}:${sessionKey}`).digest('hex');
322
+ const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
325
323
  if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
324
+ // Proven at Stop already: SessionEnd/PreCompact must not re-send it (2026-10-19/20: each re-send was an "append rejected" row).
325
+ else if (verifiedReceipt(storeReceipt(receipts, key))) report.skipped = 'turn key already verified at an earlier boundary';
326
326
  else {
327
327
  const at = now();
328
- const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
329
328
  dedupe = { stateFile, identity, fingerprint, key };
330
329
  const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
331
330
  steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '--key', key, '--value', value, '--namespace', TURN_NAMESPACE,
@@ -444,13 +443,14 @@ export function runSteps({ steps = [], receipts, event = '' } = {}, { run: suppl
444
443
  delete options.env.CLAUDE_FLOW_MEMORY_PATH;
445
444
  const r = run(invocation.executable, invocation.args, options);
446
445
  status = Number.isInteger(r.status) ? r.status : 1;
447
- if (kind === 'store' && !verified && status === 0) verified = read({ ruflo, db, key, run, options }) === value;
448
- if (verified) status = 0;
446
+ const duplicate = kind === 'store' && status !== 0 && /already exists/i.test(captureFailureReason(r, status)); // append-only refusal
447
+ if (kind === 'store' && (status === 0 || duplicate)) { verified = read({ ruflo, db, key, run, options }) === value; if (verified) status = 0; } // identical bytes = success
449
448
  if (kind === 'distill' && status === 0) {
450
449
  const match = /(?:^|\n)\s*skipped:\s*([^\n]+)/i.exec(String(r.stdout || '').replace(/\x1b\[[0-9;]*m/g, ''));
451
450
  if (match) skipped = redactText(match[1]).slice(0, 300);
452
451
  }
453
- if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
452
+ if (!verified && duplicate) error = 'duplicate turn key holds different content';
453
+ else if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
454
454
  else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
455
455
  } catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
456
456
  if (error) error = maskExcludedPaths(error, privacyPatterns, projectDir);
@@ -400,6 +400,31 @@ export function failedStage(run) {
400
400
  return stage ? STAGE_NAMES[stage] || stage : `an unnamed job (conclusion ${run?.conclusion || 'unknown'})`;
401
401
  }
402
402
 
403
+ /** A night's scheduled dispatches (the 07:36Z run and its retry slot) sit within this span; a day earlier is last night. */
404
+ const NIGHT_SPAN_MS = 12 * HOUR;
405
+ /**
406
+ * How many protected-release corpus runs belong to THIS night. Measured 2026-10-22: a manual workflow_dispatch proving
407
+ * run at 10:36Z Oct 21 and a stood-down retry slot at 11:25Z Oct 21 made ONE scheduled night read "attempt 2; 1 retried".
408
+ * The night is anchored on the newest `schedule` dispatcher that dispatched a run, plus scheduled dispatchers within
409
+ * NIGHT_SPAN_MS before it; a run counts only from that start and never when its dispatcher was a manual
410
+ * workflow_dispatch. A stood-down retry slot created no run, so it never counts. `runs` is newest-first, in window.
411
+ */
412
+ export function nightAttempts(runs, dispatchers) {
413
+ const dispatchKey = (d) => `corpus-${d.id}-${d.attempt ?? 1}`;
414
+ const runKey = (run) => CORPUS_RUN_TITLE.exec(String(run.title || ''))?.[1];
415
+ const byKey = new Map(dispatchers.map((d) => [dispatchKey(d), d]));
416
+ const manual = (run) => byKey.get(runKey(run))?.event === 'workflow_dispatch';
417
+ const [newest] = runs;
418
+ if (manual(newest)) return ' (1 attempt; manual dispatch)';
419
+ const scheduled = dispatchers.filter((d) => !d.event || d.event === 'schedule'); // an undated event is read as the schedule
420
+ const anchor = scheduled.find((d) => runs.some((run) => runKey(run) === dispatchKey(d)));
421
+ const nightStart = anchor
422
+ ? Math.min(...scheduled.filter((d) => ms(d.createdAt) <= ms(anchor.createdAt) && ms(anchor.createdAt) - ms(d.createdAt) <= NIGHT_SPAN_MS).map((d) => ms(d.createdAt)))
423
+ : ms(newest.createdAt);
424
+ const count = runs.filter((run) => ms(run.createdAt) >= nightStart && !manual(run)).length || 1;
425
+ return count > 1 ? ` (attempt ${count} of the night; ${count - 1} retried)` : ' (1 attempt)';
426
+ }
427
+
403
428
  /**
404
429
  * THE ONE LINE the owner reads first: what the night did, result first, in Eastern time. `input` is
405
430
  * gatherCorpusHealthInput's; the night is the newest corpus run of the last 24h and every retry of it.
@@ -410,9 +435,10 @@ export function summarizeNight(input, now) {
410
435
  const inWindow = (iso) => { const t = ms(iso); return t !== null && t <= at && at - t <= TONIGHT_WINDOW_MS; };
411
436
  const runs = (input?.corpusRuns || []).filter((run) => inWindow(run.createdAt))
412
437
  .map((run) => ({ ...run, outcome: run.outcome ?? classifyCorpusRun(run) })).sort((a, b) => ms(b.createdAt) - ms(a.createdAt));
413
- const dispatcher = (input?.dispatcherRuns || []).filter((d) => inWindow(d.createdAt)).sort((a, b) => ms(b.createdAt) - ms(a.createdAt))[0];
438
+ const dispatchers = (input?.dispatcherRuns || []).filter((d) => inWindow(d.createdAt)).sort((a, b) => ms(b.createdAt) - ms(a.createdAt));
439
+ const [dispatcher] = dispatchers;
414
440
  const latest = input?.latestRelease;
415
- const attempts = runs.length > 1 ? ` (attempt ${runs.length} of the night; ${runs.length - 1} retried)` : '';
441
+ const attempts = runs.length ? nightAttempts(runs, dispatchers) : '';
416
442
  const keep = latest ? `customers keep ${latest.tag} (${hours(Math.max(0, at - (ms(latest.generation || latest.publishedAt) ?? at)))} old)` : 'no promoted generation is observed';
417
443
  if (!runs.length) {
418
444
  if (!dispatcher) return { outcome: 'no-run', line: `night: no corpus run in the last 24h (no dispatcher run either); ${keep}` };