ruvnet-brain 4.7.15 → 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.15-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,668 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.15",
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.15",
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.15",
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
 
@@ -293,11 +293,17 @@ export function eligibleRepositoryStanding(row) {
293
293
  if (status === 'CURRENT') return carry === undefined && failure === undefined ? 'shipped' : null;
294
294
  if (status === 'STALE' && carry && typeof carry === 'object' && failure === undefined) {
295
295
  const carried = String(carry.carriedSourceCommit || '').toLowerCase();
296
+ const upstream = String(row.upstream?.sha || '').toLowerCase();
297
+ // Two honest shapes. A refresh that FAILED: upstream was read, differs from the carried commit, and is named
298
+ // as missedUpstream. An upstream nobody could READ tonight (transient, source-coverage.mjs): the row is pinned
299
+ // at the carried commit, says `upstreamUnavailable`, and missedUpstream is null -- unknown, never invented.
300
+ const missed = String(carry.missedUpstream || '').toLowerCase();
301
+ const upstreamKnown = HEX40.test(missed) && missed === upstream && carried !== missed;
302
+ const upstreamUnread = carry.missedUpstream === null && row.upstreamUnavailable === true && row.upstreamUnavailableKind === 'transient'
303
+ && carry.reason === 'transient: upstream unreadable' && carried === upstream;
296
304
  const shipped = typeof carry.reason === 'string' && carry.reason.length > 0
297
305
  && HEX40.test(carried) && carried === String(row.artifact?.sourceCommit || '').toLowerCase()
298
- && HEX40.test(String(carry.missedUpstream || '').toLowerCase())
299
- && String(carry.missedUpstream).toLowerCase() === String(row.upstream?.sha || '').toLowerCase()
300
- && carried !== String(carry.missedUpstream).toLowerCase()
306
+ && (upstreamKnown || upstreamUnread)
301
307
  && isAttemptCount(carry.attempts) && isIsoOrNull(carry.carriedCommittedAt)
302
308
  && row.artifact?.bytesVerified === true && row.artifact?.passagesPresent === true;
303
309
  return shipped ? 'shipped' : null;
@@ -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
  }