ruvnet-brain 4.6.4 → 4.7.0
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 +6 -50
- 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/commands/checkpoint.md +22 -40
- package/plugin/commands/rnb-brief.md +18 -15
- package/plugin/hooks/codex-hooks.json +1 -28
- package/plugin/hooks/hook-contracts.json +6 -127
- package/plugin/hooks/hooks.json +1 -40
- package/plugin/mcp/managed-cli-interface.mjs +0 -56
- package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
- package/plugin/scripts/agentdb-recall.mjs +8 -14
- package/plugin/scripts/brain-footprint.mjs +1 -1
- package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
- package/plugin/scripts/completion-claim-evidence.mjs +1 -1
- 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/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 +132 -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-routing-controller.mjs +9 -8
- 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 +9 -27
- package/scripts/remedy-registry.mjs +0 -18
- 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
|
@@ -7,9 +7,6 @@ import { managedGenerationIdentity } from './managed-cli-generation.mjs';
|
|
|
7
7
|
import { loadRuntimePreferences, runtimeChildEnv } from '../scripts/runtime-preferences.mjs';
|
|
8
8
|
import { projectDirectory } from '../scripts/project-identity.mjs';
|
|
9
9
|
import { recordManagedCliObservation, recordRegistryLatestObservation } from '../scripts/capability-claim-evidence.mjs';
|
|
10
|
-
import { runSessionSnapshotHook } from '../scripts/session-snapshot-hook.mjs';
|
|
11
|
-
import { resolveProjectStore } from '../scripts/project-store-resolver.mjs';
|
|
12
|
-
import { isAutomaticProgressionSuspension } from '../scripts/project-progression-suspension.mjs';
|
|
13
10
|
|
|
14
11
|
export const MANAGED_EXECUTABLES = Object.freeze([
|
|
15
12
|
'ruflo',
|
|
@@ -275,40 +272,6 @@ export function normalizeManagedExecution(execution) {
|
|
|
275
272
|
return { outcome: execution.code === 0 ? 'success' : 'failure', output, contradictoryFailure };
|
|
276
273
|
}
|
|
277
274
|
|
|
278
|
-
function managedSessionId(env) {
|
|
279
|
-
const supplied = env.RUVNET_BRAIN_SESSION_ID || env.RUVNET_HOOK_SESSION_ID;
|
|
280
|
-
return typeof supplied === 'string' && supplied.trim() ? supplied.trim() : `managed-cli-${process.pid}`;
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
/** Bind managed CLI execution to the existing progression writer when this project has adopted it. */
|
|
284
|
-
function managedProgressionCapture({ executable, argv, projectRoot, env, event, execution, normalized }) {
|
|
285
|
-
const adopted = fs.existsSync(path.join(projectRoot, '.swarm'));
|
|
286
|
-
if (!adopted) return { adopted: false, skipped: 'project has not adopted the canonical store' };
|
|
287
|
-
const host = String(env.RUVNET_HOOK_HOST || '').toLowerCase();
|
|
288
|
-
if (!['claude', 'codex'].includes(host)) return { adopted: true, error: 'managed host identity unavailable' };
|
|
289
|
-
let resolution;
|
|
290
|
-
try { resolution = resolveProjectStore({ projectDir: projectRoot }); }
|
|
291
|
-
catch (error) { return { adopted: true, error: `project store could not be resolved: ${error?.message || error}` }; }
|
|
292
|
-
const payload = {
|
|
293
|
-
session_id: managedSessionId(env),
|
|
294
|
-
hook_event_name: event,
|
|
295
|
-
tool_name: executable,
|
|
296
|
-
tool_input: { command: [executable, ...argv].join(' ') },
|
|
297
|
-
...(execution ? {
|
|
298
|
-
tool_response: {
|
|
299
|
-
stdout: execution.stdout,
|
|
300
|
-
stderr: execution.stderr,
|
|
301
|
-
exit_code: execution.code,
|
|
302
|
-
...(execution.signal ? { signal: execution.signal, interrupted: true } : {}),
|
|
303
|
-
outcome: normalized?.outcome,
|
|
304
|
-
...(execution.error ? { error: execution.error } : {}),
|
|
305
|
-
},
|
|
306
|
-
} : {}),
|
|
307
|
-
};
|
|
308
|
-
const result = runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host, env });
|
|
309
|
-
return isAutomaticProgressionSuspension(result) ? result : { adopted: true, ...result };
|
|
310
|
-
}
|
|
311
|
-
|
|
312
275
|
function resultOf(executable, argv, result) {
|
|
313
276
|
const output = [result.stdout, result.stderr].filter(Boolean).join(result.stdout && result.stderr ? '\n' : '');
|
|
314
277
|
const normalized = normalizeManagedExecution(result);
|
|
@@ -406,35 +369,16 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
|
|
|
406
369
|
const childEnv = (executable === 'agentic-flow' || executable === 'agentic-qe')
|
|
407
370
|
? runtimeChildEnv({ env, cwd: projectRoot })
|
|
408
371
|
: env;
|
|
409
|
-
const capture = typeof lifecycle.capture === 'function' ? lifecycle.capture : managedProgressionCapture;
|
|
410
|
-
const beforeCapture = await capture({ executable, argv, projectRoot, env: childEnv, event: 'PreToolUse' });
|
|
411
|
-
if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true
|
|
412
|
-
&& !isAutomaticProgressionSuspension(beforeCapture))) {
|
|
413
|
-
const reason = beforeCapture.error || beforeCapture.skipped || 'pre-execution progression was not durably captured';
|
|
414
|
-
return { content: [{ type: 'text', text: `managed execution refused: ${reason}` }], isError: true };
|
|
415
|
-
}
|
|
416
372
|
if (lifecycle && typeof lifecycle.beforeExecute === 'function') {
|
|
417
373
|
await lifecycle.beforeExecute({ executable, argv, projectRoot, env: childEnv });
|
|
418
374
|
}
|
|
419
375
|
const execution = await execute(executable, argv, childEnv);
|
|
420
376
|
const normalized = normalizeManagedExecution(execution);
|
|
421
|
-
const afterCapture = await capture({ executable, argv, projectRoot, env: childEnv, event: 'PostToolUse', execution, normalized });
|
|
422
377
|
if (lifecycle && typeof lifecycle.afterExecute === 'function') {
|
|
423
378
|
await lifecycle.afterExecute({ executable, argv, projectRoot, env: childEnv, execution, normalized });
|
|
424
379
|
}
|
|
425
380
|
recordManagedCliObservation({ toolName, executable, argv, execution, env });
|
|
426
|
-
if (afterCapture?.adopted && afterCapture.progressionCaptured !== true
|
|
427
|
-
&& !isAutomaticProgressionSuspension(afterCapture)) {
|
|
428
|
-
return {
|
|
429
|
-
content: [{ type: 'text', text: `managed command ${normalized.outcome}; continuity result was not durably captured: ${afterCapture.skipped || 'unknown reason'}` }],
|
|
430
|
-
isError: true,
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
381
|
const result = resultOf(executable, argv, execution);
|
|
434
|
-
if (isAutomaticProgressionSuspension(beforeCapture) || isAutomaticProgressionSuspension(afterCapture)) {
|
|
435
|
-
result.content.push({ type: 'text', text: 'Automatic project progression is operator-suspended; suspended captures produced no progression receipt. Ordinary AgentDB memory and explicit checkpoints remain available.' });
|
|
436
|
-
result.structuredContent.continuity = 'operator-suspended';
|
|
437
|
-
}
|
|
438
382
|
return result;
|
|
439
383
|
}
|
|
440
384
|
|
|
@@ -1,8 +1,15 @@
|
|
|
1
|
-
/** Bounded read-only projection of canonical Ruflo rows
|
|
2
|
-
*
|
|
3
|
-
*
|
|
1
|
+
/** Bounded read-only projection of canonical Ruflo rows THROUGH THE RUFLO CLI — never direct SQLite
|
|
2
|
+
* (owner ruling 2026-10-12: hooks call rUv's tools, they do not reimplement them). Run as an owned child
|
|
3
|
+
* so a stalled CLI is retired at the recall deadline. Output contract unchanged: {ok, value}.
|
|
4
4
|
*/
|
|
5
|
-
import {
|
|
5
|
+
import { execFile } from 'node:child_process';
|
|
6
|
+
import { resolveRuflo } from './ruflo-bin.mjs';
|
|
7
|
+
|
|
8
|
+
const call = (bin, args, deadline) => new Promise((resolve) => {
|
|
9
|
+
const timeout = Math.max(1, deadline - Date.now());
|
|
10
|
+
execFile(bin, args, { timeout, killSignal: 'SIGKILL', encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } },
|
|
11
|
+
(error, stdout) => resolve(error ? null : String(stdout || '')));
|
|
12
|
+
});
|
|
6
13
|
|
|
7
14
|
try {
|
|
8
15
|
const [storePath, input] = process.argv.slice(2);
|
|
@@ -12,11 +19,24 @@ try {
|
|
|
12
19
|
|| requests.some(row => typeof row?.namespace !== 'string' || typeof row?.key !== 'string' || !row.key))
|
|
13
20
|
|| namespaces && (!Array.isArray(namespaces) || namespaces.length > 8 || namespaces.some(value => typeof value !== 'string'))
|
|
14
21
|
|| Boolean(requests) === Boolean(namespaces)) throw new Error('invalid bounded read');
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
const bin = resolveRuflo();
|
|
23
|
+
if (!bin) throw new Error('global Ruflo unavailable');
|
|
24
|
+
let value;
|
|
25
|
+
if (requests) {
|
|
26
|
+
value = await Promise.all(requests.map(async ({ namespace, key }) => {
|
|
27
|
+
const out = await call(bin, ['memory', 'retrieve', '--key', key, '--namespace', namespace, '--value-only', '--path', storePath], deadline);
|
|
28
|
+
return out === null ? null : out.trim();
|
|
29
|
+
}));
|
|
30
|
+
} else {
|
|
31
|
+
const lists = await Promise.all(namespaces.map(async (namespace) => {
|
|
32
|
+
const out = await call(bin, ['memory', 'list', '--namespace', namespace, '--limit', '100000', '--format', 'json', '--path', storePath], deadline);
|
|
33
|
+
if (out === null) throw new Error('list failed');
|
|
34
|
+
const at = out.indexOf('[');
|
|
35
|
+
return [namespace, at < 0 ? [] : JSON.parse(out.slice(at)).map((row) => row.key).filter((key) => typeof key === 'string')];
|
|
36
|
+
}));
|
|
37
|
+
value = Object.fromEntries(lists);
|
|
38
|
+
}
|
|
39
|
+
process.stdout.write(JSON.stringify({ ok: true, value }));
|
|
20
40
|
} catch {
|
|
21
41
|
process.stdout.write(JSON.stringify({ ok: false, fatal: true }));
|
|
22
42
|
}
|
|
@@ -12,12 +12,12 @@ import path from 'node:path';
|
|
|
12
12
|
import crypto from 'node:crypto';
|
|
13
13
|
import { fileURLToPath } from 'node:url';
|
|
14
14
|
import { resolveRuflo } from './ruflo-bin.mjs';
|
|
15
|
-
import { rufloCwdFor, rufloScratchRoot } from './
|
|
15
|
+
import { rufloCwdFor, rufloScratchRoot } from './ruflo-bin.mjs';
|
|
16
16
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
17
17
|
import { isHarnessGenerated, readStdinBounded } from './hook-input.mjs';
|
|
18
18
|
import { learningContext } from './runtime-preferences.mjs';
|
|
19
19
|
import { learningTarget, LEARNING_NAMESPACE } from './learning-store.mjs';
|
|
20
|
-
import { safeAction } from './
|
|
20
|
+
import { safeAction } from './learn-capture.mjs';
|
|
21
21
|
|
|
22
22
|
import { redactText } from './continuity-events.mjs';
|
|
23
23
|
import { resolveTurnDb } from './turn-outcome-capture.mjs';
|
|
@@ -345,11 +345,6 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
345
345
|
const scratchFor = scratch || ((storePath) => rufloCwdFor(storePath, { root: rufloScratchRoot(env) }));
|
|
346
346
|
if (consequential) return await consequentialRecall({ prompt, root, store, bin, scratch: scratchFor, env, deadline, binding, signal, enumerateKeys, exclusions });
|
|
347
347
|
const namespaces = [...new Set(['lessons', 'patterns', 'pattern', 'turns', 'continuity-events', path.basename(root), 'default'])];
|
|
348
|
-
const family = /\bhooks?\b|hook.harness/i.test(prompt) ? 'decision-hook-harness-index'
|
|
349
|
-
: /score|grade|north.star/i.test(prompt) ? 'scorecard'
|
|
350
|
-
: /where are we|status|catch me up/i.test(prompt) ? 'project-state-current'
|
|
351
|
-
: /releas|publish|workflow run|dispatch/i.test(prompt) ? 'release'
|
|
352
|
-
: /requirement|always|every prompt/i.test(prompt) ? 'decision-agentdb' : null;
|
|
353
348
|
// This machine runs loaded (load average 30-60 measured 2026-10-10) and ranked search spawns one Ruflo process
|
|
354
349
|
// per namespace; run concurrently they starve every other read past the deadline. So: ONE native enumeration
|
|
355
350
|
// (reused for the curated pass and for pruning empty namespaces), then exact curated state/lessons/decisions,
|
|
@@ -362,13 +357,12 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
|
|
|
362
357
|
enumerateKeys: sharedKeys, exclusions, rankedSearch: false }).catch(() => null) : null;
|
|
363
358
|
const searchDeadline = searchDeadlineFor(Date.now(), deadline);
|
|
364
359
|
const activeNamespaces = presence?.ok ? namespaces.filter(namespace => presence.value[namespace]?.length) : namespaces;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
args: ['--format', 'json', '-q', workflowQuery, '-n', LEARNING_NAMESPACE, '-t', 'keyword', '--limit', '4'] });
|
|
360
|
+
// ONE supplementary search (owner ruling 2026-10-12). Measured on a live-size store at load 17: ~10
|
|
361
|
+
// concurrent `ruflo memory search` processes took 1.34-1.45 s each against a ~1.1 s window, so every
|
|
362
|
+
// ranked search timed out. Curated state/lessons/decisions already came from exact reads above.
|
|
363
|
+
const searchNamespace = [path.basename(root), 'default', 'turns'].find((namespace) => activeNamespaces.includes(namespace));
|
|
364
|
+
const jobs = store && searchNamespace ? [{ store, namespace: searchNamespace, family: null,
|
|
365
|
+
args: ['--format', 'json', '-q', query, '-n', searchNamespace, '--limit', '12'] }] : [];
|
|
372
366
|
const results = await Promise.all(jobs.map(async ({ store: jobStore, namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily, storePath: jobStore.path,
|
|
373
367
|
...await searchOnce({ ruflo: bin, store: jobStore, deadline: searchDeadline, env, scratch: scratchFor, args, signal }) })));
|
|
374
368
|
let status = results.every((r) => r.state === 'ok') ? 'ok'
|
|
@@ -41,7 +41,7 @@ export const FOOTPRINT_POLICY = Object.freeze({
|
|
|
41
41
|
staleStageMs: 2 * 3_600_000, // an install stage nobody activated in 2h is debris
|
|
42
42
|
staleForgeCandidateMs: 24 * 3_600_000,
|
|
43
43
|
staleLeaseMs: 6 * 3_600_000, // plugin/scripts/update-apply.mjs LEASE_FRESH_MS
|
|
44
|
-
staleRufloRunMs: 3_600_000, // plugin/scripts/
|
|
44
|
+
staleRufloRunMs: 3_600_000, // plugin/scripts/ruflo-bin.mjs STALE_RUN_MS
|
|
45
45
|
});
|
|
46
46
|
|
|
47
47
|
/** Every full-KB copy name this product (or a recovery) has ever created beside the live KB. A superset of
|
|
@@ -3,6 +3,7 @@ import fs from 'node:fs';
|
|
|
3
3
|
import os from 'node:os';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { spawn, spawnSync } from 'node:child_process';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
6
7
|
|
|
7
8
|
// New installs colocate the helper. Older standalone fixtures/installations can omit it;
|
|
8
9
|
// they retain their prior behavior until the installer upgrades both files.
|
|
@@ -44,8 +45,8 @@ const blockingHooks = new Set([
|
|
|
44
45
|
*
|
|
45
46
|
* warning: clamping SessionEnd hook timeout to 3s in .../hooks.json
|
|
46
47
|
*
|
|
47
|
-
* SessionEnd is HARD-CAPPED at 3 seconds and no registration can ask for more.
|
|
48
|
-
* 18s: measured with a real 10-entry queue and the real `ruflo`, 18342 / 18347 / 18384 ms, because
|
|
48
|
+
* SessionEnd is HARD-CAPPED at 3 seconds and no registration can ask for more. The former learning
|
|
49
|
+
* flusher needed 18s: measured with a real 10-entry queue and the real `ruflo`, 18342 / 18347 / 18384 ms, because
|
|
49
50
|
* one `ruflo hooks` call alone costs seconds and it self-bounds at LEARN_FLUSH_DEADLINE_MS.
|
|
50
51
|
*
|
|
51
52
|
* So the arithmetic never closes, and what shipped was the worst version of that: this wrapper
|
|
@@ -57,7 +58,8 @@ const blockingHooks = new Set([
|
|
|
57
58
|
* exactly — the hook returns immediately — while the flush runs to its own deadline and does its
|
|
58
59
|
* own write-back. Raising the number instead would have been a fiction the host silently clamps.
|
|
59
60
|
*/
|
|
60
|
-
|
|
61
|
+
// Empty since the learning flusher was deleted (2026-10-12): learning writes directly through ruflo.
|
|
62
|
+
const DETACHED_HOOKS = new Set([]);
|
|
61
63
|
|
|
62
64
|
/** THE BUDGET IS DERIVED FROM WHAT THE HOOK MEASURABLY COSTS, never from what looks tidy. */
|
|
63
65
|
function timeoutFor(hookId, event = '', sessionBudgetMs = 0) {
|
|
@@ -226,6 +228,14 @@ const result = spawnSync(process.execPath, [adapter, ...process.argv.slice(2)],
|
|
|
226
228
|
timeout: budgetMs,
|
|
227
229
|
killSignal: 'SIGKILL',
|
|
228
230
|
});
|
|
231
|
+
// Review N5: Codex has no shim watchdog in front of the body, so a kill here was silent. One ledger row,
|
|
232
|
+
// read by the next SessionStart (same writer and file as the shim's own rows). Never throws.
|
|
233
|
+
if (result.error?.code === 'ETIMEDOUT' || result.signal === 'SIGKILL') {
|
|
234
|
+
try {
|
|
235
|
+
const { recordHookFailure } = await import(pathToFileURL(path.join(root, 'scripts', 'hook-failures.mjs')).href);
|
|
236
|
+
recordHookFailure({ hook: hookId, event: process.argv[3] || '', error: 'killed-by-codex-wrapper', limitMs: budgetMs }, { brainHome });
|
|
237
|
+
} catch { /* an unrecordable failure must not become a second one */ }
|
|
238
|
+
}
|
|
229
239
|
if (hookId === 'session-start' && result.status !== 0) {
|
|
230
240
|
process.stderr.write('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN] SessionStart did not complete inside its host deadline; no restoration success is claimed.\n');
|
|
231
241
|
}
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
*
|
|
19
19
|
* HOSTS. Claude Code transcripts (`transcript_path`, JSONL) are parsed. Codex's Stop payload also
|
|
20
20
|
* carries `transcript_path`, but its rollout format is NOT parsed anywhere in this repository
|
|
21
|
-
* (
|
|
21
|
+
* (turn-outcome-capture.mjs declines it), so on Codex the
|
|
22
22
|
* transcript half is UNKNOWN and only the answer-side half (2)+(3) is enforced — disclosed in the
|
|
23
23
|
* correction text, never silently treated as proven.
|
|
24
24
|
*/
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* lesson an owner correction / standing rule, or explicit explicit only is authoritative
|
|
19
19
|
* open-item explicit only (the work ledger is read live, not copied) authoritative
|
|
20
20
|
*
|
|
21
|
-
* Each event is redacted (redactProgression), bounded (SUMMARY_LIMIT), and carries a content-derived
|
|
21
|
+
* Each event is redacted (redactProgression, below), bounded (SUMMARY_LIMIT), and carries a content-derived
|
|
22
22
|
* `id` so the same commit, decision or lesson is recorded once however many boundaries see it.
|
|
23
23
|
*
|
|
24
24
|
* PRIVACY. Turn capture never stores user text (a 2026-07-13 measurement: prompt echoes were 87% of a
|
|
@@ -31,7 +31,95 @@
|
|
|
31
31
|
import { nativeTurnLines } from './native-turn-transcript.mjs';
|
|
32
32
|
import crypto from 'node:crypto';
|
|
33
33
|
import { execFileSync } from 'node:child_process';
|
|
34
|
-
|
|
34
|
+
|
|
35
|
+
// Secret redaction for event payloads: pure functions, no storage.
|
|
36
|
+
function canonicalize(value) {
|
|
37
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
38
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
39
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
40
|
+
if (value && typeof value === 'object' && Object.getPrototypeOf(value) === Object.prototype) {
|
|
41
|
+
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
42
|
+
}
|
|
43
|
+
throw new TypeError('progression values must be JSON-compatible');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function secretKind(key) {
|
|
47
|
+
const normalized = key.replace(/[^a-z0-9]/gi, '').toLowerCase();
|
|
48
|
+
if (normalized.includes('privatekey')) return 'private-key';
|
|
49
|
+
if (normalized.includes('apikey')) return 'api-key';
|
|
50
|
+
if (normalized.includes('authorization')) return 'authorization';
|
|
51
|
+
if (normalized.includes('password') || normalized.includes('passwd')) return 'password';
|
|
52
|
+
if (normalized.includes('token')) return 'token';
|
|
53
|
+
if (normalized.includes('secret')) return 'secret';
|
|
54
|
+
if (normalized.includes('credential')) return 'credential';
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const INLINE_SECRETS = Object.freeze([
|
|
59
|
+
{
|
|
60
|
+
kind: 'private-key',
|
|
61
|
+
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
|
|
62
|
+
replace: '[REDACTED:private-key]',
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
kind: 'bearer-token',
|
|
66
|
+
pattern: /\bBearer\s+[A-Za-z0-9._~+/=-]{8,}/gi,
|
|
67
|
+
replace: 'Bearer [REDACTED:bearer-token]',
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
kind: 'api-key',
|
|
71
|
+
pattern: /\b(?:sk|api-key|ghp|github_pat)[-_][A-Za-z0-9._-]{8,}/gi,
|
|
72
|
+
replace: '[REDACTED:api-key]',
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
kind: 'password',
|
|
76
|
+
pattern: /\b(password|passwd)\s*[:=]\s*[^\s;,]+/gi,
|
|
77
|
+
replace: '$1=[REDACTED:password]',
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
kind: 'token',
|
|
81
|
+
pattern: /\b(token|secret|credential)\s*[:=]\s*[^\s;,]+/gi,
|
|
82
|
+
replace: '$1=[REDACTED:token]',
|
|
83
|
+
},
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
export function redactProgression(value) {
|
|
87
|
+
const redactions = [];
|
|
88
|
+
const mark = (path, kind) => {
|
|
89
|
+
if (!redactions.some((row) => row.path === path && row.kind === kind)) redactions.push({ path, kind });
|
|
90
|
+
};
|
|
91
|
+
const walk = (current, currentPath) => {
|
|
92
|
+
if (typeof current === 'string') {
|
|
93
|
+
let output = current;
|
|
94
|
+
for (const rule of INLINE_SECRETS) {
|
|
95
|
+
rule.pattern.lastIndex = 0;
|
|
96
|
+
const replaced = output.replace(rule.pattern, rule.replace);
|
|
97
|
+
if (replaced !== output) {
|
|
98
|
+
mark(currentPath, rule.kind);
|
|
99
|
+
output = replaced;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
return output;
|
|
103
|
+
}
|
|
104
|
+
if (Array.isArray(current)) return current.map((item, index) => walk(item, `${currentPath}[${index}]`));
|
|
105
|
+
if (current && typeof current === 'object' && Object.getPrototypeOf(current) === Object.prototype) {
|
|
106
|
+
return Object.fromEntries(Object.keys(current).sort().map((key) => {
|
|
107
|
+
const path = `${currentPath}.${key}`;
|
|
108
|
+
const kind = secretKind(key);
|
|
109
|
+
if (kind && current[key] !== null && current[key] !== undefined) {
|
|
110
|
+
if (current[key] !== `[REDACTED:${kind}]`) mark(path, kind);
|
|
111
|
+
return [key, `[REDACTED:${kind}]`];
|
|
112
|
+
}
|
|
113
|
+
return [key, walk(current[key], path)];
|
|
114
|
+
}));
|
|
115
|
+
}
|
|
116
|
+
return canonicalize(current);
|
|
117
|
+
};
|
|
118
|
+
const redacted = walk(value, '$');
|
|
119
|
+
redactions.sort((left, right) => left.path.localeCompare(right.path) || left.kind.localeCompare(right.kind));
|
|
120
|
+
return { value: redacted, redactions };
|
|
121
|
+
}
|
|
122
|
+
|
|
35
123
|
|
|
36
124
|
export const CONTINUITY_NAMESPACE = 'continuity-events';
|
|
37
125
|
export const EVENT_SCHEMA = 'ruvnet-brain.continuity-event';
|
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
* session-start continuity recovery at SessionStart claude, codex
|
|
21
21
|
* unprompted-speech advisory delivery at UserPromptSubmit claude, codex
|
|
22
22
|
* continuation-gate continuation nudge at turn end claude, codex
|
|
23
|
-
* session-snapshot
|
|
24
|
-
* session-snapshot
|
|
25
|
-
* session-snapshot
|
|
23
|
+
* session-snapshot turn outcome (ruflo) at Stop claude, codex
|
|
24
|
+
* session-snapshot checkpoint (ruflo) at PreCompact claude, codex
|
|
25
|
+
* session-snapshot checkpoint (ruflo) at SessionEnd claude, codex
|
|
26
|
+
* session-snapshot learning (opt-in) at PostToolUse claude, codex
|
|
26
27
|
* ground-ruvnet grounding injection at UserPromptSubmit claude, codex
|
|
27
28
|
* capacity-aware-parallel-work coordination guidance at UserPromptSubmit claude, codex
|
|
28
29
|
* decision-gate write authorization at PreToolUse (write) claude, codex
|
|
@@ -122,11 +123,9 @@ const registration = (id, matcher, hosts) => Object.freeze({ id, matcher, hosts:
|
|
|
122
123
|
*/
|
|
123
124
|
export const CONTINUITY_EVENTS = Object.freeze({
|
|
124
125
|
SessionStart: Object.freeze([
|
|
125
|
-
// learn-flush is no longer its own registration (ADR-106): session-start schedules it detached.
|
|
126
126
|
registration('session-start', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
|
|
127
127
|
]),
|
|
128
128
|
UserPromptSubmit: Object.freeze([
|
|
129
|
-
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
130
129
|
registration('unprompted-speech', '*', ['claude', 'codex']),
|
|
131
130
|
registration('ground-ruvnet', '*', ['claude', 'codex']),
|
|
132
131
|
// Context-only recommendation for clearly substantial, independently splittable work. The
|
|
@@ -144,7 +143,6 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
144
143
|
// Claude's `Edit` shape for decision-gate.mjs's own policies (protect-state, hijack-ruvnet,
|
|
145
144
|
// ground-before-write, adr-currency all then see tool_name:"Edit" exactly as on Claude).
|
|
146
145
|
PreToolUse: Object.freeze([
|
|
147
|
-
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
148
146
|
registration('decision-gate', '^(Write|Edit|MultiEdit|NotebookEdit|apply_patch)$', ['claude', 'codex']),
|
|
149
147
|
]),
|
|
150
148
|
// Extended to Codex 2026-09-12: a real MCP `search_ruvnet` call was measured to fire PostToolUse
|
|
@@ -154,16 +152,10 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
154
152
|
// no change either (proven live: the real MCP tool_response's `content[0].text` carries the exact
|
|
155
153
|
// "Searched N RuvNet repos" banner and query text the script already looks for).
|
|
156
154
|
PostToolUse: Object.freeze([
|
|
157
|
-
//
|
|
155
|
+
// Learning observation (consent-gated `ruflo memory store`) runs inside session-snapshot's PostToolUse body.
|
|
158
156
|
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
159
157
|
registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex']),
|
|
160
158
|
]),
|
|
161
|
-
PostToolUseFailure: Object.freeze([
|
|
162
|
-
registration('session-snapshot', '*', ['claude']),
|
|
163
|
-
]),
|
|
164
|
-
SubagentStop: Object.freeze([
|
|
165
|
-
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
166
|
-
]),
|
|
167
159
|
Stop: Object.freeze([
|
|
168
160
|
registration('continuation-gate', '*', ['claude', 'codex']),
|
|
169
161
|
// Codex added 2026-09-29 (owner requirement: every turn's outcome recorded on BOTH hosts —
|
|
@@ -186,7 +178,6 @@ export const CONTINUITY_EVENTS = Object.freeze({
|
|
|
186
178
|
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
187
179
|
]),
|
|
188
180
|
SessionEnd: Object.freeze([
|
|
189
|
-
// learn-flush is scheduled by session-snapshot's SessionEnd boundary (ADR-106).
|
|
190
181
|
registration('session-snapshot', '*', ['claude', 'codex']),
|
|
191
182
|
]),
|
|
192
183
|
});
|
|
@@ -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) {
|
|
@@ -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
|