ruvnet-brain 4.5.3 → 4.5.4

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.
@@ -31,9 +31,8 @@
31
31
  *
32
32
  * LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
33
33
  * (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
34
- * distill in order; the hook itself only reads, fingerprints and spawns. A breadcrumb line is
35
- * containing only key, hash and length is appended BEFORE the spawn; exact readback receipts make a lost
36
- * write is visible rather than silent. Advisory always: nothing here throws to the caller.
34
+ * distill in order; the hook itself only reads, fingerprints and spawns. A project-local journal containing redacted content and canonical binding is fsynced BEFORE
35
+ * spawning; fresh sessions replay stable keys without upsert and require exact readback receipts. Advisory always: nothing here throws to the caller.
37
36
  */
38
37
  import fs from 'node:fs';
39
38
  import os from 'node:os';
@@ -44,6 +43,7 @@ import { fileURLToPath } from 'node:url';
44
43
  import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
45
44
  import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
46
45
  import { resolveProjectStore } from './project-store-resolver.mjs';
46
+ import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
47
47
 
48
48
  export const TURN_NAMESPACE = 'turns';
49
49
  export const MIN_OUTCOME_CHARS = 200;
@@ -154,8 +154,9 @@ const consentMap = (value) => value !== null && typeof value === 'object'
154
154
  && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
155
155
  && Object.values(value).every((setting) => setting === 'on' || setting === 'off');
156
156
 
157
- export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000 } = {}) {
157
+ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false } = {}) {
158
158
  const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs });
159
+ const capturePath = fs.realpathSync.native(projectDir);
159
160
  let policy = {};
160
161
  const file = brainHome && turnCapturePolicyFile(brainHome);
161
162
  if (file && fs.existsSync(file)) {
@@ -166,15 +167,19 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
166
167
  } catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
167
168
  }
168
169
  // A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
169
- const setting = policy.paths?.[fs.realpathSync.native(projectDir)] ?? policy.projects?.[resolved.projectRoot];
170
+ const setting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
170
171
  if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
171
172
  const db = resolved.canonicalAgentDbPath;
173
+ if (unknownOriginalPath && Object.entries(policy.paths || {}).some(([origin, choice]) => choice === 'off'
174
+ && (origin === resolved.checkoutRoot || origin.startsWith(`${resolved.checkoutRoot}${path.sep}`)))) {
175
+ return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'capture origin cannot be verified under path opt-out' };
176
+ }
172
177
  assertTurnStoreFiles(db);
173
- if (setting === 'off') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
178
+ if (setting === 'off') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
174
179
  let exists = false;
175
180
  try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
176
- if (!exists && setting !== 'on') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
177
- return { db, scope: 'project', projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
181
+ if (!exists && setting !== 'on') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
182
+ return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
178
183
  }
179
184
 
180
185
  // Revalidation narrows the queue-to-launch window; it does not make SQLite's later open atomic.
