ruvnet-brain 4.5.6 → 4.5.8

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 (47) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +50 -1
  3. package/kb/forge-update.mjs +50 -9
  4. package/package.json +5 -3
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/configure.md +13 -1
  8. package/plugin/hooks/codex-hooks.json +31 -1
  9. package/plugin/hooks/hook-contracts.json +82 -2
  10. package/plugin/hooks/hooks.json +31 -1
  11. package/plugin/mcp/managed-cli-generation.mjs +91 -0
  12. package/plugin/mcp/managed-cli-interface.mjs +36 -12
  13. package/plugin/mcp/server.mjs +3 -2
  14. package/plugin/scripts/agentdb-recall.mjs +38 -10
  15. package/plugin/scripts/continuation-gate.mjs +3 -1
  16. package/plugin/scripts/continuity-hook-policy.mjs +3 -0
  17. package/plugin/scripts/continuity-journal.mjs +11 -11
  18. package/plugin/scripts/hook-shim.mjs +3 -2
  19. package/plugin/scripts/kb-copy-proof.mjs +84 -22
  20. package/plugin/scripts/learn-capture.mjs +44 -0
  21. package/plugin/scripts/learn-capture.sh +2 -241
  22. package/plugin/scripts/learn-flush.mjs +86 -191
  23. package/plugin/scripts/learning-observation.mjs +51 -0
  24. package/plugin/scripts/learning-queue.mjs +135 -0
  25. package/plugin/scripts/learning-store.mjs +105 -0
  26. package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
  27. package/plugin/scripts/nightly-scheduler.mjs +6 -2
  28. package/plugin/scripts/project-capture-queue.mjs +16 -7
  29. package/plugin/scripts/project-progression-contract.mjs +58 -4
  30. package/plugin/scripts/project-progression-outbox.mjs +22 -6
  31. package/plugin/scripts/project-progression-session-start.mjs +10 -0
  32. package/plugin/scripts/project-progression-suspension.mjs +60 -0
  33. package/plugin/scripts/project-transition-hook.mjs +25 -8
  34. package/plugin/scripts/runtime-preferences.mjs +45 -4
  35. package/plugin/scripts/session-snapshot-hook.mjs +14 -7
  36. package/plugin/scripts/session-start-core.mjs +8 -0
  37. package/plugin/scripts/session-start-proof.mjs +49 -0
  38. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  39. package/scripts/codex-fresh-host-proof.mjs +179 -0
  40. package/scripts/codex-host-execution-proof.mjs +246 -0
  41. package/scripts/codex-host-proof-runtime.mjs +104 -0
  42. package/scripts/console-engine.mjs +29 -16
  43. package/scripts/health-repair.mjs +47 -68
  44. package/scripts/onboarding-console.mjs +25 -54
  45. package/scripts/qa/progression-validation-benchmark.mjs +66 -0
  46. package/scripts/release-qualification-contract.mjs +76 -7
  47. package/scripts/remedy-registry.mjs +9 -2
@@ -2,11 +2,14 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { managedGenerationIdentity } from './managed-cli-generation.mjs';
5
7
  import { loadRuntimePreferences, runtimeChildEnv } from '../scripts/runtime-preferences.mjs';
6
8
  import { projectDirectory } from '../scripts/project-identity.mjs';
7
9
  import { recordManagedCliObservation, recordRegistryLatestObservation } from '../scripts/capability-claim-evidence.mjs';
8
10
  import { runSessionSnapshotHook } from '../scripts/session-snapshot-hook.mjs';
9
11
  import { resolveProjectStore } from '../scripts/project-store-resolver.mjs';
12
+ import { isAutomaticProgressionSuspension } from '../scripts/project-progression-suspension.mjs';
10
13
 
