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.
- package/README.md +1 -1
- package/bin/install.mjs +65 -54
- package/config/practical-rule-catalog.json +22 -22
- package/console/app.js +1 -1
- package/console/architecture.html +2 -2
- package/package.json +1 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.grok-plugin/plugin.json +12 -0
- package/plugin/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -28
- package/plugin/hooks/grok-hooks.json +95 -0
- package/plugin/hooks/hook-contracts.json +6 -127
- package/plugin/hooks/hooks.json +1 -40
- package/plugin/host-adapters/grok.json +11 -0
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +35 -27
- package/plugin/scripts/automated-run.mjs +42 -0
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/checkpoint-keys.mjs +46 -0
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- package/plugin/scripts/continuation-gate.mjs +6 -0
- package/plugin/scripts/continuity-events.mjs +90 -2
- package/plugin/scripts/continuity-hook-policy.mjs +5 -14
- package/plugin/scripts/decision-gate.mjs +2 -2
- package/plugin/scripts/decision-record.mjs +11 -40
- package/plugin/scripts/grok-hook-adapter.mjs +155 -0
- package/plugin/scripts/hook-failures.mjs +16 -0
- package/plugin/scripts/hook-shim.mjs +1 -2
- package/plugin/scripts/learn-capture.mjs +60 -42
- package/plugin/scripts/learning-store.mjs +70 -60
- package/plugin/scripts/node-sqlite.mjs +1 -1
- package/plugin/scripts/ruflo-bin.mjs +71 -1
- package/plugin/scripts/ruflo-memory-hooks.mjs +204 -0
- package/plugin/scripts/runtime-preferences.mjs +1 -1
- package/plugin/scripts/session-snapshot-hook.mjs +85 -341
- package/plugin/scripts/session-start-core.mjs +2 -12
- package/plugin/scripts/signal-watch.mjs +1 -1
- package/plugin/scripts/substitution-wall.mjs +222 -0
- package/plugin/scripts/turn-outcome-capture.mjs +68 -74
- package/scripts/codex-host-execution-proof.mjs +1 -1
- package/scripts/console-engine.mjs +0 -32
- package/scripts/health-repair.mjs +1 -81
- package/scripts/hook-qualify-core.mjs +2 -2
- package/scripts/model-managed-workflow-service.mjs +9 -10
- package/scripts/model-native-qualification.mjs +17 -5
- package/scripts/model-routing-controller.mjs +9 -8
- package/scripts/model-routing-execution-adapters.mjs +24 -2
- package/scripts/model-weekly-cycle.mjs +13 -12
- package/scripts/no-silent-substitution.mjs +65 -1
- package/scripts/onboarding-console.mjs +8 -23
- package/scripts/product-integrity-contract.mjs +1 -1
- package/scripts/release-qualification-contract.mjs +21 -30
- package/scripts/remedy-registry.mjs +0 -18
- package/scripts/user-model-prompt-hook.mjs +11 -10
- package/scripts/wired-check.mjs +1 -5
- package/plugin/scripts/continuity-brief.mjs +0 -351
- package/plugin/scripts/continuity-journal.mjs +0 -553
- package/plugin/scripts/learn-flush.mjs +0 -96
- package/plugin/scripts/learning-observation.mjs +0 -51
- package/plugin/scripts/learning-queue.mjs +0 -135
- package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
- package/plugin/scripts/project-capture-queue.mjs +0 -357
- package/plugin/scripts/project-memory-enrollment.mjs +0 -203
- package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
- package/plugin/scripts/project-progression-contract.mjs +0 -496
- package/plugin/scripts/project-progression-hook.mjs +0 -233
- package/plugin/scripts/project-progression-outbox.mjs +0 -225
- package/plugin/scripts/project-progression-producer.mjs +0 -309
- package/plugin/scripts/project-progression-reader.mjs +0 -304
- package/plugin/scripts/project-progression-session-start.mjs +0 -320
- package/plugin/scripts/project-progression-sources.mjs +0 -246
- package/plugin/scripts/project-progression-store.mjs +0 -723
- package/plugin/scripts/project-progression-suspension.mjs +0 -60
- package/plugin/scripts/project-transition-hook.mjs +0 -267
- package/plugin/scripts/session-snapshot-contract.mjs +0 -105
- package/plugin/scripts/turn-transport-journal.mjs +0 -108
- package/scripts/qa/progression-validation-benchmark.mjs +0 -66
- 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
|
|
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 './
|
|
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 |
|
|
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
|
|
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
|
-
|
|
178
|
-
return { state: '
|
|
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'
|
|
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
|
|
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.
|
|
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 (
|
|
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
|
|
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 {
|
|
11
|
-
import {
|
|
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
|
|
15
|
-
*
|
|
16
|
-
* learningContext() owns consent and brain-off
|
|
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
|
-
|
|
47
|
+
tool = input.tool_name;
|
|
32
48
|
if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) return false;
|
|
33
|
-
|
|
49
|
+
action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
|
|
34
50
|
if (!action) return false;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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 {
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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(
|
|
60
|
-
if (!context.enabled) return
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
if (
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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 */ }
|