ruvnet-brain 4.6.2 → 4.6.3

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.
@@ -12,6 +12,7 @@
12
12
  * RETAIN_COMMITTED_MS, ages quarantine/corrupt notices after QUARANTINE_REPORT_MS, and bounds committed
13
13
  * history at MAX_EVENT_RECORDS. Pending events are never dropped; prolonged outages report pressure.
14
14
  */
15
+ import { nativeTurnLines } from './native-turn-transcript.mjs';
15
16
  import fs from 'node:fs';
16
17
  import os from 'node:os';
17
18
  import path from 'node:path';
@@ -137,7 +138,9 @@ export class ContinuityJournal {
137
138
  scan() {
138
139
  this.requireBudget();
139
140
  let text = '';
140
- try { text = fs.readFileSync(this.path, 'utf8'); } catch { /* no outbox yet */ }
141
+ try { text = fs.readFileSync(this.path, 'utf8'); } catch (error) {
142
+ if (error.code !== 'ENOENT') throw new Error(`continuity outbox unreadable: ${error.code || 'I/O error'}`);
143
+ }
141
144
  const events = new Map();
142
145
  const committed = new Map();
143
146
  const failures = new Map();
@@ -367,23 +370,23 @@ export function stopNotice({ journal, status, session }) {
367
370
  }
368
371
  function defaultStore({ ruflo, db, key, value }) {
369
372
  const cwd = rufloRunDir(db);
373
+ const source = JSON.parse(value).source;
374
+ const provenance = ['assistant-detected', 'owner-correction-detected', 'agent-result'].includes(source) ? 'agent_output' : 'system_observation';
370
375
  try {
371
376
  const { executable, args } = rufloInvocation(ruflo, ['memory', 'store', '--key', key, '--value', value,
372
- '--namespace', CONTINUITY_NAMESPACE, '--no-upsert', '--provenance', 'system_observation', '--path', db]);
377
+ '--namespace', CONTINUITY_NAMESPACE, '--no-upsert', '--require-native', '--append-only', '--provenance', provenance, '--path', db]);
373
378
  const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true,
374
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
379
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined } });
375
380
  return { status: Number.isInteger(r.status) ? r.status : 1, output: `${r.stderr || ''}\n${r.stdout || ''}${r.error ? `\n${r.error.message}` : ''}` };
376
381
  } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
377
382
  }
378
383
 
379
384
  function defaultReadBack({ ruflo, db, key }) {
380
- const fast = withProgressionReader(db, (reader) => reader.readContent(CONTINUITY_NAMESPACE, key));
381
- if (fast.ok) return { key, content: fast.value, readPath: 'node:sqlite' };
382
385
  const cwd = rufloRunDir(db);
383
386
  try {
384
387
  const { executable, args } = rufloInvocation(ruflo, ['memory', 'retrieve', '--key', key, '--namespace', CONTINUITY_NAMESPACE, '--value-only', '--path', db]);
385
- const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' } });
386
- return { key, content: r.status === 0 ? String(r.stdout || '') : null, readPath: `ruflo-cli (${fast.reason})` };
388
+ const r = spawnSync(executable, args, { cwd, encoding: 'utf8', timeout: 60_000, windowsHide: true, env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined } });
389
+ return { key, content: r.status === 0 ? String(r.stdout || '') : null, readPath: 'ruflo-cli' };
387
390
  } finally { fs.rmSync(cwd, { recursive: true, force: true }); }
388
391
  }
389
392
 