11
14
  export const MANAGED_EXECUTABLES = Object.freeze([
12
15
  'ruflo',
@@ -34,6 +37,12 @@ const MAX_ARG_BYTES = 8192;
34
37
  const DEFAULT_FRESH_MS = 24 * 60 * 60 * 1000;
35
38
  const DEFAULT_TIMEOUT_MS = 30_000;
36
39
  const MAX_OUTPUT_BYTES = 1024 * 1024;
40
+ // Bind direct calls to the module loaded at process start, too. Missing shell resources do not
41
+ // prevent declarations from loading; the active dispatcher supplies the execution binding.
42
+ const LOCAL_BINDING = (() => {
43
+ try { return managedGenerationIdentity(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')).binding; }
44
+ catch { return null; }
45
+ })();
37
46
 
38
47
  const executableSchema = {
39
48
  type: 'string',
@@ -139,27 +148,32 @@ function stateDir(env) {
139
148
  return env.RUVNET_BRAIN_HELP_READ_DIR || path.join(brainHome, 'help-read');
140
149
  }
141
150
 
142
- function freshStamp(executable, argv, env, now = Date.now()) {
151
+ function freshStamp(executable, argv, env, binding, now = Date.now()) {
143
152
  const stamp = path.join(stateDir(env), helpKey(executable, argv));
144
- let stat;
153
+ let stat; let readAt;
145
154
  try {
146
- stat = fs.statSync(stamp);
155
+ stat = fs.lstatSync(stamp);
156
+ if (!stat.isFile()) return false;
157
+ const receipt = JSON.parse(fs.readFileSync(stamp, 'utf8'));
158
+ if (receipt.schemaVersion !== 1 || receipt.generation !== binding
159
+ || !Number.isFinite(receipt.readAt) || receipt.readAt > now) return false;
160
+ readAt = receipt.readAt;
147
161
  } catch {
148
162
  return false;
149
163
  }
150
164
  const configured = Number(env.RUVNET_BRAIN_HELP_MAX_AGE_MS);
151
165
  const maxAge = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_FRESH_MS;
152
- return now - stat.mtimeMs < maxAge;
166
+ return now - stat.mtimeMs < maxAge && now - readAt < maxAge;
153
167
  }
154
168
 
155
- function writeStamps(executable, argv, env) {
169
+ function writeStamps(executable, argv, env, binding) {
156
170
  const dir = stateDir(env);
157
171
  fs.mkdirSync(dir, { recursive: true });
158
172
  for (const key of stampKeysForHelp(executable, argv)) {
159
173
  const stamp = path.join(dir, key);
160
174
  const temporary = path.join(dir, `.${key}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
161
175
  try {
162
- fs.writeFileSync(temporary, '', { flag: 'wx', mode: 0o600 });
176
+ fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, generation: binding, readAt: Date.now() }), { flag: 'wx', mode: 0o600 });
163
177
  fs.renameSync(temporary, stamp);
164
178
  } finally {
165
179
  try { fs.unlinkSync(temporary); } catch { /* renamed or never created */ }
@@ -291,7 +305,8 @@ function managedProgressionCapture({ executable, argv, projectRoot, env, event,
291
305
  },
292
306
  } : {}),
293
307
  };
294
- return { adopted: true, ...runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host }) };
308
+ const result = runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host, env });
309
+ return isAutomaticProgressionSuspension(result) ? result : { adopted: true, ...result };
295
310
  }
296
311
 
297
312
  function resultOf(executable, argv, result) {
@@ -316,6 +331,8 @@ function resultOf(executable, argv, result) {
316
331
 
317
332
  export async function callManagedCli(toolName, args, env = process.env, fetchImpl = globalThis.fetch, lifecycle = {}) {
318
333
  try {
334
+ const binding = lifecycle.generationBinding || LOCAL_BINDING;
335
+ if (!binding) throw new Error('managed generation identity unavailable');
319
336
  const executable = assertExecutable(args?.executable);
320
337
  const argv = literalArgv(args?.argv ?? []);
321
338
  const projectRoot = env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory({ env });
@@ -344,13 +361,13 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
344
361
  stampKeysForHelp(executable, argv);
345
362
  const commandArgv = [...argv, '--help'];
346
363
  const execution = await execute(executable, commandArgv, env);
347
- if (execution.code === 0 && !execution.error) writeStamps(executable, argv, env);
364
+ if (normalizeManagedExecution(execution).outcome === 'success') writeStamps(executable, argv, env, binding);
348
365
  recordManagedCliObservation({ toolName, executable, argv: commandArgv, execution, env });
349
366
  return resultOf(executable, commandArgv, execution);
350
367
  }
351
368
 
352
369
  if (toolName === 'ruvnet_cli_run') {
353
- if (!freshStamp(executable, argv, env)) {
370
+ if (!freshStamp(executable, argv, env, binding)) {
354
371
  const key = helpKey(executable, argv).replaceAll('.', ' ');
355
372
  return {
356
373
  content: [{ type: 'text', text: `Read the interface first with ruvnet_cli_help for: ${key}` }],
@@ -391,7 +408,8 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
391
408
  : env;
392
409
  const capture = typeof lifecycle.capture === 'function' ? lifecycle.capture : managedProgressionCapture;
393
410
  const beforeCapture = await capture({ executable, argv, projectRoot, env: childEnv, event: 'PreToolUse' });
394
- if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true)) {
411
+ if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true
412
+ && !isAutomaticProgressionSuspension(beforeCapture))) {
395
413
  const reason = beforeCapture.error || beforeCapture.skipped || 'pre-execution progression was not durably captured';
396
414
  return { content: [{ type: 'text', text: `managed execution refused: ${reason}` }], isError: true };
397
415
  }
@@ -405,13 +423,19 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
405
423
  await lifecycle.afterExecute({ executable, argv, projectRoot, env: childEnv, execution, normalized });
406
424
  }
407
425
  recordManagedCliObservation({ toolName, executable, argv, execution, env });
408
- if (afterCapture?.adopted && afterCapture.progressionCaptured !== true) {
426
+ if (afterCapture?.adopted && afterCapture.progressionCaptured !== true
427
+ && !isAutomaticProgressionSuspension(afterCapture)) {
409
428
  return {
410
429
  content: [{ type: 'text', text: `managed command ${normalized.outcome}; continuity result was not durably captured: ${afterCapture.skipped || 'unknown reason'}` }],
411
430
  isError: true,
412
431
  };
413
432
  }
414
- return resultOf(executable, argv, execution);
433
+ 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
+ return result;
415
439
  }
416
440
 
417
441
  return {
@@ -35,7 +35,8 @@ import os from 'node:os';
35
35
  import readline from 'node:readline';
36
36
  import { fileURLToPath } from 'node:url';
37
37
  import { writeOwn as writeOwnReadiness } from '../scripts/mcp-readiness.mjs';
38
- import { callManagedCli, MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
38
+ import { MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
39
+ import { dispatchManagedCli } from './managed-cli-generation.mjs';
39
40
  import { unmountedNotice } from '../scripts/brain-location.mjs';
40
41
 
41
42
  const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
@@ -362,7 +363,7 @@ async function handleClient(msg) {
362
363
  case 'tools/call': {
363
364
  if (params?.name === 'ruvnet_cli_help' || params?.name === 'ruvnet_cli_run'
364
365
  || params?.name === 'ruvnet_registry_latest') {
365
- return clientOk(id, await callManagedCli(params.name, params.arguments || {}));
366
+ return clientOk(id, await dispatchManagedCli(params.name, params.arguments || {}));
366
367
  }
367
368
  if (params?.name !== 'search_ruvnet') return clientErr(id, -32602, `unknown tool: ${params?.name}`);
368
369
  refreshLease();
@@ -14,6 +14,9 @@ import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
14
14
  import { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
15
15
  import { resolveProjectStore } from './project-store-resolver.mjs';
16
16
  import { isHarnessGenerated, readStdinBounded } from './hook-input.mjs';
17
+ import { learningContext } from './runtime-preferences.mjs';
18
+ import { learningTarget, LEARNING_NAMESPACE } from './learning-store.mjs';
19
+ import { safeAction } from './learning-queue.mjs';
17
20
 
18
21
  import { redactText } from './continuity-events.mjs';
19
22
  export const STORE_FILES = Object.freeze(['memory.db']);
@@ -146,11 +149,22 @@ export function evidenceExcerpt(value, key, prompt = '') {
146
149
  return clean(start >= 0 ? text.slice(start) : text, 110);
147
150
  }
148
151
 
152
+ export function learningObservationExcerpt(raw) {
153
+ try {
154
+ const row = JSON.parse(raw);
155
+ if (Object.keys(row).sort().join(',') !== 'action,authoritative,outcome,provenance,schemaVersion,scope,tool'
156
+ || row.schemaVersion !== 1 || row.authoritative !== false || row.provenance !== 'system-observation'
157
+ || row.outcome !== 'host-reported-success' || !['project', 'user'].includes(row.scope)
158
+ || !['Bash', 'Write', 'Edit', 'MultiEdit'].includes(row.tool) || safeAction(row.tool, row.action) !== row.action) return null;
159
+ return `Unratified host-reported success: ${row.tool} ${row.action}. Verify current results.`;
160
+ } catch { return null; }
161
+ }
162
+
149
163
  export function pickRows(results, limit = 3) {
150
164
  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))
151
165
  && (!r.family || p.key.toLowerCase().includes(r.family))
152
- && Number.isFinite(p.score) && p.score >= MIN_RELEVANCE).map((p) => ({ ...p, targeted: Boolean(r.family) })));
153
- const signal = (p) => SIGNAL_NAMESPACES.has(p.namespace) ? 2 : p.namespace === 'turns' ? 1 : 0;
166
+ && Number.isFinite(p.score) && p.score >= MIN_RELEVANCE).map((p) => ({ ...p, storePath: r.storePath, targeted: Boolean(r.family) })));
167
+ const signal = (p) => SIGNAL_NAMESPACES.has(p.namespace) ? 2 : p.namespace === 'turns' ? 1 : p.namespace === LEARNING_NAMESPACE ? -1 : 0;
154
168
  const ranked = candidates.sort((a, b) => Number(b.targeted) - Number(a.targeted) || signal(b) - signal(a) || b.score - a.score);
155
169
  // Curated lessons and patterns are signal; lifecycle transcript telemetry is not.
156
170
  // Do not reserve a slot for a weak match just because its namespace was searched.
@@ -159,7 +173,7 @@ export function pickRows(results, limit = 3) {
159
173
  if (chosen.length >= limit) break;
160
174
  if (!chosen.some((r) => r.key === row.key && r.namespace === row.namespace)) chosen.push(row);
161
175
  }
162
- return chosen.map((p) => ({ store: 'memory.db', key: p.key, namespace: p.namespace,
176
+ return chosen.map((p) => ({ store: 'memory.db', ...(p.storePath ? { storePath: p.storePath } : {}), key: p.key, namespace: p.namespace,
163
177
  score: p.score, preview: clean(p.preview, 110) }));
164
178
  }
165
179
 
@@ -227,7 +241,15 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
227
241
  const budget = Number.isFinite(requested) && requested > 0 ? Math.min(requested, 1900) : DEFAULT_DEADLINE_MS;
228
242
  const deadline = started + budget;
229
243
  const { root, stores } = agentdbStores(projectDir, Math.max(1, Math.min(100, Math.floor(budget / 4))));
230
- if (!stores.length) return empty;
244
+ let learningStore;
245
+ try {
246
+ const context = learningContext({ env, cwd: root });
247
+ if (context.enabled) {
248
+ const db = learningTarget(context, { env });
249
+ if (fs.lstatSync(db).isFile()) learningStore = { name: 'memory.db', path: db };
250
+ }
251
+ } catch { /* No global fallback: only an explicitly authorized existing learning store. */ }
252
+ if (!stores.length && !learningStore) return empty;
231
253
  const bin = ruflo === undefined ? resolveRuflo({ env }) : ruflo;
232
254
  if (!bin) return { ...empty, stores };
233
255
  const store = stores[0];
@@ -238,10 +260,15 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
238
260
  : /where are we|status|catch me up/i.test(prompt) ? 'project-state-current'
239
261
  : /releas|publish|workflow run|dispatch/i.test(prompt) ? 'release'
240
262
  : /requirement|always|every prompt/i.test(prompt) ? 'decision-agentdb' : null;
241
- const jobs = namespaces.flatMap((namespace) => [{ namespace, family: null, args: ['--format', 'json', '-q', query, '-n', namespace, '--limit', '12'] },
242
- ...(family && !SIGNAL_NAMESPACES.has(namespace) && namespace !== 'turns' ? [{ namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
243
- const results = await Promise.all(jobs.map(async ({ namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily,
244
- ...await searchOnce({ ruflo: bin, store, deadline, env, scratch: scratchFor, args }) })));
263
+ const jobs = (store ? namespaces : []).flatMap((namespace) => [{ store, namespace, family: null, args: ['--format', 'json', '-q', query, '-n', namespace, '--limit', '12'] },
264
+ ...(family && !SIGNAL_NAMESPACES.has(namespace) && namespace !== 'turns' ? [{ store, namespace, family, args: ['--format', 'json', '-q', family, '-n', namespace, '-t', 'keyword', '--limit', '4'] }] : [])]);
265
+ const words = promptKeywords(prompt, 14);
266
+ const workflowQuery = words.map(word => safeAction('Bash', word))
267
+ .find(action => action && action !== 'command');
268
+ if (learningStore && workflowQuery) jobs.push({ store: learningStore, namespace: LEARNING_NAMESPACE, family: null,
269
+ args: ['--format', 'json', '-q', workflowQuery, '-n', LEARNING_NAMESPACE, '-t', 'keyword', '--limit', '4'] });
270
+ const results = await Promise.all(jobs.map(async ({ store: jobStore, namespace, family: recordFamily, args }) => ({ namespace, family: recordFamily, storePath: jobStore.path,
271
+ ...await searchOnce({ ruflo: bin, store: jobStore, deadline, env, scratch: scratchFor, args }) })));
245
272
  let status = results.every((r) => r.state === 'ok') ? 'ok'
246
273
  : results.some((r) => r.state === 'timed out') ? 'timed out' : 'unavailable';
247
274
  // Overfetch a bounded six exact values so rejected turn metadata cannot hide
@@ -250,11 +277,12 @@ export async function recall({ prompt, projectDir = process.cwd(), env = process
250
277
  // Ruflo previews are ~60 characters. Read the actual selected values within
251
278
  // the same deadline so the block contains useful evidence rather than titles.
252
279
  const retrieved = await Promise.all(candidates.map(async (p) => {
253
- const r = await searchOnce({ ruflo: bin, store, deadline, env, scratch: scratchFor, operation: 'retrieve',
280
+ const r = await searchOnce({ ruflo: bin, store: { name: 'memory.db', path: p.storePath }, deadline, env, scratch: scratchFor, operation: 'retrieve',
254
281
  args: ['-k', p.key, '-n', p.namespace, '--value-only'] });
255
282
  if (r.state === 'ok' && r.value && !r.value.startsWith('[WARN]')) {
256
283
  const clauses = p.namespace === 'turns' ? turnOutcomeClauses(r.value, prompt) : null;
257
- const preview = clauses ? turnOutcomeExcerpt(r.value, prompt) : evidenceExcerpt(r.value, p.key, prompt);
284
+ const preview = p.namespace === LEARNING_NAMESPACE ? learningObservationExcerpt(r.value)
285
+ : clauses ? turnOutcomeExcerpt(r.value, prompt) : evidenceExcerpt(r.value, p.key, prompt);
258
286
  return { pick: preview ? { ...p, preview, clauses } : null, state: 'ok' };
259
287
  }
260
288
  return { pick: null, state: r.state === 'ok' ? 'unavailable' : r.state };
@@ -385,7 +385,9 @@ function promiseBookkeeping() {
385
385
  }
386
386
  }
387
387
  const openKeys = new Set(led.items.filter((i) => i?.kind === PROMISE_KIND && !i.done && i.projectId === pid).map((i) => i.key));
388
- for (const promise of extractCommitments(hookInput.last_assistant_message)) {
388
+ // Owner preference suppresses only new capture; verified closure above and integrity audits stay active.
389
+ const newPromises = process.env.RUVNET_PROMISE_CAPTURE === 'off' ? [] : extractCommitments(hookInput.last_assistant_message);
390
+ for (const promise of newPromises) {
389
391
  if (openKeys.has(promise.key) || openKeys.size >= PROMISE_CAP_OPEN) continue;
390
392
  openKeys.add(promise.key);
391
393
  led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, at,
@@ -123,6 +123,7 @@ const registration = (id, matcher, hosts) => Object.freeze({ id, matcher, hosts:
123
123
  export const CONTINUITY_EVENTS = Object.freeze({
124
124
  SessionStart: Object.freeze([
125
125
  registration('session-start', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
126
+ registration('learn-flush', 'startup|resume|clear|compact|fork', ['claude', 'codex']),
126
127
  ]),
127
128
  UserPromptSubmit: Object.freeze([
128
129
  registration('session-snapshot', '*', ['claude', 'codex']),
@@ -153,6 +154,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
153
154
  // no change either (proven live: the real MCP tool_response's `content[0].text` carries the exact
154
155
  // "Searched N RuvNet repos" banner and query text the script already looks for).
155
156
  PostToolUse: Object.freeze([
157
+ registration('learn-capture', '^(?:Bash|Write|Edit|MultiEdit|exec_command|apply_patch|functions(?:\\.|__)(?:exec_command|apply_patch))$', ['claude', 'codex']),
156
158
  registration('session-snapshot', '*', ['claude', 'codex']),
157
159
  registration('grounding-stamp', '^(?:.*__)?search_ruvnet$', ['claude', 'codex']),
158
160
  ]),
@@ -184,6 +186,7 @@ export const CONTINUITY_EVENTS = Object.freeze({
184
186
  registration('session-snapshot', '*', ['claude']),
185
187
  ]),
186
188
  SessionEnd: Object.freeze([
189
+ registration('learn-flush', '*', ['claude', 'codex']),
187
190
  registration('session-snapshot', '*', ['claude', 'codex']),
188
191
  ]),
189
192
  });
@@ -303,24 +303,24 @@ export function recordingLine(status, now = Date.now()) {
303
303
  return `AgentDB: recording ✓ (last write ${ago(now - status.lastCommitAt)} ago, ${status.eventsToday} event(s) today, outbox ${status.pending} pending)`;
304
304
  }
305
305
 
306
- /**
307
- * The Claude Stop line, at most ONCE per session per condition (review S3: it repeated at every turn).
308
- * State is a tiny file in the project's own .swarm, bounded to the last 20 sessions.
309
- */
310
- export function stopNotice({ journal, status, session }) {
311
- if (!status?.stuck || !status.problem) return '';
312
- const file = path.join(journal.swarm, NOTICE_STATE);
306
+ /** Shared notices: once per session/condition, bounded to the last twenty sessions in .swarm. */
307
+ export function conditionNotice({ swarm, session, condition, message, now = Date.now }) {
308
+ const file = path.join(swarm, NOTICE_STATE);
313
309
  let state = {};
314
310
  try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first notice */ }
315
311
  const id = String(session || 'unknown');
316
312
  const shown = Array.isArray(state[id]?.conditions) ? state[id].conditions : [];
317
- if (shown.includes(status.problem)) return '';
318
- state[id] = { at: new Date(journal.now()).toISOString(), conditions: [...shown, status.problem] };
313
+ if (shown.includes(condition)) return '';
314
+ state[id] = { at: new Date(now()).toISOString(), conditions: [...shown, condition] };
319
315
  const recent = Object.entries(state).sort((a, b) => String(b[1]?.at).localeCompare(String(a[1]?.at))).slice(0, 20);
320
316
  try { fs.writeFileSync(file, JSON.stringify(Object.fromEntries(recent)), { mode: 0o600 }); } catch { /* still show it once */ }
321
- return `[RuvNet Brain] ${recordingLine(status, journal.now())}`;
317
+ return message;
318
+ }
319
+ export function stopNotice({ journal, status, session }) {
320
+ if (!status?.stuck || !status.problem) return '';
321
+ return conditionNotice({ swarm: journal.swarm, session, condition: status.problem, now: () => journal.now(),
322
+ message: `[RuvNet Brain] ${recordingLine(status, journal.now())}` });
322
323
  }
323
-
324
324
  function defaultStore({ ruflo, db, key, value }) {
325
325
  const cwd = rufloRunDir(db);
326
326
  try {
@@ -86,7 +86,8 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
86
86
  const TABLE = {
87
87
  'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
88
88
  'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
89
- 'capacity-aware-parallel-work': { file: 'capacity-aware-parallel-work.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 32768 },
89
+ // Capacity advice is a Brain prompt hint; OFF must not execute its resource probes or steer work.
90
+ 'capacity-aware-parallel-work': { file: 'capacity-aware-parallel-work.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
90
91
  // ADR-063 / issue #103: `blocking` so an opt-in refusal can actually reach the host. The hook
91
92
  // still exits 0 for every user at the shipped default (managedMemoryBoundary=advise), so this
92
93
  // changes the CEILING of what it may do, not what it does.
@@ -118,7 +119,7 @@ const TABLE = {
118
119
  // own header already says: "remains reachable through hook-shim's dispatch table by explicit
119
120
  // invocation". Restored. wired-check.mjs's H6 fix does not depend on these keys existing either
120
121
  // way — it stopped trusting hook-shim.mjs as a blind generic spawner, not their presence here.
121
- 'learn-capture': { file: 'learn-capture.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' },
122
+ 'learn-capture': { file: 'learn-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 65536 },
122
123
  'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' },
123
124
  // 1 MiB, not 64 KiB: the Stop payload now carries `last_assistant_message`, and a long closing
124
125
  // message truncated mid-JSON would parse as `{}` and silently drop the whole capture.
@@ -2,20 +2,23 @@
2
2
  //
3
3
  // kb-copy-proof.mjs — may a full copy of the knowledge base be deleted? The ONE proof used by the
4
4
  // footprint sweep (plugin/scripts/brain-footprint.mjs) and by the installer right after it activates a
5
- // new generation (bin/install.mjs unzipInto). Pure read; never follows a link; never writes.
5
+ // new generation (bin/install.mjs unzipInto). Pure read; checks KB-root ancestors and file/link types
6
+ // before comparing content; never writes.
6
7
  //
7
8
  // A copy is DISPOSABLE only when nothing in it is unique. Every file must be one of:
8
9
  // * a PRIVATE-store file (names from the PRIVATE-STORES.json fence of the live brain AND of the copy, plus
9
10
  // every updateManaged:false store in either SOURCE.json; membership rule = kb/forge-update.mjs
10
11
  // capturePrivateOverlayState) that exists BYTE-IDENTICAL at the same path in the live brain — nothing
11
12
  // else excuses a private file;
12
- // * a public release file: listed with these exact bytes in the copy's own ARCHIVE-MANIFEST.json, or a
13
- // name the live generation ships, or a member of a public store family (named by either COVERAGE.json
13
+ // * a file whose regular-file bytes survive exactly at the same path in live, or a public release file
14
+ // listed with these exact bytes in the copy's own ARCHIVE-MANIFEST.json, or a public store family (named by either COVERAGE.json
14
15
  // or the live SOURCE.json) that a newer release replaced or retired;
15
16
  // * installer-written or reinstallable (node_modules/, the updater/validator files the installer places);
16
17
  // * a symbolic link identical in the live brain (links are compared, never followed).
17
18
  // Anything else — a user's own file, an unfenced store, a link the live brain lacks — KEEPS the copy, and
18
- // is named. The live brain itself must be present, so a copy is never removed while it may be the only one.
19
+ // is named. Paths are checked for symlink ancestors within both KB roots before any exemption; host
20
+ // aliases above those roots (such as macOS /tmp) are not traversed as part of this check. Invalid metadata,
21
+ // unreadable inventory and special file types keep the copy. The live brain itself must be present.
19
22
  import fs from 'node:fs';
20
23
  import path from 'node:path';
21
24
  import crypto from 'node:crypto';
@@ -29,13 +32,13 @@ const sha256File = (file) => crypto.createHash('sha256').update(fs.readFileSync(
29
32
  /** Every regular file and link under `root`, relative, without following links. macOS volume metadata
30
33
  * (AppleDouble `._*` shadows on an exFAT disk, .DS_Store, …) is the volume's, never a copy's unique data. */
31
34
  function walk(root, prefix = '', out = []) {
32
- for (const name of names(path.join(root, prefix)).filter((n) => !isVolumeMetadata(n))) {
35
+ for (const name of fs.readdirSync(path.join(root, prefix)).sort().filter((n) => !isVolumeMetadata(n))) {
33
36
  const relative = prefix ? path.join(prefix, name) : name;
34
- const st = lstat(path.join(root, relative));
35
- if (!st) continue;
37
+ const st = fs.lstatSync(path.join(root, relative));
36
38
  if (st.isSymbolicLink()) out.push({ relative, link: true });
37
39
  else if (st.isDirectory()) walk(root, relative, out);
38
40
  else if (st.isFile()) out.push({ relative, link: false, size: st.size });
41
+ else throw new Error(`unsupported file type: ${relative}`);
39
42
  }
40
43
  return out;
41
44
  }
@@ -46,10 +49,11 @@ const storeList = (source) => {
46
49
  return stores && typeof stores === 'object' ? Object.entries(stores).map(([kbName, value]) => ({ kbName, ...value })) : [];
47
50
  };
48
51
  const stemOf = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i, '').toLowerCase();
49
- // The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store>.meta.json, <store>.passages.jsonl,
50
- // <store>.symbols.json, <store>-primer.md.
52
+ // The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store>[.big].meta.json,
53
+ // <store>[.big].passages.jsonl, <store>[.big].symbols.json, <store>-primer.md.
51
54
  const storeStem = (file) => path.basename(String(file)).toLowerCase()
52
- .replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '').replace(/\.(?:meta|symbols)\.json$|\.passages\.jsonl$/, '');
55
+ .replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '')
56
+ .replace(/(?:\.big)?(?:\.(?:meta|symbols)\.json|\.passages\.jsonl)$/, '');
53
57
  // Files the installer/updater writes into a KB that no bundle ships (bin/install.mjs placeUpdater,
54
58
  // placeTrustedCoverageValidator, ensureVerifier; the updater's snapshot receipt). Re-created on every install.
55
59
  const INSTALLER_WRITTEN = new Set(['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json', '.refresh-snapshot.json',
@@ -87,6 +91,54 @@ const sameBytes = (left, right) => {
87
91
  try { return sha256File(left) === sha256File(right); } catch { return false; }
88
92
  };
89
93
 
94
+ function safeAncestors(root, relative) {
95
+ let dir = root;
96
+ for (const part of ['', ...relative.split(path.sep).slice(0, -1)]) {
97
+ if (part) dir = path.join(dir, part);
98
+ let stat;
99
+ try { stat = fs.lstatSync(dir); }
100
+ catch (error) { return error.code === 'ENOENT'; } // a retired public path may be absent in live
101
+ if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
102
+ }
103
+ return true;
104
+ }
105
+
106
+ function metadataFailure(root) {
107
+ for (const file of ['SOURCE.json', 'PRIVATE-STORES.json', 'RVF-GENERATIONS.json',
108
+ 'COVERAGE.json', 'ARCHIVE-MANIFEST.json', 'repo-aliases.json']) {
109
+ const full = path.join(root, file);
110
+ let stat;
111
+ try { stat = fs.lstatSync(full); }
112
+ catch (error) { if (error.code === 'ENOENT' && file !== 'SOURCE.json') continue; return `${file}: ${error.message}`; }
113
+ try {
114
+ if (!stat.isFile() || stat.isSymbolicLink()) throw new Error('not a regular metadata file');
115
+ const value = JSON.parse(fs.readFileSync(full, 'utf8'));
116
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('not a metadata object');
117
+ if (file === 'PRIVATE-STORES.json' && Object.hasOwn(value, 'privateStores')
118
+ && (!Array.isArray(value.privateStores) || value.privateStores.some((name) => typeof name !== 'string' || !name.trim()))) {
119
+ throw new Error('private store fence is malformed');
120
+ }
121
+ if (file === 'SOURCE.json' && Object.hasOwn(value, 'stores')) {
122
+ if (!value.stores || typeof value.stores !== 'object'
123
+ || Object.values(value.stores).some((store) => !store || typeof store !== 'object' || Array.isArray(store))) {
124
+ throw new Error('store metadata is malformed');
125
+ }
126
+ if (storeList(value).some((store) => !store || typeof store !== 'object'
127
+ || (store.updateManaged === false && (typeof store.kbName !== 'string' || !store.kbName.trim())))) {
128
+ throw new Error('private store metadata is malformed');
129
+ }
130
+ }
131
+ if (file === 'RVF-GENERATIONS.json' && Object.hasOwn(value, 'stores')
132
+ && (!value.stores || typeof value.stores !== 'object' || Array.isArray(value.stores)
133
+ || Object.values(value.stores).some((generation) => !generation || typeof generation !== 'object'
134
+ || typeof generation.file !== 'string' || !generation.file.trim()))) throw new Error('generation metadata is malformed');
135
+ if (file === 'COVERAGE.json' && value.rows != null && !Array.isArray(value.rows)) throw new Error('coverage rows are malformed');
136
+ if (file === 'ARCHIVE-MANIFEST.json' && value.files != null && !Array.isArray(value.files)) throw new Error('archive inventory is malformed');
137
+ } catch (error) { return `${file}: ${error.message}`; }
138
+ }
139
+ return null;
140
+ }
141
+
90
142
  /**
91
143
  * @returns {{disposable: boolean, unique: {file: string, why: string}[], reason: string}} — a kept copy
92
144
  * always names the files that keep it.
@@ -95,13 +147,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
95
147
  const copy = lstat(copyDir);
96
148
  if (!copy || copy.isSymbolicLink() || !copy.isDirectory()) return { disposable: false, unique: [], reason: 'not a real directory (a link is never entered)' };
97
149
  const live = lstat(liveDir);
98
- if (!live || !live.isDirectory() || !readJson(path.join(liveDir, 'SOURCE.json'))
99
- || !names(liveDir).some((name) => /\.rvf$/i.test(name))) {
150
+ if (!live || !live.isDirectory()) {
151
+ return { disposable: false, unique: [], reason: 'the live brain is missing or incomplete, so this copy may be the only good one' };
152
+ }
153
+ for (const root of [copyDir, liveDir]) {
154
+ const failure = metadataFailure(root);
155
+ if (failure) return { disposable: false, unique: [], reason: `unreadable or malformed metadata in ${root}: ${failure}` };
156
+ }
157
+ if (!names(liveDir).some((name) => /\.rvf$/i.test(name) && lstat(path.join(liveDir, name))?.isFile())) {
100
158
  return { disposable: false, unique: [], reason: 'the live brain is missing or incomplete, so this copy may be the only good one' };
101
159
  }
102
160
  const privateNames = privateStoreNames([liveDir, copyDir]);
103
- const generations = readJson(path.join(copyDir, 'RVF-GENERATIONS.json'))?.stores || {};
104
- const privateArtifacts = Object.entries(generations).filter(([name]) => privateNames.has(name.toLowerCase()))
161
+ const generations = [copyDir, liveDir].flatMap((dir) => Object.entries(readJson(path.join(dir, 'RVF-GENERATIONS.json'))?.stores || {}));
162
+ const privateArtifacts = generations.filter(([name]) => privateNames.has(name.toLowerCase()))
105
163
  .map(([, g]) => String(g?.file || '')).filter(Boolean).map((file) => ({ directory: path.dirname(path.normalize(file)),
106
164
  basename: path.basename(file).toLowerCase(), stem: stemOf(file) }));
107
165
  const coverageNames = (dir) => (readJson(path.join(dir, 'COVERAGE.json'))?.rows || [])
@@ -118,15 +176,10 @@ export function kbCopyProof({ copyDir, liveDir }) {
118
176
  let files;
119
177
  try { files = walk(copyDir); } catch (error) { return { disposable: false, unique, reason: `unreadable copy: ${error.message}` }; }
120
178
  for (const { relative, link, size } of files) {
121
- // node_modules (npm reinstalls it) and .console-runtime (bin/install.mjs installConsoleRuntime re-places it)
122
- if (['node_modules', '.console-runtime'].includes(relative.split(path.sep)[0])
123
- || (!relative.includes(path.sep) && INSTALLER_WRITTEN.has(relative))) continue;
124
179
  const isPrivate = belongsToPrivate(relative, privateNames, privateArtifacts);
125
180
  const inLive = path.join(liveDir, relative);
126
- if (link) {
127
- const liveLink = lstat(inLive);
128
- const same = liveLink?.isSymbolicLink() && fs.readlinkSync(inLive) === fs.readlinkSync(path.join(copyDir, relative));
129
- if (!same) unique.push({ file: relative, why: 'a symbolic link the live brain does not have (never followed)' });
181
+ if (!safeAncestors(copyDir, relative) || !safeAncestors(liveDir, relative)) {
182
+ unique.push({ file: relative, why: 'unsafe path ancestor inside a KB root (never followed)' });
130
183
  continue;
131
184
  }
132
185
  if (isPrivate) {
@@ -135,10 +188,19 @@ export function kbCopyProof({ copyDir, liveDir }) {
135
188
  }
136
189
  continue;
137
190
  }
191
+ // Private membership above takes precedence over reinstallable runtime and release exemptions.
192
+ if (['node_modules', '.console-runtime'].includes(relative.split(path.sep)[0])
193
+ || (!relative.includes(path.sep) && INSTALLER_WRITTEN.has(relative))) continue;
194
+ if (link) {
195
+ const liveLink = lstat(inLive);
196
+ const same = liveLink?.isSymbolicLink() && fs.readlinkSync(inLive) === fs.readlinkSync(path.join(copyDir, relative));
197
+ if (!same) unique.push({ file: relative, why: 'a symbolic link the live brain does not have (never followed)' });
198
+ continue;
199
+ }
138
200
  if (relative === 'ARCHIVE-MANIFEST.json' && shipped.size) continue; // the release manifest itself
139
201
  const listed = shipped.get(relative);
140
202
  if (listed && listed.bytes === size && listed.sha256 === sha256File(path.join(copyDir, relative))) continue;
141
- if (lstat(inLive)) continue; // a release-owned name the live generation ships (same or newer bytes)
203
+ if (sameBytes(path.join(copyDir, relative), inLive)) continue; // existence alone proves no ownership or redundancy
142
204
  if (publicNames.has(storeStem(relative))) continue; // a public store family a newer release replaced or retired
143
205
  unique.push({ file: relative, why: 'not in the live brain, not in this copy\'s release manifest, not a public store' });
144
206
  }
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ // PostToolUse captures only a fixed workflow vocabulary, then schedules bounded orphan recovery.
3
+ import path from 'node:path';
4
+ import { randomUUID, createHash } from 'node:crypto';
5
+ import { spawn } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { readStdinBounded } from './hook-input.mjs';
8
+ import { learningContext } from './runtime-preferences.mjs';
9
+ import { learningTarget } from './learning-store.mjs';
10
+ import { safeQueue, safeAction, writeExclusive, takeQueueLock, releaseQueueLock } from './learning-queue.mjs';
11
+
12
+ try {
13
+ const context = learningContext();
14
+ if (!context.enabled) process.exit(0);
15
+ const raw = await readStdinBounded({ maxBytes: 65536, emptyMs: 150 });
16
+ const input = JSON.parse(raw.toString());
17
+ if (input.hook_event_name && input.hook_event_name !== 'PostToolUse') process.exit(0);
18
+ const response = input.tool_response;
19
+ if (typeof response === 'string') {
20
+ const exit = /^Process exited with code (-?\d+)\s*$/m.exec(response);
21
+ if ((exit && Number(exit[1]) !== 0) || /Process running with session ID/.test(response)) process.exit(0);
22
+ }
23
+ if (input.error || input.is_error === true || response?.error || response?.interrupted === true || response?.signal
24
+ || response?.success === false || response?.is_error === true || response?.isError === true
25
+ || [response?.exit_code, response?.exitCode, response?.status].some(value => Number.isInteger(value) && value !== 0)) process.exit(0);
26
+ const tool = input.tool_name;
27
+ if (!['Bash', 'Write', 'Edit', 'MultiEdit'].includes(tool)) process.exit(0);
28
+ const action = safeAction(tool, tool === 'Bash' ? input.tool_input?.command : 'edit file');
29
+ if (!action) process.exit(0);
30
+ const fresh = learningContext();
31
+ if (!fresh.enabled || fresh.queueDir !== context.queueDir) process.exit(0);
32
+ learningTarget(fresh); // Existing authorized user store is required before creating any user queue bytes.
33
+ const dir = safeQueue(fresh, true);
34
+ const sid = createHash('sha256').update(String(input.session_id || process.env.CLAUDE_SESSION_ID || 'default')).digest('hex').slice(0, 24);
35
+ writeExclusive(path.join(dir, `session-${sid}-${randomUUID()}.jsonl`), JSON.stringify({ tool, action }) + '\n');
36
+ // Per-scope lock in the flusher suppresses duplicate workers; capture never waits on the learner.
37
+ const token = takeQueueLock(fresh);
38
+ if (!token) process.exit(0);
39
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url)), '--supervisor'], {
40
+ cwd: fresh.projectDir, detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, RUVNET_LEARN_WORKER_TOKEN: token },
41
+ });
42
+ child.on('error', () => releaseQueueLock(fresh, token)); child.unref();
43
+ } catch { /* advisory transport; unsafe/malformed input produces no persisted payload */ }
44
+ process.exit(0);