ruvnet-brain 4.6.4 → 4.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +65 -54
  3. package/config/practical-rule-catalog.json +22 -22
  4. package/console/app.js +1 -1
  5. package/console/architecture.html +2 -2
  6. package/package.json +1 -2
  7. package/plugin/.claude-plugin/plugin.json +1 -1
  8. package/plugin/.codex-plugin/plugin.json +1 -1
  9. package/plugin/.grok-plugin/plugin.json +12 -0
  10. package/plugin/commands/checkpoint.md +22 -40
  11. package/plugin/commands/rnb-brief.md +18 -15
  12. package/plugin/hooks/codex-hooks.json +1 -28
  13. package/plugin/hooks/grok-hooks.json +95 -0
  14. package/plugin/hooks/hook-contracts.json +6 -127
  15. package/plugin/hooks/hooks.json +1 -40
  16. package/plugin/host-adapters/grok.json +11 -0
  17. package/plugin/mcp/managed-cli-interface.mjs +0 -56
  18. package/plugin/scripts/agentdb-recall-reader.mjs +29 -9
  19. package/plugin/scripts/agentdb-recall.mjs +35 -27
  20. package/plugin/scripts/automated-run.mjs +42 -0
  21. package/plugin/scripts/brain-footprint.mjs +1 -1
  22. package/plugin/scripts/checkpoint-keys.mjs +46 -0
  23. package/plugin/scripts/codex-hook-wrapper.mjs +13 -3
  24. package/plugin/scripts/completion-claim-evidence.mjs +1 -1
  25. package/plugin/scripts/continuation-gate.mjs +6 -0
  26. package/plugin/scripts/continuity-events.mjs +90 -2
  27. package/plugin/scripts/continuity-hook-policy.mjs +5 -14
  28. package/plugin/scripts/decision-gate.mjs +2 -2
  29. package/plugin/scripts/decision-record.mjs +11 -40
  30. package/plugin/scripts/grok-hook-adapter.mjs +155 -0
  31. package/plugin/scripts/hook-failures.mjs +16 -0
  32. package/plugin/scripts/hook-shim.mjs +1 -2
  33. package/plugin/scripts/learn-capture.mjs +60 -42
  34. package/plugin/scripts/learning-store.mjs +70 -60
  35. package/plugin/scripts/node-sqlite.mjs +1 -1
  36. package/plugin/scripts/ruflo-bin.mjs +71 -1
  37. package/plugin/scripts/ruflo-memory-hooks.mjs +204 -0
  38. package/plugin/scripts/runtime-preferences.mjs +1 -1
  39. package/plugin/scripts/session-snapshot-hook.mjs +85 -341
  40. package/plugin/scripts/session-start-core.mjs +2 -12
  41. package/plugin/scripts/signal-watch.mjs +1 -1
  42. package/plugin/scripts/substitution-wall.mjs +222 -0
  43. package/plugin/scripts/turn-outcome-capture.mjs +68 -74
  44. package/scripts/codex-host-execution-proof.mjs +1 -1
  45. package/scripts/console-engine.mjs +0 -32
  46. package/scripts/health-repair.mjs +1 -81
  47. package/scripts/hook-qualify-core.mjs +2 -2
  48. package/scripts/model-managed-workflow-service.mjs +9 -10
  49. package/scripts/model-native-qualification.mjs +17 -5
  50. package/scripts/model-routing-controller.mjs +9 -8
  51. package/scripts/model-routing-execution-adapters.mjs +24 -2
  52. package/scripts/model-weekly-cycle.mjs +13 -12
  53. package/scripts/no-silent-substitution.mjs +65 -1
  54. package/scripts/onboarding-console.mjs +8 -23
  55. package/scripts/product-integrity-contract.mjs +1 -1
  56. package/scripts/release-qualification-contract.mjs +21 -30
  57. package/scripts/remedy-registry.mjs +0 -18
  58. package/scripts/user-model-prompt-hook.mjs +11 -10
  59. package/scripts/wired-check.mjs +1 -5
  60. package/plugin/scripts/continuity-brief.mjs +0 -351
  61. package/plugin/scripts/continuity-journal.mjs +0 -553
  62. package/plugin/scripts/learn-flush.mjs +0 -96
  63. package/plugin/scripts/learning-observation.mjs +0 -51
  64. package/plugin/scripts/learning-queue.mjs +0 -135
  65. package/plugin/scripts/learning-worker-supervisor.mjs +0 -79
  66. package/plugin/scripts/project-capture-queue.mjs +0 -357
  67. package/plugin/scripts/project-memory-enrollment.mjs +0 -203
  68. package/plugin/scripts/project-progression-checkpoint.mjs +0 -145
  69. package/plugin/scripts/project-progression-contract.mjs +0 -496
  70. package/plugin/scripts/project-progression-hook.mjs +0 -233
  71. package/plugin/scripts/project-progression-outbox.mjs +0 -225
  72. package/plugin/scripts/project-progression-producer.mjs +0 -309
  73. package/plugin/scripts/project-progression-reader.mjs +0 -304
  74. package/plugin/scripts/project-progression-session-start.mjs +0 -320
  75. package/plugin/scripts/project-progression-sources.mjs +0 -246
  76. package/plugin/scripts/project-progression-store.mjs +0 -723
  77. package/plugin/scripts/project-progression-suspension.mjs +0 -60
  78. package/plugin/scripts/project-transition-hook.mjs +0 -267
  79. package/plugin/scripts/session-snapshot-contract.mjs +0 -105
  80. package/plugin/scripts/turn-transport-journal.mjs +0 -108
  81. package/scripts/qa/progression-validation-benchmark.mjs +0 -66
  82. package/scripts/session-snapshot-contract.mjs +0 -1