@@ -414,15 +417,18 @@ export function drain(journal, {
414
417
  if (now() >= deadline) break;
415
418
  const value = JSON.stringify(privateContinuityEvent(rec.event, consent.contentPathExcludes, consent.capturePath));
416
419
  if (value !== JSON.stringify(rec.event)) return { committed, failed, remaining: journal.pending().length, skipped: 'content exclusions changed; immutable continuity retained' };
417
- attempts += 1; const result = store({ ruflo, db: journal.db, key: rec.key, value });
418
- const back = readBack({ ruflo, db: journal.db, key: rec.key });
420
+ attempts += 1;
421
+ const prior = readBack({ ruflo, db: journal.db, key: rec.key });
422
+ const alreadyStored = typeof prior.content === 'string' && prior.content.trim() === value && (prior.key === undefined || prior.key === rec.key);
423
+ const result = alreadyStored ? { status: 0 } : store({ ruflo, db: journal.db, key: rec.key, value });
424
+ const back = alreadyStored ? prior : readBack({ ruflo, db: journal.db, key: rec.key });
419
425
  const content = typeof back.content === 'string' ? back.content.trim() : '';
420
- if (content === value && digestCanonical(rec.event) === rec.digest && (back.key === undefined || back.key === rec.key)) {
426
+ if (result.status === 0 && content === value && digestCanonical(rec.event) === rec.digest && (back.key === undefined || back.key === rec.key)) {
421
427
  journal.appendRecords([{ type: 'commit', key: rec.key, digest: rec.digest, committedAt: new Date(now()).toISOString(), readPath: back.readPath,
422
- alreadyStored: result.status !== 0 || content !== value, ...(attempts > 1 ? { attempts, lastError: last?.reason } : {}) }]);
428
+ alreadyStored, ...(attempts > 1 ? { attempts, lastError: last?.reason } : {}) }]);
423
429
  committed += 1;
424
430
  done = true;
425
- } else if (content) {
431
+ } else if (content && (content !== value || digestCanonical(rec.event) !== rec.digest || (back.key !== undefined && back.key !== rec.key))) {
426
432
  // A DIFFERENT row holds our key: never overwritten (--no-upsert), never retried, reported.
427
433
  journal.appendRecords([{ type: 'quarantine', key: rec.key, at: new Date(now()).toISOString(), reason: 'a different stored row holds this key' }]);
428
434
  failed += 1;
@@ -466,7 +472,7 @@ export function takeLock(journal, { now = Date.now } = {}) {
466
472
  export function launchDrain({ projectRoot, spawnFn = spawn, env = process.env } = {}) {
467
473
  try {
468
474
  const child = spawnFn(process.execPath, [fileURLToPath(import.meta.url), '--drain', projectRoot], {
469
- cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
475
+ cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined },
470
476
  });
471
477
  child.unref?.();
472
478
  return true;
@@ -513,13 +519,13 @@ export function captureContinuityEvents({
513
519
  events.push(...collectCommits({ checkoutRoot: resolution.checkoutRoot, sinceMs, host, session, project }));
514
520
  events.push(...collectReleases({ checkoutRoot: resolution.checkoutRoot, sinceMs, host, session, project }));
515
521
  }
516
- if (event === 'Stop') {
522
+ if (['Stop', 'SessionEnd', 'PreCompact', 'SubagentStop'].includes(event)) {
517
523
  let lines = null;
518
- if (host === 'claude' && typeof payload.transcript_path === 'string' && payload.transcript_path) {
524
+ if (typeof payload.transcript_path === 'string' && payload.transcript_path) {
519
525
  try { lines = readTranscript(payload.transcript_path); } catch { /* unreadable transcript: git events still count */ }
520
526
  }
521
527
  const last = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message : ''; const policy = journal.captureConsent();
522
- if (!policy.skipped && !(lines && turnReferencesExcludedResource(claudeTurn(lines), policy.contentPathExcludes, projectDir))) events.push(...collectTurnEvents({ lines, lastAssistantMessage: last, host, session, project, env, at: now() }));
528
+ if (!policy.skipped && !(lines && turnReferencesExcludedResource(claudeTurn(nativeTurnLines(lines, host)), policy.contentPathExcludes, projectDir))) events.push(...collectTurnEvents({ lines, lastAssistantMessage: last, host, session, project, env, at: now() }));
523
529
  }
524
530
  try { report.recorded = journal.record(events).length; } catch (error) { return { ...report, skipped: `outbox write failed: ${error.message}` }; }
525
531
  try { if (journal.needsCompaction()) journal.compact(); } catch { /* bounded next time */ }
@@ -0,0 +1,51 @@
1
+ /** Bounded, mechanical Codex JSONL normalization. No model inference or user-text persistence. */
2
+ const LIMIT = 2 * 1024 * 1024;
3
+ const text = content => typeof content === 'string' ? content : Array.isArray(content)
4
+ ? content.filter(part => ['text', 'input_text', 'output_text'].includes(part?.type)).map(part => part.text || '').join('\n') : '';
5
+ const message = (role, content) => ({ message: { role, content } });
6
+ const parse = value => { try { return JSON.parse(value); } catch { return null; } };
7
+
8
+ /** Keep call IDs intact; torn/unknown records cannot manufacture successful tool evidence. */
9
+ export function nativeTurnLines(lines, host) {
10
+ if (host !== 'codex') return lines;
11
+ const records = []; let bytes = 0;
12
+ for (let i = lines.length - 1; i >= 0; i--) {
13
+ bytes += Buffer.byteLength(String(lines[i]));
14
+ if (bytes > LIMIT) break;
15
+ const record = parse(lines[i]); if (record) records.push(record);
16
+ }
17
+ records.reverse();
18
+ const out = []; const sessions = new Map(); const calls = new Map();
19
+ for (const record of records) {
20
+ const p = record.payload;
21
+ if (record.type === 'event_msg' && p?.type === 'user_message') {
22
+ out.push(message('user', p.message || '')); continue;
23
+ }
24
+ if (record.type === 'event_msg' && p?.type === 'agent_message' && typeof p.message === 'string') {
25
+ out.push(message('assistant', p.message)); continue;
26
+ }
27
+ if (record.type !== 'response_item' || !p) continue;
28
+ if (p.type === 'message' && ['user', 'assistant'].includes(p.role)) {
29
+ out.push(message(p.role, text(p.content))); continue;
30
+ }
31
+ if (['function_call', 'custom_tool_call'].includes(p.type) && typeof p.call_id === 'string') {
32
+ const args = typeof p.arguments === 'string' ? parse(p.arguments) : p.arguments;
33
+ let name = String(p.name || '').split('.').at(-1); let input = args || {};
34
+ if (name === 'exec_command' && typeof args?.cmd === 'string') { name = 'Bash'; input = { command: args.cmd }; }
35
+ if (name === 'apply_patch') input = { input: p.input || '' };
36
+ if (name === 'write_stdin' && sessions.has(args?.session_id)) {
37
+ name = 'Bash'; input = { command: sessions.get(args.session_id) };
38
+ }
39
+ calls.set(p.call_id, { name, input });
40
+ out.push(message('assistant', [{ type: 'tool_use', id: p.call_id, name, input }]));
41
+ } else if (['function_call_output', 'custom_tool_call_output'].includes(p.type) && typeof p.call_id === 'string') {
42
+ const result = typeof p.output === 'string' ? parse(p.output) : p.output;
43
+ const call = calls.get(p.call_id);
44
+ if (Number.isSafeInteger(result?.session_id) && call?.name === 'Bash') sessions.set(result.session_id, call.input.command);
45
+ // Native structured exit fields are retained; opaque text is never guessed to be a pass.
46
+ out.push(message('user', [{ ...(result && typeof result === 'object' ? result : {}),
47
+ type: 'tool_result', tool_use_id: p.call_id, content: typeof p.output === 'string' ? p.output : JSON.stringify(p.output ?? '') }]));
48
+ }
49
+ }
50
+ return out.map(record => JSON.stringify(record));
51
+ }
@@ -318,6 +318,8 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
318
318
  const options = { rawInput: JSON.stringify(job.payload), host: job.host, env,
319
319
  budgetMs: Math.max(0, deadlineAt - now()), deadlineAt, signal,
320
320
  makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
321
+ // A queued job was admitted at its live boundary; replay must not re-run (or be suspended by) enrollment.
322
+ enrollMemory: () => ({ state: 'existing' }),
321
323
  captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
322
324
  captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) };
323
325
  const result = job.payload?.normalizedTransition ? captureNormalized(job, options)
@@ -0,0 +1,203 @@
1
+ /** Consent-gated lazy enrollment. Hooks queue derived events before a detached native bootstrap. */
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import crypto from 'node:crypto';
6
+ import { spawn, spawnSync } from 'node:child_process';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
9
+ import { resolveTurnDb, turnCapturePolicyFile } from './turn-outcome-capture.mjs';
10
+ import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
11
+ import { redactText } from './continuity-events.mjs';
12
+ import { enrichStateWithObservation } from './project-progression-hook.mjs';
13
+ import { redactProgression } from './project-progression-contract.mjs';
14
+ import { normalizeHostEvent } from './hook-input.mjs';
15
+ import { maskExcludedPaths, privateProgressionState } from './turn-capture-privacy.mjs';
16
+
17
+ const canonical = (value) => { try { return fs.realpathSync.native(value); } catch { return path.resolve(value); } };
18
+ export const ENROLLMENT_NAMESPACE = 'project-enrollment';
19
+ const receiptName = '.memory-enrollment.json';
20
+ const queueName = '.memory-enrollment-pending';
21
+
22
+ export function enrollmentPlan({ projectDir, env = process.env, deadlineAt = Infinity,
23
+ temporaryRoots = [os.tmpdir(), '/tmp', '/private/tmp'], systemRoots = ['/System', '/Library', '/usr', '/etc', '/var', '/private/var', '/proc', '/sys', '/dev'] } = {}) {
24
+ const home = canonical(env.HOME || env.USERPROFILE || os.homedir());
25
+ const brainHome = canonical(env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'));
26
+ const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: 500, deadlineAt });
27
+ // The consent guard and the plan must agree on which roots are excluded from default enrollment.
28
+ const consent = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 500, deadlineAt, enrollmentRoots: { temporaryRoots, systemRoots } });
29
+ if (consent.skipped) return { ...resolution, state: 'disabled', reason: consent.skipped };
30
+ let policy = {};
31
+ try { policy = JSON.parse(fs.readFileSync(turnCapturePolicyFile(brainHome), 'utf8')); } catch { /* resolveTurnDb validates any present policy */ }
32
+ const explicit = policy.paths?.[canonical(projectDir)] ?? policy.projects?.[resolution.projectRoot];
33
+ const hasDb = fs.existsSync(resolution.canonicalAgentDbPath);
34
+ // RUVNET_TURN_CAPTURE=off means turn capture is off: it never creates a store. (It does not suspend progression or
35
+ // continuity capture on an existing store; queueBoundary additionally withholds the assistant text while it is off.)
36
+ if (!hasDb && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { ...resolution, state: 'disabled', reason: 'RUVNET_TURN_CAPTURE=off' };
37
+ if (!hasDb && explicit !== 'on') {
38
+ if (resolution.kind !== 'git') return { ...resolution, state: 'disabled', reason: 'non-Git enrollment requires explicit project opt-in' };
39
+ if (!globalEnrollmentAllowed({ resolution, home, brainHome, temporaryRoots, systemRoots })) {
40
+ return { ...resolution, state: 'disabled', reason: 'global enrollment excludes home, temporary, system and Brain cache roots' };
41
+ }
42
+ }
43
+ let enrollmentPending = false;
44
+ const receipt = path.join(resolution.projectRoot, '.swarm', receiptName);
45
+ if (fs.existsSync(receipt)) {
46
+ const stat = fs.lstatSync(receipt);
47
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('unsafe enrollment receipt');
48
+ // An unparsable receipt is a torn write from a killed hook: it means "not ready", never "unavailable forever".
49
+ try { enrollmentPending = JSON.parse(fs.readFileSync(receipt, 'utf8')).state === 'pending'; } catch { enrollmentPending = true; }
50
+ }
51
+ return { ...resolution, state: hasDb && !enrollmentPending ? 'existing' : 'pending', brainHome, contentPathExcludes: consent.contentPathExcludes || [] };
52
+ }
53
+
54
+ /** Create-if-absent. A hook killed mid-write leaves a torn FINAL file, which every reader tolerates (an unparsable
55
+ * receipt means pending; an unparsable queue entry is preserved as .corrupt). No hard-link or rename dance: a link
56
+ * window leaves nlink 2 on a killed hook, which the readers' nlink check would then reject forever. */
57
+ function createExclusive(file, text) {
58
+ let fd;
59
+ try { fd = fs.openSync(file, 'wx', 0o600); } catch (error) { if (error.code === 'EEXIST') return; throw error; }
60
+ try { fs.writeFileSync(fd, text); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
61
+ }
62
+
63
+ function safeDirectory(directory) {
64
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
65
+ const stat = fs.lstatSync(directory);
66
+ if (!stat.isDirectory() || stat.isSymbolicLink() || canonical(directory) !== directory) throw new Error('enrollment directory is not canonical');
67
+ }
68
+
69
+ /** Only a small derived outcome is queued; never prompts, transcripts, tool input or environment. */
70
+ function queueBoundary(plan, { event, payload, host, projectDir, env = process.env }) {
71
+ if (!event || typeof payload?.session_id !== 'string' || !payload.session_id) return false;
72
+ const directory = path.join(plan.projectRoot, '.swarm', queueName);
73
+ safeDirectory(directory);
74
+ const turnCaptureOff = String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off';
75
+ const text = turnCaptureOff ? '' : String(payload.last_assistant_message || '');
76
+ const filtered = maskExcludedPaths(text, plan.contentPathExcludes, projectDir);
77
+ const last = filtered === text ? redactText(text).slice(0, 12000) : '[REDACTED:excluded-resource-outcome]';
78
+ const normalized = normalizeHostEvent(payload) || payload;
79
+ const observed = enrichStateWithObservation({ commands: [] }, { ...normalized, hook_event_name: event },
80
+ { contentPathExcludes: plan.contentPathExcludes, projectDir }).commands[0];
81
+ const tool = observed ? { tool_name: redactText(observed.tool), tool_input: {
82
+ ...(observed.command ? { command: redactText(observed.command) } : {}),
83
+ ...(observed.filePath ? { file_path: redactText(observed.filePath) } : {}) },
84
+ tool_response: JSON.parse(redactText(JSON.stringify(Object.fromEntries(Object.entries(observed)
85
+ .filter(([key]) => ['outcome', 'exitCode', 'error', 'signal', 'interrupted', 'isError', 'stdout', 'stderr', 'result'].includes(key)))))) } : {};
86
+ let strategic = {};
87
+ if (payload.projectProgression || payload.project_progression) {
88
+ const extension = redactProgression(payload.projectProgression || payload.project_progression).value;
89
+ extension.completeProjectState = privateProgressionState(extension.completeProjectState, plan.contentPathExcludes, projectDir);
90
+ strategic = { projectProgression: extension };
91
+ }
92
+ const boundary = { projectDir: canonical(projectDir), event, host,
93
+ payload: { session_id: payload.session_id, hook_event_name: event, ...tool, ...strategic, ...(last ? { last_assistant_message: last } : {}) } };
94
+ const digest = crypto.createHash('sha256').update(JSON.stringify(boundary)).digest('hex');
95
+ const file = path.join(directory, `${digest}.json`);
96
+ createExclusive(file, JSON.stringify(boundary));
97
+ const dirFd = fs.openSync(directory, 'r'); try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
98
+ return true;
99
+ }
100
+
101
+ export function ensureProjectMemory({ projectDir, env = process.env, event, payload, host = env.RUVNET_HOOK_HOST || 'claude', deadlineAt = Infinity,
102
+ launch = launchEnrollment, planOptions = {} } = {}) {
103
+ try {
104
+ const plan = enrollmentPlan({ ...planOptions, projectDir, env, deadlineAt });
105
+ if (plan.state === 'disabled') return plan;
106
+ if (plan.state === 'existing') {
107
+ const queue = path.join(plan.projectRoot, '.swarm', queueName);
108
+ if (!fs.existsSync(queue) || !fs.readdirSync(queue).some((name) => /^[a-f0-9]{64}\.json$/.test(name))) return plan;
109
+ }
110
+ safeDirectory(path.join(plan.projectRoot, '.swarm'));
111
+ if (plan.state === 'pending') {
112
+ const receipt = path.join(plan.projectRoot, '.swarm', receiptName);
113
+ createExclusive(receipt, JSON.stringify({ schemaVersion: 1, state: 'pending', canonicalAgentDbPath: plan.canonicalAgentDbPath }));
114
+ }
115
+ const queued = plan.state === 'pending' && queueBoundary(plan, { event, payload, host, projectDir, env });
116
+ const launched = launch({ projectDir, env });
117
+ return { ...plan, state: plan.state === 'existing' ? 'existing' : 'pending', queued, launched };
118
+ } catch (error) { return { state: 'unavailable', reason: error.message }; }
119
+ }
120
+
121
+ export function launchEnrollment({ projectDir, env }) {
122
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--enroll', canonical(projectDir)], {
123
+ cwd: os.tmpdir(), env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined }, detached: true, windowsHide: true, stdio: 'ignore',
124
+ });
125
+ child.unref();
126
+ return Boolean(child.pid);
127
+ }
128
+
129
+ /** Exact native append and same-path readback; existing stores are never initialized or replaced. */
130
+ export async function enrollProjectMemory({ projectDir, env = process.env, run = spawnSync, replay } = {}) {
131
+ const plan = enrollmentPlan({ projectDir, env });
132
+ if (plan.state === 'disabled') return plan;
133
+ const swarm = path.join(plan.projectRoot, '.swarm');
134
+ safeDirectory(swarm);
135
+ const lock = path.join(swarm, '.memory-enrollment.lock');
136
+ const token = `${process.pid} ${crypto.randomUUID()}`;
137
+ try { fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 }); } catch (error) {
138
+ if (error.code !== 'EEXIST') throw error;
139
+ const stat = fs.lstatSync(lock);
140
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return { ...plan, state: 'pending', reason: 'unsafe enrollment lock' };
141
+ const previous = fs.readFileSync(lock, 'utf8');
142
+ const pid = Number(previous.split(' ')[0]);
143
+ let dead = false;
144
+ if (Number.isSafeInteger(pid) && pid > 0) try { process.kill(pid, 0); } catch (failure) { dead = failure.code === 'ESRCH'; }
145
+ if (!dead || fs.readFileSync(lock, 'utf8') !== previous) return { ...plan, state: 'pending', reason: 'enrollment worker busy' };
146
+ fs.unlinkSync(lock);
147
+ try { fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 }); } catch { return { ...plan, state: 'pending', reason: 'enrollment worker busy' }; }
148
+ }
149
+ const workerDeadlineAt = Date.now() + 60000;
150
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rnb-enrollment-worker-'));
151
+ try {
152
+ const binary = resolveRuflo({ env, home: env.HOME || os.homedir() });
153
+ if (!binary) return { ...plan, state: 'pending', reason: 'global Ruflo unavailable' };
154
+ const key = `project-bootstrap-${plan.projectIdentity.id}`;
155
+ const value = JSON.stringify({ schemaVersion: 1, projectIdentity: plan.projectIdentity });
156
+ const invoke = (args) => { const call = rufloInvocation(binary, args); return run(call.executable, call.args,
157
+ { cwd: scratch, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined }, encoding: 'utf8', timeout: Math.max(1, Math.min(30000, workerDeadlineAt - Date.now())), maxBuffer: 256 * 1024, shell: false }); };
158
+ if (plan.state !== 'existing') {
159
+ invoke(['memory', 'store', '--key', key, '--value', value, '--namespace', ENROLLMENT_NAMESPACE,
160
+ '--path', plan.canonicalAgentDbPath, '--require-native', '--append-only', '--no-upsert', '--no-embedding']);
161
+ // Concurrent workers may lose the strict insertion race; exact readback still establishes readiness.
162
+ const result = invoke(['memory', 'retrieve', '--key', key, '--namespace', ENROLLMENT_NAMESPACE,
163
+ '--path', plan.canonicalAgentDbPath, '--value-only']);
164
+ if (result.status !== 0 || String(result.stdout || '').trim() !== value) return { ...plan, state: 'pending', reason: 'native bootstrap readback unavailable' };
165
+ const receipt = path.join(swarm, receiptName);
166
+ const temporary = `${receipt}.${crypto.randomUUID()}.tmp`;
167
+ fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, state: 'ready', key,
168
+ canonicalAgentDbPath: plan.canonicalAgentDbPath, verifiedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
169
+ fs.renameSync(temporary, receipt);
170
+ }
171
+ const queue = path.join(swarm, queueName);
172
+ if (fs.existsSync(queue)) {
173
+ safeDirectory(queue);
174
+ const capture = replay || (await import('./session-snapshot-hook.mjs')).runSessionSnapshotHook;
175
+ for (const name of fs.readdirSync(queue).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).slice(0, 32)) {
176
+ if (Date.now() > workerDeadlineAt - 8000) break;
177
+ const file = path.join(queue, name);
178
+ const stat = fs.lstatSync(file);
179
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 256000) continue;
180
+ let boundary;
181
+ try { boundary = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {
182
+ // Preserve the evidence, stop it blocking every later replay.
183
+ try { fs.renameSync(file, `${file}.corrupt`); } catch { /* retry next boundary */ }
184
+ continue;
185
+ }
186
+ // Original path privacy is rechecked, never replaced by the primary worktree identity.
187
+ const consent = enrollmentPlan({ projectDir: boundary.projectDir, env });
188
+ if (consent.state === 'disabled' || consent.canonicalAgentDbPath !== plan.canonicalAgentDbPath) continue;
189
+ const result = capture(boundary.projectDir, boundary.event, { rawInput: JSON.stringify(boundary.payload), host: boundary.host,
190
+ env, enrollMemory: () => ({ state: 'existing' }) });
191
+ if (result?.turn?.queued || result?.turn?.recorded || result?.continuity?.recorded > 0 || result?.progressionCaptured || result?.deferredToReplayer) fs.unlinkSync(file);
192
+ }
193
+ }
194
+ return { ...plan, state: 'ready' };
195
+ } finally {
196
+ fs.rmSync(scratch, { recursive: true, force: true });
197
+ try { if (fs.readFileSync(lock, 'utf8') === token) fs.unlinkSync(lock); } catch { /* ownership changed */ }
198
+ }
199
+ }
200
+
201
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--enroll') {
202
+ enrollProjectMemory({ projectDir: process.argv[3] }).catch(() => { /* pending queue survives; retry at the next boundary */ });
203
+ }
@@ -43,8 +43,8 @@
43
43
  import fs from 'node:fs';
