ruvnet-brain 4.6.4 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +65 -54
  3. package/config/practical-rule-catalog.json +22 -22
  4. package/console/app.js +1 -1
  5. package/console/architecture.html +2 -2
  6. package/package.json +1 -2
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/.grok-plugin/plugin.json +12 -0
  10. package/plugin/commands/checkpoint.md +22 -40
  11. package/plugin/commands/rnb-brief.md +18 -15
  12. package/plugin/hooks/codex-hooks.json +1 -28
  13. package/plugin/hooks/grok-hooks.json +95 -0
  14. package/plugin/hooks/hook-contracts.json +6 -127
  15. package/plugin/hooks/hooks.json +1 -40
  16. package/plugin/host-adapters/grok.json +11 -0
  17. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  18. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  19. package/plugin/scripts/agentdb-recall.mjs +35 -27
  20. package/plugin/scripts/automated-run.mjs +42 -0
  21. package/plugin/scripts/brain-footprint.mjs +1 -1
  22. package/plugin/scripts/checkpoint-keys.mjs +46 -0
  23. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  24. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  25. package/plugin/scripts/continuation-gate.mjs +6 -0
  26. package/plugin/scripts/continuity-events.mjs +90 -2
  27. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  28. package/plugin/scripts/decision-gate.mjs +2 -2
  29. package/plugin/scripts/decision-record.mjs +11 -40
  30. package/plugin/scripts/grok-hook-adapter.mjs +155 -0
  31. package/plugin/scripts/hook-failures.mjs +16 -0
  32. package/plugin/scripts/hook-shim.mjs +1 -2
  33. package/plugin/scripts/learn-capture.mjs +60 -42
  34. package/plugin/scripts/learning-store.mjs +70 -60
  35. package/plugin/scripts/node-sqlite.mjs +1 -1
  36. package/plugin/scripts/ruflo-bin.mjs +71 -1
  37. package/plugin/scripts/ruflo-memory-hooks.mjs +204 -0
  38. package/plugin/scripts/runtime-preferences.mjs +1 -1
  39. package/plugin/scripts/session-snapshot-hook.mjs +85 -341
  40. package/plugin/scripts/session-start-core.mjs +2 -12
  41. package/plugin/scripts/signal-watch.mjs +1 -1
  42. package/plugin/scripts/substitution-wall.mjs +222 -0
  43. package/plugin/scripts/turn-outcome-capture.mjs +68 -74
  44. package/scripts/codex-host-execution-proof.mjs +1 -1
  45. package/scripts/console-engine.mjs +0 -32
  46. package/scripts/health-repair.mjs +1 -81
  47. package/scripts/hook-qualify-core.mjs +2 -2
  48. package/scripts/model-managed-workflow-service.mjs +9 -10
  49. package/scripts/model-native-qualification.mjs +17 -5
  50. package/scripts/model-routing-controller.mjs +9 -8
  51. package/scripts/model-routing-execution-adapters.mjs +24 -2
  52. package/scripts/model-weekly-cycle.mjs +13 -12
  53. package/scripts/no-silent-substitution.mjs +65 -1
  54. package/scripts/onboarding-console.mjs +8 -23
  55. package/scripts/product-integrity-contract.mjs +1 -1
  56. package/scripts/release-qualification-contract.mjs +21 -30
  57. package/scripts/remedy-registry.mjs +0 -18
  58. package/scripts/user-model-prompt-hook.mjs +11 -10
  59. package/scripts/wired-check.mjs +1 -5
  60. package/plugin/scripts/continuity-brief.mjs +0 -351
  61. package/plugin/scripts/continuity-journal.mjs +0 -553
  62. package/plugin/scripts/learn-flush.mjs +0 -96
  63. package/plugin/scripts/learning-observation.mjs +0 -51
  64. package/plugin/scripts/learning-queue.mjs +0 -135
  65. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  66. package/plugin/scripts/project-capture-queue.mjs +0 -357
  67. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  68. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  69. package/plugin/scripts/project-progression-contract.mjs +0 -496
  70. package/plugin/scripts/project-progression-hook.mjs +0 -233
  71. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  72. package/plugin/scripts/project-progression-producer.mjs +0 -309
  73. package/plugin/scripts/project-progression-reader.mjs +0 -304
  74. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  75. package/plugin/scripts/project-progression-sources.mjs +0 -246
  76. package/plugin/scripts/project-progression-store.mjs +0 -723
  77. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  78. package/plugin/scripts/project-transition-hook.mjs +0 -267
  79. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  80. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  81. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  82. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -162,6 +162,7 @@ const POLICY = (id, file, interpreter = 'bash') => ({ id, file, interpreter });