@@ -0,0 +1,11 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "kind": "ruvnet-brain-host-adapter",
4
+ "id": "grok",
5
+ "displayName": "Grok CLI",
6
+ "os": ["linux", "macos", "windows"],
7
+ "modes": [],
8
+ "manifest": "plugin/.grok-plugin/plugin.json",
9
+ "hooks": "plugin/hooks/grok-hooks.json",
10
+ "loader": "plugin/scripts/grok-hook-adapter.mjs"
11
+ }
@@ -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';
@@ -25,11 +25,16 @@ import { maskExcludedPaths } from './turn-capture-privacy.mjs';
25
25
  import { searchOnce, nativeRead, exactReads, searchJsonResult } from './agentdb-recall-process.mjs';
26
26
  import { makeLesson } from './lesson-store.mjs';
27
27
  import { supersededKeys, tombstonedKeys } from './decision-record.mjs';
28
+ import { checkpointMs, checkpointNamespaces, newestCheckpoint } from './checkpoint-keys.mjs';
28
29
  export const STORE_FILES = Object.freeze(['memory.db']);
29
30
  export const DEFAULT_DEADLINE_MS = 1900;
30
31
  export const BLOCK_MAX_BYTES = 600;
31
32
  export const CONSEQUENTIAL_BLOCK_MAX_BYTES = 1800;
32
33
  export const MIN_RELEVANCE = 0.45;
34
+ /** Exact-read requests one bounded reader child accepts (agentdb-recall-reader.mjs). */
35
+ export const MAX_EXACT_READS = 32;
36
+
37
+ export { checkpointMs, checkpointNamespaces, newestCheckpoint };
33
38
 