44
44
  import { loadNodeSqlite } from './node-sqlite.mjs';
45
45
 
46
- /** Rows that `ruflo memory` itself considers live (memory-initializer.js ACTIVE_MEMORY_ROW_SQL). */
47
- const ACTIVE_ROW_SQL = "(status = 'active' OR status IS NULL)";
46
+ /** Installed Ruflo live-memory-row.js visibility; bind current time on every read. */
47
+ const ACTIVE_ROW_SQL = "(status = 'active' OR status IS NULL) AND (expires_at IS NULL OR expires_at > ?)";
48
48
 
49
49
  /**
50
50
  * Every SQLite database file begins with this 16-byte header (sqlite.org/fileformat.html §1.3):
@@ -224,7 +224,7 @@ export function openProgressionReader(dbPath, { deadlineAt = Infinity, signal }
224
224
  }
225
225
 
226
226
  const query = (statement, params) => {
227
- try { return statement.all(...params); } catch (error) {
227
+ try { return statement.all(Date.now(), ...params); } catch (error) {
228
228
  throw new ProgressionReaderUnavailable(`read failed: ${error.message}`);
229
229
  }
230
230
  };
@@ -236,7 +236,7 @@ export function openProgressionReader(dbPath, { deadlineAt = Infinity, signal }
236
236
  if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new TypeError('maxEntries must be a positive safe integer');
237
237
  let rows = [];
238
238
  if (Number.isFinite(deadlineAt) || signal) {
239
- for (const row of listStatement.iterate(namespace)) {
239
+ for (const row of listStatement.iterate(Date.now(), namespace)) {
240
240
  checkDeadline(); rows.push(row);
241
241
  if (rows.length > maxEntries) throw new Error('progression enumeration exceeds its bound');
242
242
  }
@@ -124,29 +124,6 @@ function resultText(result, field) {
124
124
  return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
125
125
  }
126
126
 
127
- function initializeCanonicalStore(store, resolution) {
128
- const key = 'project-continuity-bootstrap-v1';
129
- const namespace = 'project-progression-control';
130
- const value = JSON.stringify({
131
- schema: 'ruvnet-brain.project-continuity-bootstrap',
132
- schemaVersion: 1,
133
- projectIdentity: resolution.projectIdentity,
134
- });
135
- store.run([
136
- 'memory', 'store', '--key', key, '--value', value,
137
- '--namespace', namespace, '--no-upsert', '--provenance', 'system_observation',
138
- '--path', resolution.canonicalAgentDbPath,
139
- ]);
140
- const readback = store.run([
141
- 'memory', 'retrieve', '--key', key, '--namespace', namespace,
142
- '--value-only', '--path', resolution.canonicalAgentDbPath,
143
- ]);
144
- if (resultStatus(readback) !== 0 || resultText(readback, 'stdout') !== value
145
- || !fs.existsSync(resolution.canonicalAgentDbPath)) {
146
- throw new Error('canonical AgentDB initialization failed');
147
- }
148
- }
149
-
150
127
  /**
151
128
  * One line, only when there is something to say. Durable-but-uncommitted snapshots are evidence the
152
129
  * session must know about: they will be committed at the next capture boundary, and until then the
@@ -267,10 +244,13 @@ export function restoreProgressionForSession({
267
244
  const failed = miss('outbox-replay');
268
245
  return { ...failed, context: `${failed.context} Replay is suspended by capture consent; no older checkpoint was injected.` };
269
246
  }
270
- if (initializing) {
271
- fs.mkdirSync(path.dirname(resolution.canonicalAgentDbPath), { recursive: true, mode: 0o700 });
272
- initializeCanonicalStore(store, resolution);
273
- }
247
+ // ADR-105 rule 6: restoring a session never creates a store. A project's store is created only by the
248
+ // consent-gated native enrollment (project-memory-enrollment.mjs), never by SessionStart and never when
249
+ // consent is absent or RUVNET_TURN_CAPTURE=off.
250
+ if (initializing) return {
251
+ status: 'unavailable', reason: 'not-enrolled', severity: 'info',
252
+ context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nNo canonical project store exists yet; it is created by consent-gated native enrollment at the first captured boundary. Nothing to restore.',
253
+ };
274
254
  if (!suspended) {
275
255
  const queue = drainCaptureQueue({ projectDir, budgetMs: Math.max(0, deadlineAt - Date.now()),
276
256
  deadlineAt, signal, env, makeStoreFactory: () => () => store });
@@ -358,9 +358,8 @@ export class ProjectProgressionStore {
358
358
  this.deadlineAt = deadlineAt;
359
359
  this.signal = signal;
360
360
  this.resolution = resolveProjectStore({ projectDir, requestedStorePath, deadlineAt });
361
- // Best effort: a cleanup that cannot run must never stop a capture or a restore.
362
- try { this.legacyDebris = cleanLegacyRufloDebris(path.dirname(this.resolution.canonicalAgentDbPath)); }
363
- catch (error) { this.legacyDebris = { removed: [], refused: [{ path: null, reason: error.message }] }; }
361
+ // Opening continuity is never authorization to remove another store or its artifacts.
362
+ this.legacyDebris = { removed: [], refused: [], skipped: 'automatic cleanup disabled; existing stores preserved' };
364
363
  this.brainHome = brainHome;
365
364
  this.rufloBinary = rufloBinary;
366
365
  this.runner = runner;
@@ -387,7 +386,7 @@ export class ProjectProgressionStore {
387
386
  cwd,
388
387
  encoding: 'utf8',
389
388
  timeout: 120_000,
390
- env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
389
+ env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined },
391
390
  });
392
391
  } finally {
393
392
  fs.rmSync(cwd, { recursive: true, force: true });
@@ -416,11 +415,24 @@ export class ProjectProgressionStore {
416
415
  this.requireCaptureConsent(snapshot);
417
416
  const stored = this.run([
418
417
  'memory', 'store', '--key', snapshot.eventKey, '--value', JSON.stringify(snapshot),
419
- '--namespace', PROGRESSION_NAMESPACE, '--no-upsert', '--provenance', 'system_observation',
418
+ '--namespace', PROGRESSION_NAMESPACE, '--no-upsert', '--require-native', '--append-only', '--provenance', 'system_observation',
420
419
  '--path', this.resolution.canonicalAgentDbPath,
421
420
  ]);
422
421
  const alreadyStored = resultStatus(stored) !== 0;
423
- if (!alreadyStored) onPhase('stored');
422
+ if (alreadyStored) {
423
+ const diagnostic = `${resultText(stored, 'stderr')}\n${resultText(stored, 'stdout')}`.replace(/\x1b\[[0-9;]*m/g, '');
424
+ const isDuplicate = line => {
425
+ const reason = line.trim().replace(/^\[ERROR\]\s*/, '').replace(/^Failed to store:\s*/, '');
426
+ return reason === 'immutable append rejected: logical key already exists'
427
+ || reason === `key "${snapshot.eventKey}" already exists in namespace "${PROGRESSION_NAMESPACE}" — pass upsert=true (--upsert on the CLI) to update it`;
428
+ };
429
+ const lines = diagnostic.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
430
+ const duplicate = lines.some(isDuplicate) && lines.filter(line => /^\[ERROR\]|^Failed to store:/i.test(line)).every(isDuplicate)
431
+ && !/\b(?:unavailable|refus(?:ed|ing|al)|permission denied|native WAL|unsafe write)\b/i.test(diagnostic);
432
+ if (resultStatus(stored) !== 1 || stored.error || stored.signal || !duplicate) {
433
+ throw new Error(`progression native append failed: ${resultText(stored, 'stderr').trim() || resultText(stored, 'stdout').trim() || 'unknown error'}`);
434
+ }
435
+ } else onPhase('stored');
424
436
 