162
162
  const REFUSAL_POLICIES = [
163
163
  POLICY('protect-state', 'protect-brain-state.sh'),
164
164
  POLICY('hijack-ruvnet', 'hijack-ruvnet.sh'),
165
+ POLICY('substitution-wall', 'substitution-wall.mjs', 'node'), // owner ruling 2026-10-12: only CALL rUv's memory tools; refuses a rebuilt transport (outbox/queue/journal/replay), in this repo only
165
166
  POLICY('ground-before-write', 'ground-before-write.sh'), POLICY('decision-recall', 'decision-recall-gate.mjs', 'node'), // ADR-106
166
167
  // adr-currency-gate fires on the EDIT, where the pre-push gate fires on the push. Same rule, same
167
168
  // machinery (it calls doc-currency.mjs, never a second copy of the logic) — moved to the earliest
@@ -181,7 +182,7 @@ const SPEECH = { id: 'unprompted-speech', file: 'unprompted-runtime.mjs', interp
181
182
 
182
183
  /** Which policies apply to which PreToolUse sub-event, mirroring the matchers they replaced. */
183
184
  const REGISTRY = {
184
- 'write': ['protect-state', 'hijack-ruvnet', 'ground-before-write', 'decision-recall', 'adr-currency', 'duplicate-code'],
185
+ 'write': ['protect-state', 'hijack-ruvnet', 'substitution-wall', 'ground-before-write', 'decision-recall', 'adr-currency', 'duplicate-code'],
185
186
  };
186
187
 
187
188
  export function policiesFor(event, registry = REGISTRY, all = REFUSAL_POLICIES) {
@@ -287,7 +288,6 @@ function payloadInput(payload) { return parsed(payload).tool_input || {}; }
287
288
  * `Cannot access 'speechEventFor' before initialization` on this gate's first live refusal.
288
289
  */
289
290
  function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : 'PreToolUse-write'; }
290
-
291
291
  // ── Runtime ──────────────────────────────────────────────────────────────────────────────────────
292
292
  if (isMain()) {
293
293
  const started = Date.now();
@@ -6,7 +6,6 @@
6
6
  * node decision-record.mjs --consult "<topic>" consequential recall + persisted receipt
7
7
  * node decision-record.mjs --record --what W --why Y --shape S --evidence kind:ref [...]
8
8
  * [--supersedes <key>]... [--status ratified|detected-unconfirmed]
9
- * node decision-record.mjs --flush retry records left pending by a refusal
10
9
  *
11
10
  * Two hooks consume the facts this file produces (ADR-106): decision-gate's `decision-recall` policy
12
11
  * reads the consult receipt before a new ADR or an ADR status change; continuation-gate reads the
@@ -15,7 +14,8 @@
15
14
  * Store contract (ADR-105 §3, `ruflo memory store --help` read 2026-10-11): every write is
16
15
  * `--no-upsert --require-native --append-only --path <abs>` from a private scratch cwd with no
17
16
  * CLAUDE_FLOW_MEMORY_PATH; a row is committed only when an exact-key readback returns the same
18
- * bytes; any other refusal stays pending on disk (mode 0600, fsynced) and is never reported as done.
17
+ * bytes; any other refusal is LOUD — a hook-failures row plus the printed reason — and is never reported as
18
+ * done. Nothing is kept on disk for a later replay (owner ruling 2026-10-12: no private outbox).
19
19
  */
20
20
  import fs from 'node:fs';
21
21
  import os from 'node:os';
@@ -28,8 +28,9 @@ import { maskExcludedPaths } from './turn-capture-privacy.mjs';
28
28
  import { resolveTurnDb, readSettledTranscript } from './turn-outcome-capture.mjs';
29
29
  import { resolveProjectStore } from './project-store-resolver.mjs';
30
30
  import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
31
- import { rufloRunDir } from './project-progression-store.mjs';
31
+ import { rufloRunDir } from './ruflo-bin.mjs';
32
32
  import { currentTurnRecords } from './completion-claim-evidence.mjs';
33
+ import { recordHookFailure } from './hook-failures.mjs';
33
34
 
34
35
  export const DECISION_SCHEMA = 'ruvnet-brain.decision-record';
35
36
  export const STATUSES = Object.freeze(['ratified', 'detected-unconfirmed']);
@@ -151,21 +152,11 @@ export function defaultReadBack({ ruflo, db, namespace, key }) {
151
152
  return { content: r.status === 0 ? r.stdout.trim() : null };
152
153
  }
153
154
 
154
- const pendingDir = (db) => path.join(path.dirname(db), '.decision-records-pending');
155
- function writePending(db, entry) {
156
- const dir = pendingDir(db);
157
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
158
- const file = path.join(dir, `${entry.key}.json`);
159
- const fd = fs.openSync(file, 'w', 0o600);
160
- try { fs.writeSync(fd, JSON.stringify(entry)); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
161
- return file;
162
- }
163
-
164
155
  /**
165
- * The §3 door. committed | already-stored | quarantined | pending — never "committed" without an
156
+ * The §3 door. committed | already-stored | quarantined | refused — never "committed" without an
166
157
  * exact readback of the same bytes, and a refusal is never treated as a duplicate.
167
158
  */
168
- export function storeDecision({ ruflo, db, namespace, key, value, store = defaultStore, readBack = defaultReadBack, keepPending = true }) {
159
+ export function storeDecision({ ruflo, db, namespace, key, value, store = defaultStore, readBack = defaultReadBack }) {
169
160
  const prior = readBack({ ruflo, db, namespace, key });
170
161
  if (typeof prior.content === 'string' && prior.content === value) return { state: 'already-stored', key };
171
162
  if (typeof prior.content === 'string' && prior.content && prior.content !== value) return { state: 'quarantined', key, reason: 'a different stored row holds this key' };
@@ -174,23 +165,8 @@ export function storeDecision({ ruflo, db, namespace, key, value, store = defaul
174
165
  if (result.status === 0 && back.content === value) return { state: 'committed', key };
175
166
  if (typeof back.content === 'string' && back.content && back.content !== value) return { state: 'quarantined', key, reason: 'a different stored row holds this key' };
176
167
  const reason = redactText(String(result.output || '').trim().split('\n').filter(Boolean).slice(-1)[0] || `store exited ${result.status}`).slice(0, 300);
177
- const pending = keepPending ? writePending(db, { key, namespace, value, at: new Date().toISOString(), reason }) : null;
178
- return { state: 'pending', key, reason, pending };
179
- }
180
-
181
- /** Replay pending records through the same door; a committed one leaves the pending directory. */
182
- export function flushPending({ ruflo, db, store = defaultStore, readBack = defaultReadBack }) {
183
- const out = [];
184
- let names = [];
185
- try { names = fs.readdirSync(pendingDir(db)).filter((n) => n.endsWith('.json')); } catch { return out; }
186
- for (const name of names) {
187
- const file = path.join(pendingDir(db), name);
188
- let entry; try { entry = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { out.push({ state: 'unreadable', file }); continue; }
189
- const r = storeDecision({ ruflo, db, namespace: entry.namespace, key: entry.key, value: entry.value, store, readBack, keepPending: false });
190
- if (r.state === 'committed' || r.state === 'already-stored') fs.rmSync(file, { force: true });
191
- out.push(r);
192
- }
193
- return out;
168
+ recordHookFailure({ hook: 'decision-record', event: 'record', error: `not recorded: ${reason}`.slice(0, 160) });
169
+ return { state: 'refused', key, reason };
194
170
  }
195
171
 
196
172
  // ── consult receipt (read before deciding) ─────────────────────────────────────────────────────
@@ -267,7 +243,7 @@ function parseArgs(argv) {
267
243
  for (let i = 0; i < argv.length; i++) {
268
244
  const a = argv[i];
269
245
  const next = () => { const v = argv[++i]; if (v === undefined) throw new Error(`${a} needs a value`); return v; };
270
- if (['--consult', '--record', '--flush'].includes(a)) { o.mode = a.slice(2); if (a === '--consult') o.topic = next(); }
246
+ if (['--consult', '--record'].includes(a)) { o.mode = a.slice(2); if (a === '--consult') o.topic = next(); }
271
247
  else if (a === '--what') o.what = next();
272
248
  else if (a === '--why') o.why = next();
273
249
  else if (a === '--shape') o.shape = next();
@@ -286,7 +262,7 @@ function parseArgs(argv) {
286
262
  export async function main(argv = process.argv.slice(2), { env = process.env, out = process.stdout, err = process.stderr, recallFn, store, readBack } = {}) {
287
263
  let o;
288
264
  try { o = parseArgs(argv); } catch (e) { err.write(`decision-record: ${e.message}\n`); return 2; }
289
- if (!o.mode) { err.write('decision-record: one of --consult "<topic>", --record, --flush is required\n'); return 2; }
265
+ if (!o.mode) { err.write('decision-record: one of --consult "<topic>", --record is required\n'); return 2; }
290
266
  const projectDir = path.resolve(o.project || process.cwd());
291
267
  const brainHome = brainHomeOf(env);
292
268
  let target;
@@ -306,11 +282,6 @@ export async function main(argv = process.argv.slice(2), { env = process.env, ou
306
282
  const ruflo = resolveRuflo({ env });
307
283
  if (!ruflo) { err.write('decision-record: not recorded — global ruflo not found\n'); return 3; }
308
284
  try { if (!fs.statSync(target.db).isFile()) throw new Error(); } catch { err.write('decision-record: not recorded — project has no canonical store (.swarm/memory.db)\n'); return 3; }
309
- if (o.mode === 'flush') {
310
- const rows = flushPending({ ruflo, db: target.db });
311
- out.write(`decision-record flush: ${rows.map((r) => `${r.key || r.file} ${r.state}`).join('; ') || 'nothing pending'}\n`);
312
- return rows.every((r) => ['committed', 'already-stored'].includes(r.state)) ? 0 : 3;
313
- }
314
285
  const identity = resolveProjectStore({ projectDir, gitTimeoutMs: 1000 }).projectIdentity.id;
315
286
  const built = buildDecisionRecord(o, { projectRoot: target.projectRoot, projectIdentity: identity,
316
287
  host: env.RUVNET_HOOK_HOST === 'codex' ? 'codex' : env.CLAUDE_PLUGIN_ROOT || env.CLAUDECODE ? 'claude' : 'cli',
@@ -329,7 +300,7 @@ export async function main(argv = process.argv.slice(2), { env = process.env, ou
329
300
  out.write(`decision-record: supersedes ${key} — ${String(row.what).slice(0, 120)}\n`);
330
301
  }
331
302
  const r = storeDecision({ ruflo, db: target.db, namespace, key: built.key, value: built.value, ...(store ? { store } : {}), ...(readBack ? { readBack } : {}) });
332
- out.write(`decision-record: ${r.state} ${r.key} (namespace ${namespace})${r.reason ? ` — ${r.reason}` : ''}${r.pending ? ` — retained for --flush` : ''}\n`);
303
+ out.write(`decision-record: ${r.state} ${r.key} (namespace ${namespace})${r.reason ? ` — ${r.reason}` : ''}${r.state === 'refused' ? ` — NOT recorded (logged to hook-failures). A store that committed but did not read back may still hold it: run \`ruflo memory retrieve -k ${r.key} -n ${namespace} --path ${target.db}\` before re-running --record` : ''}\n`);
333
304
  if (!['committed', 'already-stored'].includes(r.state)) return 3;
334
305
  let ok = true;
335
306
  for (const oldKey of built.record.supersedes) {
@@ -0,0 +1,155 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * grok-hook-adapter.mjs — the Grok CLI host boundary. Grok payloads in, the shared Brain hook bodies
4
+ * (through hook-shim.mjs, unchanged) in the middle, Grok-valid output back. Registered only by
5
+ * plugin/hooks/grok-hooks.json, which Grok selects through plugin/.grok-plugin/plugin.json.
6
+ *
7
+ * Every rule below is either quoted from Grok's shipped docs (~/.grok/docs/user-guide/10-hooks.md,
8
+ * grok 1.0.50) or was measured on a real `grok -p` turn on 2026-10-13
9
+ * (tests/fixtures/hook-payloads/grok-1.0.50/). Where the docs are silent the behaviour is marked
10
+ * UNVERIFIED in grok-hooks.json and the contract test, never guessed here.
11
+ *
12
+ * · INPUT. Grok sends camelCase keys plus snake_case copies of SOME of them (measured: session_id,
13
+ * tool_name, tool_input, tool_response, transcript_path; NOT stop_hook_active,
14
+ * last_assistant_message or prompt_id). hook-input.mjs normalizeHostEvent() already owns that
15
+ * mapping (and Grok's tool names: search_replace -> Edit, run_terminal_command -> Bash); it is
16
+ * reused here, not restated. prompt_id is added from promptId.
17
+ * · TRANSCRIPT. Grok's transcriptPath is its own ACP update log (…/updates.jsonl), not Claude's
18
+ * transcript. Every body that parses a transcript parses Claude's format only, so the path is
19
+ * withheld: the bodies then report their transcript half UNKNOWN (as on Codex) instead of
20
+ * misreading a foreign file.
21
+ * · SESSION-END STOP. "A separate Stop also fires at session end (reason: "channel_closed" or
22
+ * "shutdown"); its decision output is parsed but ignored" (10-hooks.md:399). Measured: a Stop with
23
+ * reason "shutdown" arrived after SessionEnd. It is not a turn, so no body runs for it.
24
+ * · OUTPUT. PreToolUse: exit 2 is a deny and the first stderr line its reason (10-hooks.md:377);
25
+ * JSON permissionDecision/additionalContext use Claude's vocabulary (:318-321). PostToolUse:
26
+ * stdout is parsed (:341); prose is wrapped as additionalContext rather than recorded as malformed.
27
+ * Stop: Claude's decision/reason/additionalContext vocabulary "works unchanged" (:497); non-JSON is
28
+ * an allow (:391). SessionStart, UserPromptSubmit, PreCompact, SessionEnd: stdout is ignored or
29
+ * discarded (:113, :531), so none is written.
30
+ * · FAIL OPEN. Only decision-gate may refuse. Any other non-zero status, a timeout or a crash exits 0,
31
+ * and a kill by this adapter's own budget is one hook-failures row (same writer as the Codex wrapper).
32
+ */
33
+ import fs from 'node:fs';
34
+ import path from 'node:path';
35
+ import { spawnSync } from 'node:child_process';
36
+ import { fileURLToPath, pathToFileURL } from 'node:url';
37
+ import { normalizeHostEvent } from './hook-input.mjs';
38
+ import { developmentHooksSuspended } from './development-maintenance.mjs';
39
+
40
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
41
+ const PLUGIN_ROOT = path.resolve(HERE, '..');
42
+
43
+ /** Ids whose exit 2 is a refusal Grok must see. Must name only ids hook-shim.mjs calls 'blocking'. */
44
+ export const GROK_BLOCKING_HOOKS = Object.freeze(new Set(['decision-gate']));
45
+
46
+ /** Events whose stdout Grok reads (10-hooks.md:531). Everything else is written nowhere. */
47
+ export const GROK_OUTPUT_EVENTS = Object.freeze(new Set(['PreToolUse', 'PostToolUse', 'Stop']));
48
+
49
+ /**
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.
53
+ */
54
+ export function budgetFor(hookId, event) {
55
+ if (event === 'SessionEnd') return 2_200;
56
+ if (hookId === 'decision-gate') return 6_500;
57
+ if (hookId === 'grounding-stamp' || hookId === 'grounding-turn-mark') return 4_000;
58
+ return 8_500;
59
+ }
60
+
61
+ /** Grok payload -> the Claude-shaped payload the shared bodies read. Pure. */
62
+ export function grokToBrainPayload(input) {
63
+ const out = normalizeHostEvent(input && typeof input === 'object' ? input : {});
64
+ if (out.prompt_id === undefined && typeof input?.promptId === 'string') out.prompt_id = input.promptId;
65
+ delete out.transcript_path;
66
+ delete out.transcriptPath;
67
+ return out;
68
+ }
69
+
70
+ /** True for Grok's session-end observe-only Stop fire, which is not a turn (10-hooks.md:399, :502). */
71
+ export function isSessionEndStop(payload) {
72
+ return payload?.hook_event_name === 'Stop' && typeof payload.reason === 'string' && payload.reason !== 'end_turn';
73
+ }
74
+
75
+ /**
76
+ * The body's result -> what this process writes and its exit code. Pure, so the contract test can
77
+ * drive every branch without a host.
78
+ */
79
+ export function grokOutput({ hookId, event, status, stdout = '', stderr = '' }) {
80
+ if (status === 2 && GROK_BLOCKING_HOOKS.has(hookId) && event === 'PreToolUse') {
81
+ return { stdout: isJson(stdout) ? stdout : '', stderr: stderr || 'RuvNet Brain decision-gate refused this write.\n', code: 2 };
82
+ }
83
+ if (status !== 0 || !GROK_OUTPUT_EVENTS.has(event)) return { stdout: '', stderr: '', code: 0 };
84
+ const text = String(stdout || '').trim();
85
+ if (!text) return { stdout: '', stderr: '', code: 0 };
86
+ if (isJson(text)) return { stdout: text, stderr: '', code: 0 };
87
+ if (event === 'Stop') return { stdout: '', stderr: '', code: 0 };
88
+ return { stdout: JSON.stringify({ hookSpecificOutput: { hookEventName: event, additionalContext: text } }), stderr: '', code: 0 };
89
+ }
90
+
91
+ function isJson(text) {
92
+ try { const v = JSON.parse(text); return Boolean(v) && typeof v === 'object' && !Array.isArray(v); } catch { return false; }
93
+ }
94
+
95
+ function readStdin(limit = 1024 * 1024) {
96
+ return new Promise((resolve) => {
97
+ const chunks = [];
98
+ let bytes = 0;
99
+ let done = false;
100
+ let idle;
101
+ const finish = () => { if (done) return; done = true; clearTimeout(idle); process.stdin.pause(); resolve(Buffer.concat(chunks).toString('utf8')); };
102
+ const arm = () => { clearTimeout(idle); idle = setTimeout(finish, 150); };
103
+ process.stdin.on('data', (chunk) => {
104
+ if (bytes < limit) { const kept = chunk.subarray(0, limit - bytes); chunks.push(kept); bytes += kept.length; }
105
+ arm();
106
+ });
107
+ process.stdin.once('end', finish);
108
+ process.stdin.once('error', finish);
109
+ arm();
110
+ process.stdin.resume();
111
+ });
112
+ }
113
+
114
+ async function main() {
115
+ if (developmentHooksSuspended()) return 0;
116
+ const hookId = process.argv[2] || '';
117
+ const extra = process.argv.slice(3);
118
+ const raw = await readStdin();
119
+ let input = {};
120
+ try { input = raw ? JSON.parse(raw) : {}; } catch { /* the bodies fail soft on an empty payload */ }
121
+ if (typeof input.cwd === 'string' && developmentHooksSuspended(input.cwd)) return 0;
122
+ const payload = grokToBrainPayload(input);
123
+ const event = String(payload.hook_event_name || '');
124
+ if (isSessionEndStop(payload)) return 0;
125
+
126
+ const projectDir = String(payload.cwd || process.env.GROK_WORKSPACE_ROOT || process.cwd());
127
+ let cwd = process.cwd();
128
+ try { if (fs.statSync(projectDir).isDirectory()) cwd = projectDir; } catch { /* keep the default */ }
129
+ const budget = budgetFor(hookId, event);
130
+ const env = {
131
+ ...process.env,
132
+ CLAUDE_PLUGIN_ROOT: PLUGIN_ROOT,
133
+ CLAUDE_PROJECT_DIR: projectDir,
134
+ CLAUDE_SESSION_ID: String(payload.session_id || process.env.GROK_SESSION_ID || ''),
135
+ RUVNET_HOOK_HOST: 'grok',
136
+ };
137
+ const r = spawnSync(process.execPath, [path.join(HERE, 'hook-shim.mjs'), hookId, ...extra], {
138
+ input: JSON.stringify(payload), encoding: 'utf8', env, cwd, timeout: budget, killSignal: 'SIGKILL',
139
+ maxBuffer: 16 * 1024 * 1024,
140
+ });
141
+ if (r.error?.code === 'ETIMEDOUT' || r.signal === 'SIGKILL') {
142
+ try {
143
+ const { recordHookFailure } = await import(pathToFileURL(path.join(HERE, 'hook-failures.mjs')).href);
144
+ recordHookFailure({ hook: hookId, event, error: 'killed-by-grok-adapter', limitMs: budget });
145
+ } catch { /* an unrecordable failure must not become a second one */ }
146
+ return 0;
147
+ }
148
+ const out = grokOutput({ hookId, event, status: r.status, stdout: r.stdout, stderr: r.stderr });
149
+ if (out.stdout) fs.writeSync(1, out.stdout);
150
+ if (out.stderr) fs.writeSync(2, out.stderr);
151
+ return out.code;
152
+ }
153
+
154
+ const isMain = (() => { try { return fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
155
+ if (isMain) process.exit(await main().catch(() => 0));
@@ -8,6 +8,7 @@
8
8
  */
9
9
  import fs from 'node:fs';
10
10
  import path from 'node:path';
11
+ import os from 'node:os';
11
12
 
12
13
  export const FAILURE_FILE = 'hook-failures.jsonl';
13
14
  export const FAILURE_LINE_PREFIX = '[RuvNet Brain — HOOK FAILURES]';
@@ -43,3 +44,18 @@ export function failureLine(brainHome, { advance = true } = {}) {
43
44
  return `${FAILURE_LINE_PREFIX} ${rows.length} advisory hook run(s) did not complete or did not resolve since ${rows[0].at}: ${parts.join('; ')}. `
44
45
  + `They failed open. Details: ${file}`;
45
46
  }
47
+
48
+ /**
49
+ * The writer for bodies that run OUTSIDE the shim's watchdog (a refused `ruflo memory store`, a failed
50
+ * exact readback). Same file, same row shape as hook-shim.mjs's inlined recordFailure, so the next
51
+ * SessionStart's failureLine() reports it. Never throws: an unrecordable failure must not become a second one.
52
+ */
53
+ export function recordHookFailure(fields, { brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain') } = {}) {
54
+ try {
55
+ const file = path.join(brainHome, FAILURE_FILE);
56
+ 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 });
59
+ return true;
60
+ } catch { return false; }
61
+ }
@@ -122,7 +122,6 @@ const TABLE = {
122
122
  // invocation". Restored. wired-check.mjs's H6 fix does not depend on these keys existing either
123
123
  // way — it stopped trusting hook-shim.mjs as a blind generic spawner, not their presence here.
124
124
  'learn-capture': { file: 'learn-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 65536 },
125
- 'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
126
125
  // 1 MiB, not 64 KiB: the Stop payload now carries `last_assistant_message`, and a long closing
127
126
  // message truncated mid-JSON would parse as `{}` and silently drop the whole capture.
128
127
  'session-snapshot': { file: 'session-snapshot-hook.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 1048576 },
@@ -268,7 +267,7 @@ function readHookInput(limit) {
268
267
  * even if an older one was already reported.
269
268
  *
270
269
  * WHY A TTL RATHER THAN A TRUE SESSION ID, stated plainly rather than implied: no session identifier
271
- * reaches this dispatcher. Claude Code does not set CLAUDE_SESSION_ID (learn-flush.mjs documents the
270
+ * reaches this dispatcher. Claude Code does not set CLAUDE_SESSION_ID (the learning hook documented the
272
271
  * same finding), and the only place `session_id` exists is the stdin payload — which the majority of
273
272
  * these table entries deliberately never read. So the window is an approximation of a session, not a
274
273
  * claim to be one: it bounds a persistent breakage to roughly one line per working session instead
@@ -1,21 +1,37 @@
1
1
  #!/usr/bin/env node
2
- // PostToolUse captures only a fixed workflow vocabulary, then schedules bounded orphan recovery.
3
2
  import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { randomUUID, createHash } from 'node:crypto';
3
+ import os from 'node:os';
6
4
  import { spawn } from 'node:child_process';
7
5
  import { fileURLToPath } from 'node:url';
8
6
  import { readStdinBounded } from './hook-input.mjs';
9
7
  import { learningContext } from './runtime-preferences.mjs';
10
- import { learningTarget } from './learning-store.mjs';
11
- import { safeQueue, safeAction, writeExclusive, takeQueueLock, releaseQueueLock, queueFiles } from './learning-queue.mjs';
8
+ import { recordLearningObservation, distillObservations } from './learning-store.mjs';
9
+ import { resolveRuflo } from './ruflo-bin.mjs';
10
+ import { recordHookFailure } from './hook-failures.mjs';
11
+
12
+ // Fixed vocabulary: arbitrary executable names, arguments, basenames and paths never persist.
13
+ 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
+ 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(' '));
15
+ export function safeAction(tool, value) {
16
+ if (typeof value !== 'string') return null;
17
+ if (tool === 'Bash') {
18
+ const [verb, sub] = value.trim().split(/\s+/, 3);
19
+ if (!verbs.has(verb)) return 'command';
20
+ return subs.has(sub) ? `${verb} ${sub}` : verb;
21
+ }
22
+ if (!['Write', 'Edit', 'MultiEdit'].includes(tool)) return null;
23
+ // Legacy action is minimized again before invocation; no private basename reaches Ruflo argv.
24
+ return 'edit file';
25
+ }
12
26
 
13
27
  /**
14
- * One PostToolUse observation. Since ADR-106 it runs IN-PROCESS from session-snapshot's PostToolUse
15
- * boundary (one registration instead of two); the script entry below stays for explicit invocation.
16
- * learningContext() owns consent and brain-off, so the fold keeps ADR-054's OFF contract.
28
+ * One PostToolUse observation, written DIRECTLY with `ruflo memory store` + exact-key readback (owner
29
+ * ruling 2026-10-12: no local queue, no flush worker, no replay). It runs in-process from
30
+ * session-snapshot's PostToolUse boundary. learningContext() owns consent and brain-off. A refused or
31
+ * unverified write is LOUD: one row in the hook-failures ledger, summarized at the next SessionStart.
17
32
  */
18
- export function captureLearning(input) {
33
+ export function captureLearning(input, { env = process.env, binary = resolveRuflo({ env }), brainHome, detach = false } = {}) {
34
+ let tool; let action;
19
35
  try {
20
36
  const context = learningContext();
21
37
  if (!context.enabled || !input || typeof input !== 'object') return false;
@@ -28,48 +44,50 @@ export function captureLearning(input) {
28
44
  if (input.error || input.is_error === true || response?.error || response?.interrupted === true || response?.signal
29
45
  || response?.success === false || response?.is_error === true || response?.isError === true
30
46
  || [response?.exit_code, response?.exitCode, response?.status].some(value => Number.isInteger(value) && value !== 0)) return false;
31
- const tool = input.tool_name;
47
+ tool = input.tool_name;
32
48
  if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) return false;
33
- const action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
49
+ action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
34
50
  if (!action) return false;
35
- const fresh = learningContext();
36
- if (!fresh.enabled || fresh.queueDir !== context.queueDir) return false;
37
- learningTarget(fresh); // Existing authorized user store is required before creating any user queue bytes.
38
- const dir = safeQueue(fresh, true);
39
- const sid = createHash('sha256').update(String(input.session_id || process.env.CLAUDE_SESSION_ID || 'default')).digest('hex').slice(0, 24);
40
- writeExclusive(path.join(dir, `session-${sid}-${randomUUID()}.jsonl`), JSON.stringify({ tool, action }) + '\n');
41
- // Per-scope lock in the flusher suppresses duplicate workers; capture never waits on the learner.
42
- const token = takeQueueLock(fresh);
43
- if (!token) return true;
44
- const child = spawn(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url)), '--supervisor'], {
45
- cwd: fresh.projectDir, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, RUVNET_LEARN_WORKER_TOKEN: token },
46
- });
47
- child.on('error', () => releaseQueueLock(fresh, token)); child.unref();
51
+ if (!binary) throw new Error('ruflo not found');
52
+ const observation = { tool, action, session: String(input.session_id || '') };
53
+ // Review M3: on the hook path the store + exact readback run in a detached, bounded child, so the
54
+ // 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 });
56
+ recordLearningObservation(binary, context, observation, { env, deadline: Date.now() + 6000 });
48
57
  return true;
49
- } catch { return false; /* advisory transport; unsafe/malformed input produces no persisted payload */ }
58
+ } catch (error) {
59
+ if (tool && action) recordHookFailure({ hook: 'learn-capture', event: 'PostToolUse', error: String(error?.message || error).slice(0, 160) }, brainHome ? { brainHome } : undefined);
60
+ return false;
61
+ }
50
62
  }
51
63
 
52
- /**
53
- * Lifecycle recovery (formerly its own SessionStart/SessionEnd registration, ADR-106): when learning
54
- * is enabled, start learn-flush detached so an orphaned queue is drained by one finite worker. The
55
- * caller never waits; learn-flush owns the lock, the deadline and the consent re-checks.
56
- */
57
- export function scheduleLearningRecovery({ cwd = process.cwd(), env = process.env, spawnFn = spawn } = {}) {
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 } = {}) {
66
+ 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 } : {}) } });
68
+ child.unref();
69
+ return true;
70
+ }
71
+
72
+ function runDetached(mode) {
73
+ const fail = (error) => recordHookFailure({ hook: mode === '--distill' ? 'learn-distill' : 'learn-capture', event: mode === '--distill' ? 'SessionEnd' : 'PostToolUse', error: String(error?.message || error).slice(0, 160) });
58
74
  try {
59
- const context = learningContext({ env, cwd });
60
- if (!context.enabled) return false;
61
- // Nothing queued, nothing to recover. Spawning anyway cost a process at every session boundary and,
62
- // on Windows, a live worker holds the project directory as its cwd (EBUSY on delete/move).
63
- if (queueFiles(context, { limit: 1, deadline: Date.now() + 100 }).length === 0) return false;
64
- const child = spawnFn(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url))], {
65
- cwd, detached: true, stdio: 'ignore', windowsHide: true, env });
66
- child.on?.('error', () => {}); child.unref?.();
67
- return true;
68
- } catch { return false; }
75
+ const context = learningContext(); // consent is re-read: a withdrawn consent writes nothing
76
+ if (!context.enabled) return;
77
+ const binary = resolveRuflo({ env: process.env });
78
+ if (!binary) throw new Error('ruflo not found');
79
+ if (mode === '--distill') { distillObservations(binary, context, { env: process.env }); return; }
80
+ const o = JSON.parse(process.env.RUVNET_LEARN_OBSERVATION || '{}');
81
+ if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(o.tool) || typeof o.action !== 'string') throw new Error('invalid detached observation');
82
+ 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); }
69
84
  }
70
85
 
71
86
  const isMain = (() => { try { return Boolean(process.argv[1]) && fs.realpathSync(process.argv[1]) === fs.realpathSync(fileURLToPath(import.meta.url)); } catch { return false; } })();
72
- if (isMain) {
87
+ if (isMain && ['--observe', '--distill'].includes(process.argv[2])) {
88
+ runDetached(process.argv[2]);
89
+ process.exit(0);
90
+ } else if (isMain) {
73
91
  try {
74
92
  if (learningContext().enabled) captureLearning(JSON.parse((await readStdinBounded({ maxBytes: 65536, emptyMs: 150 })).toString()));
75
93
  } catch { /* malformed input produces no persisted payload */ }