34
39
  export function recallBinding({ binding = {}, projectRoot, storePath, prompt } = {}) {
35
40
  const allowed = ['projectRoot', 'storePath', 'sessionId', 'workflowId', 'phase', 'workerId', 'requestDigest'];
@@ -140,7 +145,7 @@ export function turnOutcomeExcerpt(value, prompt) {
140
145
  /** Quote a substantive exact-value passage, including the remedy in structured lessons. */
141
146
  export function evidenceExcerpt(value, key, prompt = '') {
142
147
  const text = redactText(value);
143
- if (/^project-state-current/i.test(key)) {
148
+ if (/^project-state-(?:current|auto)/i.test(key)) {
144
149
  // Checkpoints often lead with version/hash metadata. Quote one actual field,
145
150
  // selected by task terms, instead of spending the evidence budget on identity.
146
151
  const first = text.indexOf('{'); const last = text.lastIndexOf('}');
@@ -190,6 +195,8 @@ export function learningObservationExcerpt(raw) {
190
195
  export function pickRows(results, limit = 3) {
191
196
  const candidates = results.flatMap((r) => r.rows.filter((p) => p.namespace === r.namespace && (!NOISE_KEY.test(p.key) || p.namespace === 'turns' && /^turn[-_]/i.test(p.key) || p.namespace === 'continuity-events' && /^cevt-.*-(?:decision|lesson|open-item)-/i.test(p.key))
192
197
  && (!r.family || p.key.toLowerCase().includes(r.family))
198
+ // State comes only from the newest exact checkpoint read; a semantic hit on an older one is never shown.
199
+ && (r.family || !/^project-state-/i.test(p.key))
193
200
  && Number.isFinite(p.score) && p.score >= MIN_RELEVANCE).map((p) => ({ ...p, storePath: r.storePath, targeted: Boolean(r.family) })));
194
201
  const signal = (p) => SIGNAL_NAMESPACES.has(p.namespace) ? 2 : p.namespace === 'turns' ? 1 : p.namespace === LEARNING_NAMESPACE ? -1 : 0;
195
202
  const ranked = candidates.sort((a, b) => Number(b.targeted) - Number(a.targeted) || signal(b) - signal(a) || b.score - a.score);
@@ -227,13 +234,14 @@ export function searchDeadlineFor(started, deadline) { return Math.min(deadline,
227
234
  async function consequentialRecall({ prompt, root, store, bin, scratch, env, deadline, binding, signal, enumerateKeys, exclusions, rankedSearch = true }) {
228
235
  const searchDeadline = searchDeadlineFor(Date.now(), deadline);
229
236
  const phaseBinding = recallBinding({ binding, projectRoot: root, storePath: store.path, prompt });
230
- const namespaces = [...new Set([path.basename(root), 'default', 'lessons', 'continuity-events'])];
237
+ const stateNamespaces = checkpointNamespaces(root);
238
+ const namespaces = [...new Set([...stateNamespaces, 'lessons', 'continuity-events'])];
231
239
  const enumeration = await enumerateKeys({ storePath: store.path, namespaces, deadline, signal, env, scratch });
232
240
  if (!enumeration?.ok || signal?.aborted || Date.now() >= deadline) return { block: formatBlock({ picks: [], status: 'unavailable structural enumeration' }), picks: [], stores: [store],
233
241
  status: { 'memory.db': 'unavailable' }, outcome: signal?.aborted ? 'unavailable' : Date.now() >= deadline ? 'timed-out' : 'unavailable', categories: { state: 'unavailable', decisions: 'unavailable', lessons: 'unavailable' } };
234
- const keys = enumeration.value;
235
- const stateKeys = keys[path.basename(root)].filter(key => /^project-state-current-\d+$/.test(key) && Number.isSafeInteger(Number(key.split('-').at(-1))))
236
- .sort((a, b) => Number(b.split('-').at(-1)) - Number(a.split('-').at(-1)));
242
+ const keys = Object.fromEntries(namespaces.map(namespace => [namespace, Array.isArray(enumeration.value?.[namespace]) ? enumeration.value[namespace] : []]));
243
+ // Newest by the epoch-ms in the key, across every namespace spelling: never insertion order, score or one namespace.
244
+ const latestState = newestCheckpoint(keys, stateNamespaces);
237
245
  const decisionNamespaces = namespaces.filter(namespace => namespace !== 'lessons');
238
246
  const decisionKeys = decisionNamespaces.flatMap(namespace => keys[namespace].filter(key => /^decision[-_]|^cevt-.*-decision-/i.test(key)).map(key => ({ category: 'decisions', namespace, key })));
239
247
  let relevantDecisions = decisionKeys;
@@ -255,13 +263,17 @@ async function consequentialRecall({ prompt, root, store, bin, scratch, env, dea
255
263
  }
256
264
  if (!relevantDecisions.length) decisionAvailability = 'unavailable'; // semantic miss is not absent history
257
265
  }
258
- const candidates = [
259
- ...stateKeys.slice(0, 1).map(key => ({ category: 'state', namespace: path.basename(root), key })),
260
- ...keys.lessons.filter(key => /^lesson[-_]/i.test(key)).map(key => ({ category: 'lessons', namespace: 'lessons', key })),
261
- ...relevantDecisions,
262
- ];
263
- if (candidates.length > 32) return { block: formatBlock({ picks: [], status: 'unavailable bounded category selection' }), picks: [], stores: [store], status: { 'memory.db': 'unavailable' }, outcome: 'unavailable', categories: { state: 'unavailable', decisions: 'unavailable', lessons: 'unavailable' } };
264
- const categories = { state: !stateKeys.length && keys[path.basename(root)].includes('project-state-current') ? 'unavailable' : 'ok-empty', decisions: decisionAvailability, lessons: 'ok-empty' };
266
+ const stateCandidates = latestState ? [{ category: 'state', namespace: latestState.namespace, key: latestState.key }] : [];
267
+ let lessonCandidates = keys.lessons.filter(key => /^lesson[-_]/i.test(key)).map(key => ({ category: 'lessons', namespace: 'lessons', key }));
268
+ // The exact-read budget is bounded. Too many lessons must never cost the state record: lessons alone become
269
+ // unavailable (an honest marker), the newest checkpoint and the decisions are still read.
270
+ let lessonAvailability = 'ok-empty';
271
+ if (stateCandidates.length + lessonCandidates.length + relevantDecisions.length > MAX_EXACT_READS) { lessonCandidates = []; lessonAvailability = 'unavailable'; }
272
+ const candidates = [...stateCandidates, ...lessonCandidates, ...relevantDecisions];
273
+ if (candidates.length > MAX_EXACT_READS) return { block: formatBlock({ picks: [], status: 'unavailable bounded category selection' }), picks: [], stores: [store], status: { 'memory.db': 'unavailable' }, outcome: 'unavailable', categories: { state: 'unavailable', decisions: 'unavailable', lessons: 'unavailable' } };
274
+ // Checkpoint keys that exist but none of which carries an orderable epoch-ms: history we cannot date, not absence.
275
+ const unorderedState = !latestState && stateNamespaces.some(namespace => keys[namespace].some(key => /^project-state-(?:current|auto)/.test(key)));
276
+ const categories = { state: unorderedState ? 'unavailable' : 'ok-empty', decisions: decisionAvailability, lessons: lessonAvailability };
265
277
  const records = [];
266
278
  candidates.forEach(candidate => { candidate.storePath = store.path; });
267
279
  const exactResults = await exactReads({ candidates, bin, deadline, env, scratch, signal });
@@ -344,12 +356,9 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
344
356
  const query = recallQuery(prompt);
345
357
  const scratchFor = scratch || ((storePath) => rufloCwdFor(storePath, { root: rufloScratchRoot(env) }));
346
358
  if (consequential) return await consequentialRecall({ prompt, root, store, bin, scratch: scratchFor, env, deadline, binding, signal, enumerateKeys, exclusions });
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;
359
+ // Only namespaces something below reads: curated lessons/decisions, the checkpoint namespaces (dirname, its
360
+ // lowercase form, default) and turns for the single ranked search. Each one is a `ruflo memory list` process.
361
+ const namespaces = [...new Set(['lessons', 'turns', 'continuity-events', ...checkpointNamespaces(root)])];
353
362
  // This machine runs loaded (load average 30-60 measured 2026-10-10) and ranked search spawns one Ruflo process
354
363
  // per namespace; run concurrently they starve every other read past the deadline. So: ONE native enumeration
355
364
  // (reused for the curated pass and for pruning empty namespaces), then exact curated state/lessons/decisions,
@@ -362,13 +371,12 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
362
371
  enumerateKeys: sharedKeys, exclusions, rankedSearch: false }).catch(() => null) : null;
363
372
  const searchDeadline = searchDeadlineFor(Date.now(), deadline);
364
373
  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'] });
374
+ // ONE supplementary search (owner ruling 2026-10-12). Measured on a live-size store at load 17: ~10
375
+ // concurrent `ruflo memory search` processes took 1.34-1.45 s each against a ~1.1 s window, so every
376
+ // ranked search timed out. Curated state/lessons/decisions already came from exact reads above.
377
+ const searchNamespace = [path.basename(root), 'default', 'turns'].find((namespace) => activeNamespaces.includes(namespace));
378
+ const jobs = store && searchNamespace ? [{ store, namespace: searchNamespace, family: null,
379
+ args: ['--format', 'json', '-q', query, '-n', searchNamespace, '--limit', '12'] }] : [];
372
380
  const results = await Promise.all(jobs.map(async ({ store: jobStore, namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily, storePath: jobStore.path,
373
381
  ...await searchOnce({ ruflo: bin, store: jobStore, deadline: searchDeadline, env, scratch: scratchFor, args, signal }) })));
374
382
  let status = results.every((r) => r.state === 'ok') ? 'ok'
@@ -0,0 +1,42 @@
1
+ /**
2
+ * automated-run.mjs — is this Stop hook running inside an automated, machine-read run?
3
+ *
4
+ * Automated runs keep their exact output (4.7.1, check 2f). Measured: `claude -p` returned a
5
+ * "Correction to my previous reply ... Verified:" paragraph and `codex exec` returned "UNVERIFIED: ..."
6
+ * because the continuation gate asked for a correction, which corrupted callers that parse the answer.
7
+ *
8
+ * The approach is copied from the owner's own Stop gate, ~/.claude/hooks/owner-review-gate.mjs:
9
+ * line 19 — `CLAUDE_CODE_ENTRYPOINT` starting with `sdk-` (scripted `claude -p` / Agent SDK runs);
10
+ * lines 20-24 — `/bin/ps -o args= -p <ppid>` and the regex `(^|\/)codex(\s+\S+)*\s+exec(\s|$)`;
11
+ * an unknown parent keeps gating.
12
+ * One difference, and why: that gate is registered directly, so Codex is its parent. This gate runs at
13
+ * the end of a chain (node -e wrapper -> codex-hook.mjs -> codex-hook-adapter.mjs -> hook-shim.mjs ->
14
+ * this), so the Codex check walks a bounded number of ancestors, and only when the hook host is Codex.
15
+ * Interactive Claude (`cli`, desktop, VS Code entrypoints) and interactive Codex stay gated.
16
+ */
17
+ import { execFileSync } from 'node:child_process';
18
+
19
+ const CODEX_EXEC = /(^|\/)codex(\s+\S+)*\s+exec(\s|$)/;
20
+ const MAX_ANCESTORS = 12;
21
+
22
+ function psRow(pid) {
23
+ const out = execFileSync('/bin/ps', ['-o', 'ppid=,command=', '-p', String(pid)], { encoding: 'utf8', timeout: 1000 });
24
+ const m = /^\s*(\d+)\s+(.*)$/s.exec(out.trim());
25
+ return m ? { ppid: Number(m[1]), args: m[2].trim() } : null;
26
+ }
27
+
28
+ /** True when the hook belongs to an automated run. `ps` is injectable for tests. */
29
+ export function isAutomatedRun({ env = process.env, ppid = process.ppid, ps = psRow } = {}) {
30
+ if (/^sdk-/.test(env.CLAUDE_CODE_ENTRYPOINT || '')) return true;
31
+ if (env.RUVNET_HOOK_HOST !== 'codex') return false;
32
+ try {
33
+ let pid = ppid;
34
+ for (let i = 0; i < MAX_ANCESTORS && pid > 1; i++) {
35
+ const row = ps(pid);
36
+ if (!row) return false;
37
+ if (CODEX_EXEC.test(row.args)) return true;
38
+ pid = row.ppid;
39
+ }
40
+ } catch { /* unknown parent: keep gating */ }
41
+ return false;
42
+ }
@@ -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
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Which project-state checkpoint is the newest — decided by the epoch-ms in the KEY, never by `ruflo memory list`
3
+ * order, createdAt/updatedAt, semantic score or a list limit. Shared by the per-prompt recall
4
+ * (agentdb-recall.mjs) and SessionStart restore (ruflo-memory-hooks.mjs). Pure: no I/O.
5
+ */
6
+ import path from 'node:path';
7
+
8
+ /**
9
+ * Namespaces that hold a project's checkpoints. `ruflo memory list -n` is case-sensitive, and owners write
10
+ * both the dirname and its lowercase form (measured 2026-10-13: AppealArmor's newest checkpoints are all in
11
+ * `appealarmor`), plus `default` when no -n was given.
12
+ */
13
+ export function checkpointNamespaces(root) {
14
+ const base = path.basename(root);
15
+ return [...new Set([base, base.toLowerCase(), 'default'])];
16
+ }
17
+
18
+ const CHECKPOINT_KEY = /^project-state-(current|auto)-(\d{13})(?:-[A-Za-z0-9][A-Za-z0-9._-]{0,63})?$/;
19
+ const MIN_CHECKPOINT_MS = Date.UTC(2020, 0, 1);
20
+ const FUTURE_SKEW_MS = 24 * 3600 * 1000;
21
+
22
+ /** The epoch-ms a checkpoint key carries ({family, ms}), or null for a key that cannot be ordered by time. */
23
+ export function checkpointMs(key, now = Date.now()) {
24
+ const match = CHECKPOINT_KEY.exec(String(key));
25
+ if (!match) return null;
26
+ const ms = Number(match[2]);
27
+ return ms >= MIN_CHECKPOINT_MS && ms <= now + FUTURE_SKEW_MS ? { family: match[1], ms } : null;
28
+ }
29
+
30
+ /** Newest key of one family ('current' | 'auto') across {namespace: keys[]}; ties go to the earlier namespace. */
31
+ export function newestOfFamily(keysByNamespace, namespaces, family, now = Date.now()) {
32
+ let best = null;
33
+ namespaces.forEach((namespace, rank) => {
34
+ for (const key of keysByNamespace?.[namespace] ?? []) {
35
+ const parsed = checkpointMs(key, now);
36
+ if (!parsed || parsed.family !== family) continue;
37
+ if (!best || parsed.ms > best.ms || parsed.ms === best.ms && rank === best.rank && key > best.key) best = { namespace, key, ms: parsed.ms, rank };
38
+ }
39
+ });
40
+ return best && { namespace: best.namespace, key: best.key, ms: best.ms };
41
+ }
42
+
43
+ /** The newest curated project-state-current-*; an automatic project-state-auto-* only when no curated one exists. */
44
+ export function newestCheckpoint(keysByNamespace, namespaces, now = Date.now()) {
45
+ return newestOfFamily(keysByNamespace, namespaces, 'current', now) ?? newestOfFamily(keysByNamespace, namespaces, 'auto', now);
46
+ }
@@ -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
  */
@@ -42,6 +42,7 @@ import path from 'node:path';
42
42
  import os from 'node:os';
43
43
  import crypto from 'node:crypto';
44
44
  import { readStopHookInput } from './hook-input.mjs';
45
+ import { isAutomatedRun } from './automated-run.mjs';
45
46
  import {
46
47
  auditCapabilityClaims,
47
48
  buildCapabilityInventoryReceipt,
@@ -336,6 +337,11 @@ if (has('--clear')) { save({ items: [] }); console.log('ledger cleared'); proces
336
337
  */
337
338
  const hookInput = await readStopHookInput(); // shared with grounding-turn-gate: hook-input.mjs (ADR-043)
338
339
 
340
+ // AUTOMATED RUNS KEEP THEIR EXACT OUTPUT (4.7.1): scripted `claude -p` / SDK runs and `codex exec` are
341
+ // read by machines, and a correction request rewrote their answers. Same rule as the owner's
342
+ // owner-review-gate.mjs lines 15-24; see automated-run.mjs. Interactive sessions are still gated.
343
+ if (isAutomatedRun()) process.exit(EXIT_ALLOW);
344
+
339
345
  // LOOP-SAFETY 1 (ADR-043 / Fable #1) — only an affirmatively-parsed hook payload may force. A 'tty' or
340
346
  // 'unreadable' source cannot be confirmed a fresh stop, so it never forces.
341
347
  if (hookInput.__source !== 'stdin') process.exit(EXIT_ALLOW);
@@ -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
  });