425
437
  // THE READ-BACK, through the fast path when it is available.
426
438
  //
@@ -1,6 +1,7 @@
1
1
  import crypto from 'node:crypto';
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import os from 'node:os';
4
5
  import { execFileSync } from 'node:child_process';
5
6
 
6
7
  function sha256(value) {
@@ -47,21 +48,36 @@ function gitValue(cwd, args, gitTimeoutMs, deadlineAt) {
47
48
  return execFileSync('git', args, {
48
49
  cwd,
49
50
  encoding: 'utf8',
50
- stdio: ['ignore', 'pipe', 'ignore'],
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
51
53
  ...(Number.isFinite(remaining) || gitTimeoutMs
52
54
  ? { timeout: Math.max(1, Math.floor(Math.min(gitTimeoutMs || Infinity, remaining))), killSignal: 'SIGKILL' } : {}),
53
55
  }).trim();
54
56
  } catch (error) {
55
57
  // A timed-out Git identity check is not evidence for a non-git project.
56
58
  if (error.code === 'ETIMEDOUT') throw new Error('Git project identity timed out');
57
- return null;
59
+ if (error.code === 'ENOENT' && !(() => { for (let c = cwd; ; c = path.dirname(c)) { if (fs.existsSync(path.join(c, '.git'))) return true; if (path.dirname(c) === c) return false; } })()) return null; // no git binary and no .git marker: not a Git project
60
+ if (error.status === 128 && /^fatal: not a git repository\b/m.test(String(error.stderr || ''))) {
61
+ let cursor = cwd;
62
+ for (;;) {
63
+ if (fs.existsSync(path.join(cursor, '.git'))) throw new Error('Git project identity unavailable: invalid Git marker');
64
+ const parent = path.dirname(cursor);
65
+ if (parent === cursor) break;
66
+ cursor = parent;
67
+ }
68
+ return null;
69
+ }
70
+ throw new Error('Git project identity unavailable');
58
71
  }
59
72
  }