@@ -197,9 +202,9 @@ function assertTurnStoreFiles(db) {
197
202
  const projectName = (projectDir) => redactText(path.basename(path.resolve(projectDir))).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
198
203
 
199
204
  /** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
200
- export function launchDetached(steps, { receipts }) {
201
- const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps, receipts })], {
202
- cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...process.env, ...RUFLO_ENV },
205
+ export function launchDetached(steps, { receipts, brainHome, projectDir, env = process.env }) {
206
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps: steps.map((step) => step.kind === 'store' && step.journalFile ? { kind: 'journal', journalFile: step.journalFile, db: step.args[step.args.indexOf('--path') + 1] } : step), receipts })], {
207
+ cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, ...RUFLO_ENV, ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(projectDir ? { RUVNET_TURN_PROJECT_DIR: projectDir } : {}) },
203
208
  });
204
209
  child.unref();
205
210
  return { launched: true, pid: child.pid };
@@ -257,7 +262,8 @@ export function captureTurnOutcome({
257
262
  Object.assign(report, { db, scope });
258
263
  if (target.skipped) return { ...report, skipped: target.skipped, distill: { queued: false, skipped: target.skipped } };
259
264
  const steps = [];
260
- const binding = { projectRoot, projectDir: fs.realpathSync.native(projectDir), brainHome };
265
+ const rootStat = fs.statSync(projectRoot);
266
+ const binding = { projectRoot, projectDir: fs.realpathSync.native(projectDir), brainHome, rootIdentity: `${rootStat.dev}:${rootStat.ino}` };
261
267
  let dedupe;
262
268
  const project = projectName(projectRoot);
263
269
  const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
@@ -292,11 +298,17 @@ export function captureTurnOutcome({
292
298
  if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
293
299
  else {
294
300
  const at = now();
295
- const key = `turn-${project}-${at.getTime()}-${crypto.randomBytes(6).toString('hex')}`;
301
+ const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
296
302
  dedupe = { stateFile, identity, fingerprint, key };
297
- const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
303
+ let value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
304
+ // Retry uses the original durable content (including its timestamp), never a new value.
305
+ const pendingFile = path.join(path.dirname(db), 'turn-outbox', `${digest(key)}.json`);
306
+ if (fs.existsSync(pendingFile)) {
307
+ try { const pending = readJournal(pendingFile, db); value = pending.value; }
308
+ catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
309
+ }
298
310
  steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
299
- '--path', db, '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
311
+ '--path', db, '--no-upsert', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
300
312
  // This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
301
313
  Object.assign(report, { queued: true, key, value });
302
314
  }
@@ -316,10 +328,12 @@ export function captureTurnOutcome({
316
328
  // Only explicit persisted consent permits creating a project store directory.
317
329
  if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
318
330
  if (report.queued) {
331
+ const step = steps.find((item) => item.kind === 'store');
332
+ step.journalFile = journalTurn(step, db, report.key, { onDurability: (evidence) => { report.durability = evidence; } });
319
333
  fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
320
334
  `${JSON.stringify({ ts: Date.now(), key: report.key, hash: crypto.createHash('sha256').update(report.value).digest('hex'), len: report.value.length })}\n`, { mode: 0o600 });
321
335
  }
322
- report.launch = launch(steps, { receipts });
336
+ report.launch = launch(steps, { receipts, brainHome, projectDir: binding.projectDir, env });
323
337
  if (dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
324
338
  } catch (error) {
325
339
  return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: `launch failed: ${redactText(error.message)}` }, skipped: `launch failed: ${redactText(error.message)}` };
@@ -335,48 +349,106 @@ function readBack({ ruflo, db, key, run, options }) {
335
349
  }
336
350
 
337
351
  /** The detached worker: bounded steps, safe error evidence, exact content readback. */
338
- export function runSteps({ steps = [], receipts } = {}, { run = spawnSync, read = readBack, env = process.env } = {}) {
352
+ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spawnSync, read = readBack,
353
+ env = process.env, deadlineMs, home = os.homedir(),
354
+ projectDir = env.RUVNET_TURN_PROJECT_DIR || process.cwd(),
355
+ brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') } = {}) {
356
+ const run = (binary, args, options) => {
357
+ if (deadlineMs === undefined) return suppliedRun(binary, args, options);
358
+ const remaining = deadlineMs - Date.now();
359
+ if (remaining <= 0) return { status: 1, error: new Error('turn replay deadline exhausted') };
360
+ return suppliedRun(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
361
+ };
339
362
  const results = [];
340
- for (const step of steps) {
341
- let status = null; let error = null; let verified = false;
342
- const args = [...step.args];
343
- const db = args[args.indexOf(step.kind === 'store' ? '--path' : '--db') + 1];
344
- const key = step.kind === 'store' ? args[args.indexOf('-k') + 1] : undefined;
345
- const valueIndex = args.indexOf('--value') + 1;
346
- if (step.kind === 'store') args[valueIndex] = redactText(args[valueIndex]);
363
+ for (const queued of steps) {
364
+ let status = 1; let error = null; let verified = false; let db = ''; let key;
365
+ const kind = queued.kind === 'journal' ? 'store' : queued.kind;
347
366
  try {
348
- if (!step.projectRoot || !step.projectDir || !step.brainHome) throw new Error('queued step has no canonical project binding');
349
- const target = resolveTurnDb({ projectDir: step.projectDir, brainHome: step.brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
350
- if (target.skipped) throw new Error(target.skipped);
351
- if (target.projectRoot !== step.projectRoot || target.db !== db) throw new Error('queued canonical project/store identity changed');
352
367
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
353
- const invocation = rufloInvocation(step.ruflo, args);
354
- // Contain Ruflo's auxiliary writes in the canonical store's own directory.
355
- const home = path.dirname(db);
356
- const options = { encoding: 'utf8', timeout: STEP_TIMEOUT_MS, cwd: home, windowsHide: true,
357
- maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: home } };
358
- const r = run(invocation.executable, invocation.args, options);
368
+ if (kind === 'store' && queued.kind !== 'journal') { const data = storeData(queued); key = data.key; db = data.db; }
369
+ if (queued.kind !== 'journal' && (!queued.projectRoot || !queued.projectDir || !queued.brainHome)) throw new Error('queued step has no canonical project binding');
370
+ const target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000 });
371
+ if (target.skipped) throw new Error(target.skipped);
372
+ db = target.db;
373
+ let args; let value; let binding;
374
+ if (kind === 'store') {
375
+ const data = queued.kind === 'journal' ? readJournal(queued.journalFile, db) : storeData(queued);
376
+ if (queued.kind === 'journal' && queued.db !== db) throw new Error('foreign turn queue store rejected');
377
+ if (queued.kind !== 'journal' && data.db !== db) throw new Error('foreign turn store rejected');
378
+ if (data.legacyBrainHome && data.legacyBrainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
379
+ if (queued.kind !== 'journal' && queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
380
+ binding = queued.kind === 'journal' ? data.binding : queued;
381
+ key = data.key; value = redactText(data.value);
382
+ args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--provenance', 'agent_output'];
383
+ } else if (kind === 'distill') {
384
+ if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
385
+ const recipe = queued.args;
386
+ if (!Array.isArray(recipe) || recipe.slice(0, 3).join(' ') !== 'memory distill run') throw new Error('invalid turn distill operation');
387
+ const seen = new Set();
388
+ for (let i = 3; i < recipe.length; i += 2) {
389
+ const flag = recipe[i]; const value = recipe[i + 1];
390
+ if (seen.has(flag) || !['--db', '--namespace', '--max-entries'].includes(flag) || typeof value !== 'string') throw new Error('invalid turn distill flags');
391
+ seen.add(flag);
392
+ if (flag === '--db' && value !== db || flag === '--namespace' && value !== TURN_NAMESPACE || flag === '--max-entries' && value !== '500') throw new Error('invalid turn distill scope');
393
+ }
394
+ if (!seen.has('--db')) throw new Error('missing turn distill database');
395
+ binding = queued;
396
+ args = ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'];
397
+ } else throw new Error('invalid turn worker operation');
398
+ if (!binding.projectRoot || !binding.projectDir) throw new Error('queued step has no canonical project binding');
399
+ const rootStat = fs.statSync(target.projectRoot);
400
+ if (binding.rootIdentity && binding.rootIdentity !== `${rootStat.dev}:${rootStat.ino}`) throw new Error('queued canonical project identity changed');
401
+ if (binding.projectRoot !== target.projectRoot) throw new Error('queued canonical project/store identity changed');
402
+ const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
403
+ if (origin.skipped) throw new Error(origin.skipped);
404
+ if (origin.projectRoot !== target.projectRoot || origin.db !== db) throw new Error('queued canonical capture origin changed');
405
+ const ruflo = resolveRuflo({ env, home });
406
+ if (!ruflo) throw new Error('ruflo not found for durable replay');
407
+ const invocation = rufloInvocation(ruflo, args);
408
+ const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
409
+ if (remaining <= 0) throw new Error('turn replay deadline exhausted');
410
+ const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), cwd: path.dirname(db), windowsHide: true,
411
+ maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: path.dirname(db) } };
412
+ const existing = kind === 'store' && queued.journalFile ? read({ ruflo, db, key, run, options }) : null;
413
+ if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
414
+ verified = kind === 'store' && existing === value;
415
+ const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
359
416
  status = Number.isInteger(r.status) ? r.status : 1;
360
- if (r.error || status !== 0) error = redactText(r.error?.message || String(r.stderr || '').trim().split(/\r?\n/)[0] || `ruflo exited ${status}`).slice(0, 300);
361
- else if (step.kind === 'store') {
362
- verified = read({ ruflo: step.ruflo, db, key, run, options }) === args[valueIndex];
363
- if (!verified) { status = 1; error = 'exact turn key/content readback failed'; }
364
- }
417
+ if (kind === 'store' && !verified) verified = read({ ruflo, db, key, run, options }) === value;
418
+ if (verified) status = 0;
419
+ if (!verified && (r.error || status !== 0)) error = redactText(r.error?.message || String(r.stderr || '').trim().split(/\r?\n/)[0] || `ruflo exited ${status}`).slice(0, 300);
420
+ else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
365
421
  } catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
366
- const row = { at: new Date().toISOString(), kind: step.kind, db: redactText(db),
367
- storeIdentity: crypto.createHash('sha256').update(db).digest('hex'), key, status, error,
368
- ...(step.kind === 'store' ? { verified } : {}) };
422
+ const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
423
+ ...(kind === 'store' ? { verified } : {}) };
369
424
  results.push(row);
370
425
  if (receipts) {
371
- try {
372
- fs.mkdirSync(path.dirname(receipts), { recursive: true, mode: 0o700 });
373
- fs.appendFileSync(receipts, `${JSON.stringify(row)}\n`, { mode: 0o600 });
374
- } catch { /* receipts are best effort */ }
426
+ try { appendReceipt(receipts, row); if (verified && queued.journalFile) acknowledgeJournal(queued.journalFile, db); }
427
+ catch { /* failed receipt persistence retains the journal for exact-key retry */ }
375
428
  }
376
429
  }
377
430
  return results;
378
431
  }
