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.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +6 -50
  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/commands/checkpoint.md +22 -40
  10. package/plugin/commands/rnb-brief.md +18 -15
  11. package/plugin/hooks/codex-hooks.json +1 -28
  12. package/plugin/hooks/hook-contracts.json +6 -127
  13. package/plugin/hooks/hooks.json +1 -40
  14. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  15. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  16. package/plugin/scripts/agentdb-recall.mjs +8 -14
  17. package/plugin/scripts/brain-footprint.mjs +1 -1
  18. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  19. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  20. package/plugin/scripts/continuity-events.mjs +90 -2
  21. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  22. package/plugin/scripts/decision-gate.mjs +2 -2
  23. package/plugin/scripts/decision-record.mjs +11 -40
  24. package/plugin/scripts/hook-failures.mjs +16 -0
  25. package/plugin/scripts/hook-shim.mjs +1 -2
  26. package/plugin/scripts/learn-capture.mjs +60 -42
  27. package/plugin/scripts/learning-store.mjs +70 -60
  28. package/plugin/scripts/node-sqlite.mjs +1 -1
  29. package/plugin/scripts/ruflo-bin.mjs +71 -1
  30. package/plugin/scripts/ruflo-memory-hooks.mjs +132 -0
  31. package/plugin/scripts/runtime-preferences.mjs +1 -1
  32. package/plugin/scripts/session-snapshot-hook.mjs +85 -341
  33. package/plugin/scripts/session-start-core.mjs +2 -12
  34. package/plugin/scripts/signal-watch.mjs +1 -1
  35. package/plugin/scripts/substitution-wall.mjs +222 -0
  36. package/plugin/scripts/turn-outcome-capture.mjs +68 -74
  37. package/scripts/codex-host-execution-proof.mjs +1 -1
  38. package/scripts/console-engine.mjs +0 -32
  39. package/scripts/health-repair.mjs +1 -81
  40. package/scripts/hook-qualify-core.mjs +2 -2
  41. package/scripts/model-managed-workflow-service.mjs +9 -10
  42. package/scripts/model-routing-controller.mjs +9 -8
  43. package/scripts/no-silent-substitution.mjs +65 -1
  44. package/scripts/onboarding-console.mjs +8 -23
  45. package/scripts/product-integrity-contract.mjs +1 -1
  46. package/scripts/release-qualification-contract.mjs +9 -27
  47. package/scripts/remedy-registry.mjs +0 -18
  48. package/scripts/wired-check.mjs +1 -5
  49. package/plugin/scripts/continuity-brief.mjs +0 -351
  50. package/plugin/scripts/continuity-journal.mjs +0 -553
  51. package/plugin/scripts/learn-flush.mjs +0 -96
  52. package/plugin/scripts/learning-observation.mjs +0 -51
  53. package/plugin/scripts/learning-queue.mjs +0 -135
  54. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  55. package/plugin/scripts/project-capture-queue.mjs +0 -357
  56. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  57. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  58. package/plugin/scripts/project-progression-contract.mjs +0 -496
  59. package/plugin/scripts/project-progression-hook.mjs +0 -233
  60. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  61. package/plugin/scripts/project-progression-producer.mjs +0 -309
  62. package/plugin/scripts/project-progression-reader.mjs +0 -304
  63. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  64. package/plugin/scripts/project-progression-sources.mjs +0 -246
  65. package/plugin/scripts/project-progression-store.mjs +0 -723
  66. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  67. package/plugin/scripts/project-transition-hook.mjs +0 -267
  68. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  69. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  70. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  71. 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; never opens a second store.
2
- * Run as an owned child so SQLite/filesystem stalls can be retired at the recall deadline.
3
- * Unsupported schema stands down to global Ruflo; malformed/duplicate rows fail closed.
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 { withProgressionReader } from './project-progression-reader.mjs';
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 result = withProgressionReader(storePath, reader => requests
16
- ? requests.map(({ namespace, key }) => reader.readContent(namespace, key))
17
- : Object.fromEntries(namespaces.map(namespace => [namespace, reader.listKeys(namespace, { maxEntries: 100000 })])),
18
- { deadlineAt: deadline });
19
- process.stdout.write(JSON.stringify(result));
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 './project-progression-store.mjs';
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 './learning-queue.mjs';
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
- const jobs = (store ? activeNamespaces : []).flatMap((namespace) => [{ store, namespace, family: null, args: ['--format', 'json', '-q', query, '-n', namespace, '--limit', '12'] },
366
- ...(family && !SIGNAL_NAMESPACES.has(namespace) && namespace !== 'turns' ? [{ store, namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
367
- const words = promptKeywords(prompt, 14);
368
- const workflowQuery = words.map(word => safeAction('Bash', word))
369
- .find(action => action && action !== 'command');
370
- if (learningStore && workflowQuery) jobs.push({ store: learningStore, namespace: LEARNING_NAMESPACE, family: null,
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/project-progression-store.mjs STALE_RUN_MS
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. learn-flush needs
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
- const DETACHED_HOOKS = new Set(['learn-flush']);
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
- * (project-progression-sources.mjs and turn-outcome-capture.mjs both decline it), so on Codex the
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
- import { redactProgression } from './project-progression-contract.mjs';
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 continuity capture at turn end claude, codex
24
- * session-snapshot continuity capture at PreCompact claude
25
- * session-snapshot continuity capture at SessionEnd claude, codex
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
- // learn-capture runs in-process inside session-snapshot's PostToolUse boundary (ADR-106).
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 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) {
@@ -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