60
73
 
61
74
  function gitProject(projectDir, gitTimeoutMs, deadlineAt) {
62
- const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir'], gitTimeoutMs, deadlineAt);
63
- const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel'], gitTimeoutMs, deadlineAt);
64
- if (!commonValue || !checkoutValue) return null;
75
+ // One process, two lines (common dir, then checkout root): each Git spawn is a load-sensitive cost on the prompt path.
76
+ const identity = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'], gitTimeoutMs, deadlineAt);
77
+ if (identity === null) return null;
78
+ const [commonValue, checkoutValue, ...extra] = identity.split('\n');
79
+ if (extra.length) throw new Error('Git project identity incomplete');
80
+ if (!commonValue || !checkoutValue) throw new Error('Git project identity incomplete');
65
81
  const gitCommonDir = canonicalDirectory(commonValue, 'Git common directory');
66
82
  const checkoutRoot = canonicalDirectory(checkoutValue, 'Git checkout root');
67
83
  if (path.basename(gitCommonDir) !== '.git') {
@@ -74,6 +90,18 @@ function gitProject(projectDir, gitTimeoutMs, deadlineAt) {
74
90
  };
75
91
  }
76
92
 
93
+ function nonGitRoot(input) {
94
+ // A home directory that does not exist (CI sandboxes, relocated HOME) must not make project resolution throw.
95
+ const home = (() => { try { return fs.realpathSync.native(os.homedir()); } catch { return path.resolve(os.homedir()); } })();
96
+ let cursor = input;
97
+ while (cursor !== path.dirname(cursor) && cursor !== home) {
98
+ if (fs.existsSync(path.join(cursor, '.swarm', 'memory.db'))
99
+ || ['AGENTS.md', 'package.json', 'Cargo.toml', 'pyproject.toml'].some((marker) => fs.existsSync(path.join(cursor, marker)))) return cursor;
100
+ cursor = path.dirname(cursor);
101
+ }
102
+ return input;
103
+ }
104
+
77
105
  export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath, gitTimeoutMs, deadlineAt = Infinity } = {}) {
78
106
  const canonicalInput = canonicalDirectory(projectDir, 'projectDir');
79
107
  if (gitTimeoutMs !== undefined && (!Number.isSafeInteger(gitTimeoutMs) || gitTimeoutMs <= 0)) throw new TypeError('gitTimeoutMs must be a positive integer');
@@ -81,7 +109,7 @@ export function resolveProjectStore({ projectDir = process.cwd(), requestedStore
81
109
  const resolved = git ?? {
82
110
  gitCommonDir: null,
83
111
  checkoutRoot: canonicalInput,
84
- projectRoot: canonicalInput,
112
+ projectRoot: nonGitRoot(canonicalInput),
85
113
  };
86
114
  const kind = git ? 'git' : 'non-git';
87
115
  const canonicalAgentDbPath = path.join(resolved.projectRoot, '.swarm', 'memory.db');
@@ -116,3 +144,13 @@ export function resolveProjectStore({ projectDir = process.cwd(), requestedStore
116
144
  projectIdentity,
117
145
  });
118
146
  }
147
+
148
+ /** Default enrollment applies only to Git developer roots; explicit consent is evaluated by callers. */
149
+ export function globalEnrollmentAllowed({ resolution, home = os.homedir(), brainHome = path.join(home, '.cache', 'ruvnet-brain'),
150
+ temporaryRoots = [os.tmpdir(), '/tmp', '/private/tmp'],
151
+ systemRoots = ['/System', '/Library', '/usr', '/etc', '/var', '/private/var', '/proc', '/sys', '/dev'] } = {}) {
152
+ const canonical = (value) => { try { return fs.realpathSync.native(value); } catch { return path.resolve(value); } };
153
+ const root = resolution.projectRoot;
154
+ return resolution.kind === 'git' && root !== canonical(home) && root !== path.parse(root).root
155
+ && ![...temporaryRoots, ...systemRoots, brainHome].some((excluded) => isWithin(canonical(excluded), root));
156
+ }
@@ -15,6 +15,7 @@ import { projectDirectory } from './project-identity.mjs';
15
15
  import { buildProjectProgression } from './project-progression-producer.mjs';
16
16
  import { ProjectProgressionStore } from './project-progression-store.mjs';
17
17
  import { resolveProjectStore } from './project-store-resolver.mjs';
18
+ import { ensureProjectMemory } from './project-memory-enrollment.mjs';
18
19
  import { resolveTurnDb, captureTurnOutcome } from './turn-outcome-capture.mjs';
19
20
  import { captureContinuityEvents, stopNotice } from './continuity-journal.mjs';
20
21
  import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
@@ -129,6 +130,7 @@ export function runSessionSnapshotHook(projectDir, event, {
129
130
  spawnReplay = replayOutboxDetached,
130
131
  ordered = null,
131
132
  captureEvents = captureContinuityEvents,
133
+ enrollMemory = ensureProjectMemory,
132
134
  deadlineAt: inheritedDeadlineAt = Infinity,
133
135
  signal,
134
136
  } = {}) {
@@ -136,6 +138,11 @@ export function runSessionSnapshotHook(projectDir, event, {
136
138
  const checkDeadline = () => { if (signal?.aborted || now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
137
139
  const suspended = (reason) => ({ metadataWritten: false, progressionCaptured: false, receipt: null, skipped: reason,
138
140
  turn: { event, recorded: false, skipped: reason }, continuity: { event, recorded: 0, launched: false, skipped: reason } });
141
+ let enrollmentPayload = {};
142
+ try { enrollmentPayload = rawInput ? JSON.parse(rawInput) : {}; } catch { /* malformed payload */ }
143
+ const enrollment = enrollMemory({ projectDir, env, event, payload: enrollmentPayload, host, deadlineAt });
144
+ if (enrollment.state === 'pending') return { ...suspended('native project memory enrollment pending'), enrollment };
145
+ if (enrollment.state === 'disabled' || enrollment.state === 'unavailable') return { ...suspended(enrollment.reason), enrollment };
139
146
  // Consent is checked before metadata, transcript inspection or any durable capture queue.
140
147
  try {
141
148
  checkDeadline();
@@ -26,6 +26,7 @@ import fs from 'node:fs';
26
26
  import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
+ import { enrollmentPlan } from './project-memory-enrollment.mjs';
29
30
  import { restoreWithBrief } from './continuity-brief.mjs';
30
31
  import { turnRecordingStatus } from './turn-outcome-capture.mjs';
31
32
  import {
@@ -189,6 +190,11 @@ export async function runSessionStart({
189
190
  }
190
191
  };
191
192
 
193
+ // Report only. SessionStart never creates or bootstraps a store (ADR-105 rule 6); enrollment starts at the first
194
+ // consented captured boundary, so a session that merely started leaves no store behind.
195
+ let enrollment;
196
+ try { enrollment = enrollmentPlan({ projectDir: cwd, env, deadlineAt: hookDeadlineAt }); } catch (error) { enrollment = { state: 'unavailable', reason: error.message }; }
197
+ if (enrollment.state === 'pending' || enrollment.state === 'unavailable') emit(`[RuvNet Brain — TURN CAPTURE] Project memory enrollment ${enrollment.state}; restoration is not yet proven.`);
192
198
  const restoreStart = Date.now();
193
199
  const restoreDeadlineAt = Math.min(hookDeadlineAt, restoreStart + STAGE_BUDGETS_MS.restore);
194
200
  const restoreController = new AbortController();