379
432
 
433
+ /** Replay only bounded canonical project entries; consent suspends rather than deletes them. */
434
+ export function replayTurnQueue({ projectDir = process.cwd(), env = process.env, home = os.homedir(),
435
+ brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'), limit = 10,
436
+ launch = launchDetached, synchronous = false, runner, deadlineMs } = {}) {
437
+ if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
438
+ try {
439
+ const target = resolveTurnDb({ projectDir, brainHome });
440
+ if (target.skipped) return { queued: false, skipped: target.skipped };
441
+ const steps = pendingTurnFiles(target.db, limit).map((journalFile) => ({ kind: 'journal', journalFile, db: target.db }));
442
+ if (!steps.length) return { queued: false, count: 0 };
443
+ if (synchronous) {
444
+ const results = runSteps({ steps, receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl') }, { env, home, projectDir, brainHome, ...(runner ? { run: runner } : {}), deadlineMs });
445
+ const pending = pendingTurnFiles(target.db, 25).length;
446
+ return { queued: false, count: steps.length, pending, failed: results.filter((row) => row.status !== 0).length, verified: results.filter((row) => row.verified === true).length, results };
447
+ }
448
+ return { queued: true, count: steps.length, pending: steps.length, launch: launch(steps, { receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl'), projectDir, brainHome, env }) };
449
+ } catch (error) { return { queued: false, skipped: redactText(error.message) }; }
450
+ }
451
+
380
452
  /** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
381
453
  export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now() } = {}) {
382
454
  const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
@@ -389,8 +461,14 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
389
461
  try { return [JSON.parse(line)]; } catch { return []; }
390
462
  }).filter((r) => r.kind === 'store' && (r.storeIdentity === crypto.createHash('sha256').update(target.db).digest('hex') || r.db === target.db) && now - Date.parse(r.at) < 7 * 86_400_000).slice(-20);
391
463
  } catch { /* first run */ }
392
- const failed = rows.filter((r) => r.status !== 0 || r.verified !== true);
464
+ rows = [...new Map(rows.map((row) => [row.key || row.at, row])).values()];
465
+ let pending = 0;
466
+ try { pending = pendingTurnFiles(target.db, 25).length; } catch { return { state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }; }
467
+ const historical = rows.filter((r) => r.status === 0 && r.verified === undefined);
468
+ const failed = rows.filter((r) => r.status !== 0 || r.verified === false);
393
469
  if (failed.length) return { state: 'warn', line: `turn recording failing ${failed.length}/${rows.length} — ${redactText(failed.at(-1).error || 'write has no exact readback evidence').slice(0, 300)}` };
470
+ if (historical.length) return { state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` };
471
+ if (pending) return { state: 'unknown', line: `turn recording pending durable replay (${pending} entries); not yet proven` };
394
472
  return rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
395
473
  : { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' };
396
474
  }
@@ -0,0 +1,106 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+
5
+ export const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
6
+ export const turnQueueDirectory = (db) => path.join(path.dirname(db), 'turn-outbox');
7
+ function regular(file, directory = false) {
8
+ const stat = fs.lstatSync(file);
9
+ if (stat.isSymbolicLink() || (!directory && (!stat.isFile() || stat.nlink !== 1))
10
+ || (directory && (!stat.isDirectory() || fs.realpathSync.native(file) !== file))) throw new Error('unsafe turn journal path');
11
+ }
12
+ // Node's Windows directory handles do not consistently support fsync. Never weaken
13
+ // file fsync or hide unexpected I/O errors; a missing namespace flush is reported.
14
+ export function syncTurnDirectory(dir, { platform = process.platform, io = fs } = {}) {
15
+ let fd;
16
+ try {
17
+ fd = io.openSync(dir, 'r'); io.fsyncSync(fd);
18
+ return { platform, directoryFsync: 'completed' };
19
+ } catch (error) {
20
+ if (platform !== 'win32' || !['EISDIR', 'EPERM', 'EACCES', 'EINVAL', 'ENOTSUP', 'EBADF'].includes(error.code)) throw error;
21
+ return { platform, directoryFsync: 'unavailable', reason: error.code,
22
+ limitation: 'file fsync remains required; directory-entry persistence across power loss is unproven' };
23
+ } finally { if (fd !== undefined) io.closeSync(fd); }
24
+ }
25
+ // Legacy recipes are data extraction inputs only, never executable commands.
26
+ export function storeData(step) {
27
+ if (step?.kind !== 'store' || !Array.isArray(step.args) || step.args[0] !== 'memory' || step.args[1] !== 'store') throw new Error('invalid turn store operation');
28
+ const fields = {}; const aliases = { '-k': 'key', '--key': 'key', '--value': 'value', '-n': 'namespace', '--namespace': 'namespace', '--path': 'db', '--tags': 'tags', '--provenance': 'provenance' };
29
+ let strict = false;
30
+ for (let i = 2; i < step.args.length; i++) {
31
+ const flag = step.args[i];
32
+ if (flag === '--no-upsert') { if (strict) throw new Error('duplicate turn store flag'); strict = true; continue; }
33
+ const field = aliases[flag];
34
+ if (!field || Object.hasOwn(fields, field) || typeof step.args[i + 1] !== 'string') throw new Error('unknown or duplicate turn store flag');
35
+ fields[field] = step.args[++i];
36
+ }
37
+ if (!fields.key || typeof fields.value !== 'string' || fields.namespace !== 'turns' || !path.isAbsolute(fields.db || '')) throw new Error('invalid turn store namespace or data');
38
+ return { key: fields.key, value: fields.value, db: fields.db };
39
+ }
40
+ const bindingOf = (step) => ({ projectRoot: step.projectRoot, projectDir: step.projectDir, rootIdentity: step.rootIdentity });
41
+ function validateBinding(binding) {
42
+ if (!binding || Object.keys(binding).some((key) => !['projectRoot', 'projectDir', 'rootIdentity'].includes(key))
43
+ || !path.isAbsolute(binding.projectRoot || '') || !path.isAbsolute(binding.projectDir || '')
44
+ || typeof binding.rootIdentity !== 'string' || !/^\d+:\d+$/.test(binding.rootIdentity)) throw new Error('invalid turn canonical binding');
45
+ }
46
+ export function journalTurn(step, db, key, { platform = process.platform, io = fs, onDurability } = {}) {
47
+ const dir = turnQueueDirectory(db);
48
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); regular(dir, true);
49
+ const file = path.join(dir, `${digest(key)}.json`);
50
+ const data = storeData(step);
51
+ if (data.db !== db || data.key !== key) throw new Error('turn journal identity mismatch');
52
+ const binding = bindingOf(step); validateBinding(binding);
53
+ const record = { schemaVersion: 2, key, value: data.value, contentDigest: digest(data.value), consentScope: 'canonical-project-and-path', binding };
54
+ let created = false;
55
+ try {
56
+ const fd = io.openSync(file, 'wx', 0o600);
57
+ created = true;
58
+ try { io.writeFileSync(fd, JSON.stringify(record)); io.fsyncSync(fd); } finally { io.closeSync(fd); }
59
+ } catch (error) {
60
+ if (error.code !== 'EEXIST') throw error;
61
+ const previous = readJournal(file, db);
62
+ if (previous.key !== key || previous.contentDigest !== record.contentDigest) throw new Error('turn journal identity collision');
63
+ // Reflush an existing complete entry: its creator may have died before fsync.
64
+ const fd = io.openSync(file, 'r+');
65
+ try { io.fsyncSync(fd); } finally { io.closeSync(fd); }
66
+ }
67
+ const evidence = { fileFsync: 'completed', entry: created ? 'created-exclusively' : 'existing-exact-match-reflushed', ...syncTurnDirectory(dir, { platform, io }) };
68
+ if (onDurability) onDurability(evidence);
69
+ return file;
70
+ }
71
+ export function readJournal(file, db) {
72
+ const dir = turnQueueDirectory(db); regular(dir, true);
73
+ if (path.dirname(file) !== dir) throw new Error('foreign turn journal rejected');
74
+ regular(file);
75
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
76
+ let normalized = record;
77
+ if (record.schemaVersion === 1) {
78
+ const data = storeData(record.step);
79
+ if (!record.step.args.includes('--no-upsert')) throw new Error('legacy turn journal requires strict insert');
80
+ if (data.db !== db || data.key !== record.key) throw new Error('legacy turn journal identity mismatch');
81
+ // Legacy job-selected consent settings are never consumed; caller policy governs replay.
82
+ normalized = { schemaVersion: 2, key: data.key, value: data.value, contentDigest: record.contentDigest,
83
+ consentScope: record.consentScope, binding: bindingOf(record.step), legacyBrainHome: record.step.brainHome };
84
+ } else if (Object.keys(record).some((key) => !['schemaVersion', 'key', 'value', 'contentDigest', 'consentScope', 'binding'].includes(key))) throw new Error('unknown turn journal field');
85
+ validateBinding(normalized.binding);
86
+ if (normalized.schemaVersion !== 2 || normalized.consentScope !== 'canonical-project-and-path'
87
+ || typeof normalized.key !== 'string' || !normalized.key.startsWith('turn-') || normalized.key.length > 250
88
+ || typeof normalized.value !== 'string' || normalized.value.length > 4000
89
+ || digest(normalized.value) !== normalized.contentDigest
90
+ || path.basename(file) !== `${digest(normalized.key)}.json`) throw new Error('invalid turn journal');
91
+ return normalized;
92
+ }
93
+ export function pendingTurnFiles(db, limit = 10) {
94
+ const dir = turnQueueDirectory(db);
95
+ try { regular(dir, true); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
96
+ return fs.readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, Math.min(25, Math.max(0, limit))).map((name) => path.join(dir, name));
97
+ }
98
+ export function acknowledgeJournal(file, db, options = {}) {
99
+ readJournal(file, db); fs.unlinkSync(file); return syncTurnDirectory(path.dirname(file), options);
100
+ }
101
+ export function appendReceipt(file, row) {
102
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
103
+ if (fs.existsSync(file)) regular(file);
104
+ const fd = fs.openSync(file, 'a', 0o600);
105
+ try { fs.writeFileSync(fd, `${JSON.stringify(row)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
106
+ }
@@ -68,6 +68,7 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
68
68
  "reason": "Canonical project isolation, persisted consent and truthful capture status",
69
69
  "files": [
70
70
  "tests/unit/turn-outcome-capture.test.mjs",
71
+ "tests/unit/turn-journal-platform.test.mjs",
71
72
  "tests/unit/project-store-resolver.test.mjs"
72
73
  ]
73
74
  },
@@ -160,6 +161,18 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
160
161
  "tests/unit/managed-memory-boundary.test.mjs"
161
162
  ]
162
163
  },
164
+ {
165
+ "id": "automatic-memory-transition-durability",
166
+ "reason": "Reviewed POSIX automatic boundary capture, complete history, consent suspension, first-start recovery and data-only turn replay; real global Ruflo and filesystem prerequisites are required",
167
+ "files": [
168
+ "tests/unit/project-transition-hook.test.mjs",
169
+ "tests/unit/turn-transport-security.test.mjs",
170
+ "tests/unit/turn-durable-transport.test.mjs",
171
+ "tests/integration/automatic-progression-continuation.test.mjs",
172
+ "tests/integration/capture-consent-boundary.test.mjs",
173
+ "tests/integration/session-start-turn-replay.test.mjs"
174
+ ]
175
+ },
163
176
  {
164
177
  "id": "pending-continuity-capacity",
165
178
  "reason": "POSIX non-root permission refusal, capacity-pressure reporting and recovery preserve all accepted pending continuity events",