ruvnet-brain 4.5.1 → 4.5.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.
@@ -13,16 +13,30 @@ import { inventoryFootprint } from './brain-footprint.mjs';
13
13
  import { confirm, footprintAlarm } from './brain-confirmation.mjs';
14
14
 
15
15
  /**
16
- * KNOWLEDGE SELF-HEAL (owner invariant 2026-09-30: no brain may ever be more than 48h old). The
17
- * nightly scheduler is opt-in and can silently never register, so this does not depend on it: when
18
- * a session starts and nothing proves the knowledge current inside 24h, launch ONE detached, bounded
19
- * `npx ruvnet-brain@latest --update --no-nightly-prompt` (the exact argv bin/nightly-refresh.mjs
20
- * runs) through the existing host-update.mjs credential boundary and detach.mjs TTL supervisor.
21
- * Per machine (all state under brainHome), at most one launch per 6h (30 min after an offline
22
- * probe), never while a refresh lock or another launch is live, never blocks (spawn + unref, no
23
- * wait). The outcome is recorded by the worker and spoken by the NEXT session's knowledge line.
16
+ * KNOWLEDGE SELF-HEAL (owner invariant 2026-09-30: no brain may ever be more than 48h old) AND
17
+ * NEWER-PUBLISHED CHECK (owner requirement 2026-10-02: "all accounts auto update anytime a new corpus of
18
+ * knowledge happens"). Two triggers, ONE updater:
19
+ * • 'update' — nothing proves the knowledge current inside 24h: launch the detached, bounded
20
+ * `npx ruvnet-brain@latest --update --no-nightly-prompt` (the exact argv bin/nightly-refresh.mjs runs)
21
+ * through host-update.mjs's credential boundary and detach.mjs's TTL supervisor. At most one launch
22
+ * per 6h (30 min after an offline probe).
23
+ * • 'check' — the knowledge is fresh BY AGE, but age never says whether something newer was published
24
+ * (measured: v4.4.1 stayed live ~13h after v4.5.0 shipped). At most once per checkMinutes per machine,
25
+ * the same detached worker first runs the installed `kb/forge-update.mjs --check` (one GET of the
26
+ * canonical releases/latest pointer, no download) and proceeds to the SAME update only on a newer
27
+ * identity — forge-update's currencyVerdict(): UPDATE_AVAILABLE/UNKNOWN; CURRENT and REFUSED (the
28
+ * published corpus is older: never downgrade) stop there.
29
+ * Per machine (all state under brainHome), never while a refresh lock or another launch is live, never
30
+ * blocks (spawn + unref, no wait). SessionStart calls this; so does the long-lived MCP server on a timer
31
+ * (plugin/mcp/server.mjs, announce:false so it never consumes the once-per-session line). The outcome is
32
+ * recorded by the worker and spoken by the NEXT session's knowledge line.
24
33
  */
25
- export const AUTO_UPDATE_POLICY = Object.freeze({ staleHours: 24, retryHours: 6, offlineRetryMinutes: 30, ttlSec: 1800 });
34
+ export const AUTO_UPDATE_POLICY = Object.freeze({ staleHours: 24, retryHours: 6, offlineRetryMinutes: 30, ttlSec: 1800, checkMinutes: 60 });
35
+ export const corpusCheckMinutes = (env = {}) => {
36
+ const raw = env.RUVNET_CORPUS_CHECK_MINUTES;
37
+ const n = Number(raw);
38
+ return raw !== undefined && raw !== '' && Number.isFinite(n) && n >= 0 ? n : AUTO_UPDATE_POLICY.checkMinutes;
39
+ };
26
40
 
27
41
  const writeJsonAtomic = (file, value) => {
28
42
  const tmp = `${file}.tmp-${process.pid}`;
@@ -30,14 +44,17 @@ const writeJsonAtomic = (file, value) => {
30
44
  catch { try { fs.rmSync(tmp, { force: true }); } catch { /* ignore */ } return false; }
31
45
  };
32
46
 
33
- export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {}, spawnFn = spawn }) => {
47
+ export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {}, spawnFn = spawn, announce = true }) => {
34
48
  const facts = knowledgeFacts({ env, home, now });
35
- const { attemptFile, lockFile, logFile } = facts.auto;
49
+ const { attemptFile, lockFile, logFile, checkFile, checkResultFile } = facts.auto;
36
50
  const attempt = facts.attempt;
37
51
  const hoursAgo = (iso) => (now - Date.parse(iso || '')) / 3_600_000;
38
- if (attempt?.outcome === 'succeeded' && !attempt.reported) {
52
+ if (announce && attempt?.outcome === 'succeeded' && !attempt.reported) {
39
53
  const built = facts.builtMs;
40
- const tag = facts.source?.releaseTag ? `corpus ${facts.source.releaseTag}` : 'the latest corpus';
54
+ // A corpus-only release carries its identity in corpusReleaseTag; releaseTag is the code release beside it.
55
+ const id = facts.source?.corpusReleaseTag || facts.source?.releaseTag;
56
+ const short = id && id.length > 28 ? `${id.slice(0, 26)}…` : id;
57
+ const tag = !id ? 'the latest corpus' : id.startsWith('corpus-') ? short : `corpus ${short}`;
41
58
  emit(`${KNOWLEDGE_LINE_PREFIX}UPDATED] automatic update finished ${Math.round(hoursAgo(attempt.finishedAt))}h ago: ${tag}, `
42
59
  + `knowledge base built ${Number.isFinite(built) ? `${Math.round(facts.hours(built))}h ago` : 'at an UNKNOWN time'}.`);
43
60
  writeJsonAtomic(attemptFile, { ...attempt, reported: true });
@@ -46,7 +63,14 @@ export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {},
46
63
  if (optOut) return { launched: false, why: optOut };
47
64
  const fresh = facts.provenWithin(AUTO_UPDATE_POLICY.staleHours)
48
65
  || (Number.isFinite(facts.builtMs) && facts.hours(facts.builtMs) <= AUTO_UPDATE_POLICY.staleHours);
49
- if (fresh) return { launched: false, why: 'fresh' };
66
+ const mode = fresh ? 'check' : 'update';
67
+ if (mode === 'check') {
68
+ const lastCheck = Date.parse(facts.corpusCheck?.launchedAt || '');
69
+ const sinceCheck = now - lastCheck;
70
+ if (Number.isFinite(lastCheck) && sinceCheck >= 0 && sinceCheck < corpusCheckMinutes(env) * 60_000) {
71
+ return { launched: false, why: 'fresh' };
72
+ }
73
+ }
50
74
  // A nightly or manual --update holds this lock (kb/refresh-run.mjs refreshLockPath, duplicated here
51
75
  // because the plugin payload cannot import kb/); --update would refuse anyway, this saves the launch.
52
76
  if (exists(path.join(path.dirname(facts.kbDir), `.${path.basename(facts.kbDir)}.refresh-run.lock`))) {
@@ -54,8 +78,11 @@ export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {},
54
78
  }
55
79
  const since = hoursAgo(attempt?.launchedAt);
56
80
  const retryHours = attempt?.outcome === 'offline' ? AUTO_UPDATE_POLICY.offlineRetryMinutes / 60 : AUTO_UPDATE_POLICY.retryHours;
57
- if (since >= 0 && since < retryHours) return { launched: false, why: 'throttled' };
58
- // Cross-process: exactly one session wins the O_EXCL create; a lock older than the TTL is stale.
81
+ // A SUCCEEDED update never delays the next newer-published check: a different, newer corpus may proceed
82
+ // at once. The same target is not re-run within 6h (host-update.mjs --if-newer's 'not-converged' guard).
83
+ const retryBlocks = !(mode === 'check' && attempt?.outcome === 'succeeded');
84
+ if (retryBlocks && since >= 0 && since < retryHours) return { launched: false, why: 'throttled' };
85
+ // Cross-process: exactly one session (or MCP server timer) wins the O_EXCL create; a stale lock is reclaimed.
59
86
  const claim = () => { try { fs.writeFileSync(lockFile, `${JSON.stringify({ pid: process.pid, at: new Date(now).toISOString() })}\n`, { flag: 'wx' }); return true; } catch { return false; } };
60
87
  if (!claim()) {
61
88
  const lockMs = Date.parse(json(lockFile)?.at || '') || mtimeMs(lockFile);
@@ -63,20 +90,26 @@ export const knowledgeAutoUpdate = ({ env, home, now, hookDir, emit = () => {},
63
90
  try { fs.rmSync(lockFile, { force: true }); } catch { /* raced */ }
64
91
  if (!claim()) return { launched: false, why: 'locked' };
65
92
  }
66
- writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: new Date(now).toISOString(), outcome: 'launched' });
93
+ const at = new Date(now).toISOString();
94
+ // A check is not an update attempt: it must not reset the 6h retry or read as an abandoned launch.
95
+ if (mode === 'update') writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: at, outcome: 'launched' });
96
+ else writeJsonAtomic(checkFile, { ...(facts.corpusCheck || {}), schemaVersion: 1, launchedAt: at, outcome: 'checking' });
67
97
  try {
68
98
  const child = spawnFn(process.execPath, [path.join(hookDir, 'detach.mjs'), String(AUTO_UPDATE_POLICY.ttlSec), logFile,
69
- process.execPath, path.join(hookDir, 'host-update.mjs'), '--knowledge', attemptFile, lockFile],
99
+ process.execPath, path.join(hookDir, 'host-update.mjs'), '--knowledge', attemptFile, lockFile,
100
+ ...(mode === 'check' ? ['--if-newer', facts.kbDir, checkFile, checkResultFile] : [])],
70
101
  { detached: true, stdio: 'ignore', env, windowsHide: true });
71
102
  child.on?.('error', () => {});
72
103
  child.unref?.();
73
104
  } catch (error) {
74
- writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: new Date(now).toISOString(), outcome: 'failed',
75
- code: null, reason: `could not launch: ${error.message}`, finishedAt: new Date(now).toISOString() });
105
+ const reason = `could not launch: ${error.message}`;
106
+ if (mode === 'update') {
107
+ writeJsonAtomic(attemptFile, { schemaVersion: 1, launchedAt: at, outcome: 'failed', code: null, reason, finishedAt: at });
108
+ } else writeJsonAtomic(checkFile, { schemaVersion: 1, launchedAt: at, outcome: 'failed', reason, checkedAt: at });
76
109
  try { fs.rmSync(lockFile, { force: true }); } catch { /* ignore */ }
77
- return { launched: false, why: 'launch failed' };
110
+ return { launched: false, why: 'launch failed', mode };
78
111
  }
79
- return { launched: true, why: 'stale' };
112
+ return { launched: true, why: mode === 'check' ? 'checking for a newer published corpus' : 'stale', mode };
80
113
  };
81
114
 
82
115
  /**
@@ -179,7 +212,9 @@ export const heartbeat = ({ env, hookDir, stateDir, home, running, seedDispatche
179
212
  const kbDir = path.join(home, '.cache', 'ruvnet-brain', 'kb');
180
213
  if (pref === 'yes' && exists(path.join(kbDir, 'forge-update.mjs'))) {
181
214
  const kbLog = path.join(stateDir, '.last-kb-check.log');
182
- if (/\bBEHIND\b/.test(read(kbLog))) {
215
+ // Only when the automatic knowledge update is OFF on this machine: otherwise it IS applied (signature
216
+ // verified first) and the knowledge line says so — this notice would be a false statement.
217
+ if (/\bBEHIND\b/.test(read(kbLog)) && autoUpdateOptOut({ env, home, facts: knowledgeFacts({ env, home, now }) })) {
183
218
  emit('[RuvNet Brain — a newer knowledge bundle is available. It is signed (Ed25519) and the updater verifies that signature before extracting anything. We do NOT auto-apply it: applying replaces executable tool files, which is your call. To update: npx ruvnet-brain@latest --update]');
184
219
  }
185
220
  // S2 (ONE CURRENCY VERDICT): --result-file records the SAME structured verdict --check/--apply
@@ -10,9 +10,8 @@
10
10
  * is the RECORDING tier; distill mines it into episodes/reasoning_patterns/
11
11
  * causal_edges, the KNOWLEDGE tier, which stays empty unless it is run)
12
12
  *
13
- * WHERE: the project's `.swarm/memory.db` when it exists, else the machine-wide
14
- * `~/.claude/global-memory/.swarm/memory.db` (outside every repository). `.swarm` is NEVER created
15
- * inside a project — see session-snapshot-hook.mjs's writeSessionSnapshot for why that is trespass.
13
+ * WHERE: only the canonical project's `.swarm/memory.db` (including linked worktrees).
14
+ * An absent store requires persisted opt-in; there is no automatic machine-wide fallback.
16
15
  *
17
16
  * WHAT (measured facts carried over from the owner's local Claude hook, 2026-09-29):
18
17
  * • The Brain's continuation gate continues most turns, so the turn's REAL final Stop carries
@@ -33,7 +32,7 @@
33
32
  * LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
34
33
  * (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
35
34
  * distill in order; the hook itself only reads, fingerprints and spawns. A breadcrumb line is
36
- * appended next to the db BEFORE the spawn, and the worker appends a receipt per step, so a lost
35
+ * containing only key, hash and length is appended BEFORE the spawn; exact readback receipts make a lost
37
36
  * write is visible rather than silent. Advisory always: nothing here throws to the caller.
38
37
  */
39
38
  import fs from 'node:fs';
@@ -43,7 +42,8 @@ import crypto from 'node:crypto';
43
42
  import { spawn, spawnSync } from 'node:child_process';
44
43
  import { fileURLToPath } from 'node:url';
45
44
  import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
46
- import { userLevelAgentdbHooks } from './continuity-events.mjs';
45
+ import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
46
+ import { resolveProjectStore } from './project-store-resolver.mjs';
47
47
 
48
48
  export const TURN_NAMESPACE = 'turns';
49
49
  export const MIN_OUTCOME_CHARS = 200;
@@ -87,7 +87,7 @@ export function claudeTurn(lines) {
87
87
  if ((o?.message?.role || o?.role) !== 'assistant') continue;
88
88
  const c = o.message?.content;
89
89
  const t = textOf(c).trim();
90
- if (t) texts.push(t);
90
+ if (t) texts.push(redactText(t));
91
91
  if (!Array.isArray(c)) continue;
92
92
  for (const u of c) {
93
93
  if (!u || u.type !== 'tool_use') continue;
@@ -138,22 +138,63 @@ export function readSettledTranscript(file, { stableMs = 400, maxMs = 2000, slee
138
138
 
139
139
  export function buildTurnRecord({ turn, project, host, session, at = new Date() }) {
140
140
  const parts = [`[turn ${at.toISOString()} project=${project} host=${host}]`,
141
- `OUTCOME: ${turn.finalText.replace(/\s+/g, ' ').slice(0, 2500)}`];
142
- if (turn.files.length) parts.push(`FILES CHANGED: ${turn.files.slice(0, 25).join(', ')}`);
143
- if (turn.actions.length) parts.push(`ACTIONS: ${turn.actions.slice(-15).join(' • ')}`);
141
+ `OUTCOME: ${redactText(turn.finalText).replace(/\s+/g, ' ').slice(0, 2500)}`];
142
+ if (turn.files.length) parts.push(`FILES CHANGED: ${turn.files.slice(0, 25).map(redactText).join(', ')}`);
143
+ if (turn.actions.length) parts.push(`ACTIONS: ${turn.actions.slice(-15).map(redactText).join(' • ')}`);
144
144
  parts.push(`SESSION: ${session || '?'}`);
145
- return parts.join(' || ').slice(0, 4000);
145
+ return redactText(parts.join(' || ')).slice(0, 4000);
146
146
  }
147
147
 
148
- /** Project db if the project already has one; otherwise the machine-wide db outside every repo. */
149
- export function resolveTurnDb({ projectDir, home = os.homedir(), env = process.env } = {}) {
150
- const projectDb = path.join(projectDir, '.swarm', 'memory.db');
151
- try { if (fs.statSync(projectDb).isFile()) return { db: projectDb, scope: 'project' }; } catch { /* absent */ }
152
- const globalDb = env.RUVNET_TURN_GLOBAL_DB || path.join(home, '.claude', 'global-memory', '.swarm', 'memory.db');
153
- return { db: globalDb, scope: 'global' };
148
+ /** Persisted per-canonical-project/per-path consent, reread at every boundary (no restart). */
149
+ export function turnCapturePolicyFile(brainHome) {
150
+ return path.join(brainHome, 'turn-capture', 'policy.json');
154
151
  }
155
152
 
156
- const projectName = (projectDir) => path.basename(path.resolve(projectDir)).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
153
+ const consentMap = (value) => value !== null && typeof value === 'object'
154
+ && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
155
+ && Object.values(value).every((setting) => setting === 'on' || setting === 'off');
156
+
157
+ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000 } = {}) {
158
+ const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs });
159
+ let policy = {};
160
+ const file = brainHome && turnCapturePolicyFile(brainHome);
161
+ if (file && fs.existsSync(file)) {
162
+ try {
163
+ policy = JSON.parse(fs.readFileSync(file, 'utf8'));
164
+ if (policy?.schemaVersion !== 1 || !consentMap(policy.projects)
165
+ || (Object.hasOwn(policy, 'paths') && !consentMap(policy.paths))) throw new Error('invalid policy');
166
+ } catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
167
+ }
168
+ // 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
+ if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
171
+ const db = resolved.canonicalAgentDbPath;
172
+ assertTurnStoreFiles(db);
173
+ if (setting === 'off') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
174
+ let exists = false;
175
+ 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' };
178
+ }
179
+
180
+ // Revalidation narrows the queue-to-launch window; it does not make SQLite's later open atomic.
181
+ function assertTurnStoreFiles(db) {
182
+ const directory = path.dirname(db);
183
+ let stat;
184
+ try { stat = fs.lstatSync(directory); } catch (error) { if (error.code !== 'ENOENT') throw error; }
185
+ if (stat && (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync.native(directory) !== directory)) {
186
+ throw new Error('store directory is not a canonical regular directory');
187
+ }
188
+ for (const file of [db, `${db}-wal`, `${db}-shm`, `${db}-journal`]) {
189
+ let entry;
190
+ try { entry = fs.lstatSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
191
+ if (entry && (!entry.isFile() || entry.isSymbolicLink() || entry.nlink > 1)) {
192
+ throw new Error('store or SQLite side file is a symlink, hard link or non-regular file');
193
+ }
194
+ }
195
+ }
196
+
197
+ const projectName = (projectDir) => redactText(path.basename(path.resolve(projectDir))).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
157
198
 
158
199
  /** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
159
200
  export function launchDetached(steps, { receipts }) {
@@ -164,22 +205,36 @@ export function launchDetached(steps, { receipts }) {
164
205
  return { launched: true, pid: child.pid };
165
206
  }
166
207
 
167
- function sameTurnSeen(stateFile, sessionKey, fingerprint) {
168
- let last = {};
169
- try { last = JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { /* first run */ }
170
- if (last[sessionKey] === fingerprint) return true;
208
+ function readTurnState(stateFile) {
209
+ try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; }
210
+ }
211
+
212
+ function sameTurnSeen(stateFile, sessionKey, fingerprint, receipts) {
213
+ const previous = readTurnState(stateFile)[sessionKey];
214
+ if (previous?.fingerprint !== fingerprint) return false;
215
+ try {
216
+ const rows = readTail(receipts, 128 * 1024).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
217
+ const receipt = rows.findLast((r) => r.kind === 'store' && r.key === previous.key);
218
+ if (receipt) return receipt.status === 0 && receipt.verified === true;
219
+ } catch { /* queued worker has no receipt yet */ }
220
+ // An in-flight worker gets a bounded grace period; a killed/lost worker cannot consume the turn forever.
221
+ return Date.now() - previous.at < 2 * STEP_TIMEOUT_MS + 10_000;
222
+ }
223
+
224
+ function markTurnQueued(stateFile, sessionKey, fingerprint, key) {
225
+ const last = readTurnState(stateFile);
171
226
  const keys = Object.keys(last);
172
227
  for (const k of keys.slice(0, Math.max(0, keys.length - 200))) delete last[k];
173
228
  try {
174
229
  fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 });
175
- fs.writeFileSync(stateFile, JSON.stringify({ ...last, [sessionKey]: fingerprint }), { mode: 0o600 });
230
+ fs.writeFileSync(stateFile, JSON.stringify({ ...last, [sessionKey]: { fingerprint, key, at: Date.now() } }), { mode: 0o600 });
176
231
  } catch { /* dedupe is best effort; a duplicate record beats a lost one */ }
177
- return false;
178
232
  }
179
233
 
180
234
  /**
181
235
  * Capture this turn's outcome (Stop) and/or queue distillation (SessionEnd, PreCompact).
182
- * Returns a plain report; `skipped` / `distill.skipped` carry the reason whenever nothing is written.
236
+ * Returns a plain report; `queued` means a worker request, never proof the store committed.
237
+ * `skipped` / `distill.skipped` carry the reason whenever nothing is requested.
183
238
  */
184
239
  export function captureTurnOutcome({
185
240
  projectDir, event, payload = {}, host = 'claude',
@@ -191,15 +246,20 @@ export function captureTurnOutcome({
191
246
  settleMs = 2000,
192
247
  now = () => new Date(),
193
248
  } = {}) {
194
- const report = { event, host, recorded: false, distill: { queued: false } };
249
+ const report = { event, host, queued: false, recorded: false, distill: { queued: false } };
195
250
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') {
196
251
  return { ...report, skipped: 'RUVNET_TURN_CAPTURE=off', distill: { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
197
252
  }
198
253
  if (!ruflo) return { ...report, skipped: 'ruflo not found', distill: { queued: false, skipped: 'ruflo not found' } };
199
- const { db, scope } = resolveTurnDb({ projectDir, home, env });
254
+ let target;
255
+ try { target = resolveTurnDb({ projectDir, brainHome }); } catch (error) { return { ...report, skipped: `store resolution failed: ${redactText(error.message)}` }; }
256
+ const { db, scope, projectRoot } = target;
200
257
  Object.assign(report, { db, scope });
258
+ if (target.skipped) return { ...report, skipped: target.skipped, distill: { queued: false, skipped: target.skipped } };
201
259
  const steps = [];
202
- const project = projectName(projectDir);
260
+ const binding = { projectRoot, projectDir: fs.realpathSync.native(projectDir), brainHome };
261
+ let dedupe;
262
+ const project = projectName(projectRoot);
203
263
  const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
204
264
 
205
265
  // ONE WRITER PER TURN (ADR-100 §3). Measured 2026-10-01 on this repo's real store: 624 `turns` rows
@@ -228,14 +288,17 @@ export function captureTurnOutcome({
228
288
  } else {
229
289
  const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
230
290
  const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
231
- if (sameTurnSeen(stateFile, `${host}:${sessionKey}`, fingerprint)) report.skipped = 'same turn outcome already recorded';
291
+ const identity = crypto.createHash('sha256').update(`${host}:${db}:${sessionKey}`).digest('hex');
292
+ if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
232
293
  else {
233
294
  const at = now();
234
- const key = `turn-${project}-${at.getTime()}`;
295
+ const key = `turn-${project}-${at.getTime()}-${crypto.randomBytes(6).toString('hex')}`;
296
+ dedupe = { stateFile, identity, fingerprint, key };
235
297
  const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
236
- steps.push({ kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
298
+ steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
237
299
  '--path', db, '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
238
- Object.assign(report, { recorded: true, key, value });
300
+ // This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
301
+ Object.assign(report, { queued: true, key, value });
239
302
  }
240
303
  }
241
304
  } else report.skipped = `turn outcomes are recorded at Stop, not ${event}`;
@@ -243,49 +306,66 @@ export function captureTurnOutcome({
243
306
  if (event === 'SessionEnd' || event === 'PreCompact') {
244
307
  if (!fs.existsSync(db)) report.distill = { queued: false, skipped: 'no memory db to distill yet' };
245
308
  else {
246
- steps.push({ kind: 'distill', ruflo, args: ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'] });
309
+ steps.push({ ...binding, kind: 'distill', ruflo, args: ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'] });
247
310
  report.distill = { queued: true, db };
248
311
  }
249
312
  }
250
313
  if (!steps.length) return report;
251
314
 
252
315
  try {
253
- // The machine-wide db lives outside every repository; its directory is created on first use so a
254
- // fresh machine records from its first turn. A project's `.swarm` is never created (scope check).
255
- if (scope === 'global') fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
256
- if (report.recorded) {
316
+ // Only explicit persisted consent permits creating a project store directory.
317
+ if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
318
+ if (report.queued) {
257
319
  fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
258
- `${JSON.stringify({ ts: Date.now(), key: report.key, project, host, value: report.value })}\n`, { mode: 0o600 });
320
+ `${JSON.stringify({ ts: Date.now(), key: report.key, hash: crypto.createHash('sha256').update(report.value).digest('hex'), len: report.value.length })}\n`, { mode: 0o600 });
259
321
  }
260
322
  report.launch = launch(steps, { receipts });
323
+ if (dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
261
324
  } catch (error) {
262
- return { ...report, recorded: false, distill: { queued: false, skipped: `launch failed: ${error.message}` }, skipped: `launch failed: ${error.message}` };
325
+ return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: `launch failed: ${redactText(error.message)}` }, skipped: `launch failed: ${redactText(error.message)}` };
263
326
  }
264
327
  return report;
265
328
  }
266
329
 
267
- /** The detached worker: run each step in order, bounded, and append one receipt per step. */
268
- export function runSteps({ steps = [], receipts } = {}, { run = spawnSync } = {}) {
330
+ /** Exact (namespace, key, content) readback. Exit zero alone never proves capture. */
331
+ function readBack({ ruflo, db, key, run, options }) {
332
+ const invocation = rufloInvocation(ruflo, ['memory', 'retrieve', '--key', key, '--namespace', TURN_NAMESPACE, '--value-only', '--path', db]);
333
+ const result = run(invocation.executable, invocation.args, options);
334
+ return result.status === 0 ? String(result.stdout || '').trim() : null;
335
+ }
336
+
337
+ /** The detached worker: bounded steps, safe error evidence, exact content readback. */
338
+ export function runSteps({ steps = [], receipts } = {}, { run = spawnSync, read = readBack, env = process.env } = {}) {
269
339
  const results = [];
270
340
  for (const step of steps) {
271
- let status = null;
272
- let error = null;
273
- const db = step.args[step.args.indexOf(step.kind === 'store' ? '--path' : '--db') + 1];
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]);
274
347
  try {
275
- const { executable, args } = rufloInvocation(step.ruflo, step.args);
276
- // CONTAINMENT (measured 2026-09-29, ruflo 3.48.0): even with an explicit --path, `memory store`
277
- // writes `.swarm/hnsw.index` and `ruvector.db` relative to its CWD. Run from an inherited cwd,
278
- // that planted `.swarm/` + `ruvector.db` in a repository that never adopted the brain — the
279
- // exact trespass session-snapshot-hook.mjs forbids. So every step runs INSIDE the db's own
280
- // directory, with ruflo's memory root pinned there too.
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
+ 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.
281
355
  const home = path.dirname(db);
282
- const r = run(executable, args, { stdio: 'ignore', timeout: STEP_TIMEOUT_MS, cwd: home, windowsHide: true,
283
- env: { ...process.env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: home } });
284
- status = r.status;
285
- if (r.error) error = r.error.message;
286
- } catch (e) { error = e.message; }
287
- const row = { at: new Date().toISOString(), kind: step.kind, db,
288
- key: step.kind === 'store' ? step.args[step.args.indexOf('-k') + 1] : undefined, status, error };
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);
359
+ 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
+ }
365
+ } 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 } : {}) };
289
369
  results.push(row);
290
370
  if (receipts) {
291
371
  try {
@@ -297,6 +377,24 @@ export function runSteps({ steps = [], receipts } = {}, { run = spawnSync } = {}
297
377
  return results;
298
378
  }
299
379
 
380
+ /** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
381
+ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now() } = {}) {
382
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
383
+ let target;
384
+ try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
385
+ if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
386
+ let rows = [];
387
+ try {
388
+ rows = readTail(path.join(brainHome, 'turn-capture', 'receipts.jsonl'), 128 * 1024).flatMap((line) => {
389
+ try { return [JSON.parse(line)]; } catch { return []; }
390
+ }).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
+ } catch { /* first run */ }
392
+ const failed = rows.filter((r) => r.status !== 0 || r.verified !== true);
393
+ 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)}` };
394
+ return rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
395
+ : { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' };
396
+ }
397
+
300
398
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--run-steps') {
301
399
  try { runSteps(JSON.parse(process.argv[3] || '{}')); } catch { /* a detached worker has no one to report to */ }
302
400
  process.exit(0);
@@ -41,6 +41,7 @@ const usage = () => `Usage:
41
41
  node scripts/learning-replay.mjs [--trap ${TRAP.MEMORY_SEARCH}|${TRAP.POST_TASK}] [--n N] [--host codex|claude-code] [--model MODEL]
42
42
  node scripts/learning-replay.mjs --check
43
43
  node scripts/learning-replay.mjs --check-portfolio
44
+ node scripts/learning-replay.mjs --measure-portfolio --host codex --model MODEL
44
45
  node scripts/learning-replay.mjs --check-mutants
45
46
  node scripts/learning-replay.mjs --dry-run
46
47
  node scripts/learning-replay.mjs --mutant <${Object.keys(MUTANTS).join('|')}>
@@ -86,6 +87,19 @@ function printed(label, result) {
86
87
  return EXIT[result.status] ?? EXIT.UNKNOWN;
87
88
  }
88
89
 
90
+ // A portfolio includes both positive traps AND all four causal mutants. Run every scenario even
91
+ // after a failure, then let the unchanged evidence checker decide the result; child exit 1 is the
92
+ // expected outcome of a causal mutant, and is never by itself a successful portfolio measurement.
93
+ export async function measurePortfolio({ host, model, execute = main, check = checkPortfolio }) {
94
+ for (const trap of [TRAP.MEMORY_SEARCH, TRAP.POST_TASK]) {
95
+ await execute(['--trap', trap, '--n', '3', '--host', host, '--model', model]);
96
+ for (const mutant of ['delete-lesson', 'brain-off-treated']) {
97
+ await execute(['--trap', trap, '--mutant', mutant, '--n', '1', '--host', host, '--model', model]);
98
+ }
99
+ }
100
+ return printed(`${INVARIANT}-PORTFOLIO`, check());
101
+ }
102
+
89
103
  export async function main(argv = process.argv.slice(2)) {
90
104
  const { has, arg } = parse(argv);
91
105
  if (has('--help') || has('-h')) {
@@ -113,6 +127,7 @@ export async function main(argv = process.argv.slice(2)) {
113
127
  }
114
128
  if (has('--check-portfolio')) return printed(`${INVARIANT}-PORTFOLIO`, checkPortfolio());
115
129
  if (has('--check-mutants')) return printed(`${INVARIANT}-MUTANTS`, checkMutantArtifacts());
130
+ if (has('--measure-portfolio')) return measurePortfolio({ host, model });
116
131
 
117
132
  const source = checkSourceIdentity();
118
133
  if (!source.clean) {
@@ -8,7 +8,7 @@ export * from './learning-replay-contract.mjs';
8
8
  export * from './learning-replay-execution.mjs';
9
9
  export * from './learning-replay-fixture.mjs';
10
10
  export * from './learning-replay-proof.mjs';
11
- export { main } from './learning-replay-cli.mjs';
11
+ export { main, measurePortfolio } from './learning-replay-cli.mjs';
12
12
 
13
13
  const invokedDirectly = process.argv[1]
14
14
  && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
@@ -177,14 +177,14 @@ sh scripts/memdb-health.sh "$RUVNET_PROJECT_MEMORY_DB" >> "$LOG" 2>&1 \
177
177
  && echo "===== memdb-health canary: OK =====" >> "$LOG" \
178
178
  || echo "===== memdb-health canary: UNHEALTHY (see line above) =====" >> "$LOG"
179
179
 
180
- # ── LEARNING-REPLAY: the D4 counterfactual trap (ADR-058 §D4). THE ONE STANDING TOKEN SPEND, priced
181
- # in the open: N=3 replay, two model arms per run, ~$0.09 and ~55s measured 2026-07-27 on haiku.
180
+ # ── LEARNING-REPLAY: the D4 counterfactual portfolio (ADR-058 §D4). This manual author run spends
181
+ # real model tokens: two N=3 positive traps and four N=1 causal mutants, with two arms per run.
182
182
  #
183
183
  # It runs HERE, and not on a GitHub runner, because the trap needs credentials a bare runner does not
184
184
  # have — a real model session AND the real global `ruflo` binary (it records into a fixture project's
185
185
  # .swarm/memory.db and refreshes it with `ruflo memory distill`). .github/workflows/learning-replay.yml
186
- # is the currency gate on the artifact this writes; if this step stops running, that workflow goes red
187
- # on staleness rather than everything staying quietly green.
186
+ # is the currency gate on the evidence this writes; if evidence stops being refreshed, it goes red
187
+ # on staleness. This wrapper is an author diagnostic, not an installed scheduler or publisher.
188
188
  #
189
189
  # Best-effort, same shape as the canaries below: it never blocks the rebuild. Its exit code is not
190
190
  # thrown away though — it is written to the log by name, because 0/1/3/4 are four different facts
@@ -222,7 +222,7 @@ echo "===== RELEASE-CONVERGENCE watchdog — $(date -u +%FT%TZ) =====" >> "$LOG"
222
222
  || echo "[release-watchdog] exited non-zero — see above; nightly continues" >> "$LOG"
223
223
 
224
224
  echo "===== LEARNING-REPLAY counterfactual trap — $(date -u +%FT%TZ) =====" >> "$LOG"
225
- "$NODE_BIN" scripts/learning-replay.mjs --n 3 --model haiku >> "$LOG" 2>&1
225
+ "$NODE_BIN" scripts/learning-replay.mjs --measure-portfolio --host codex --model gpt-6.1-sol >> "$LOG" 2>&1
226
226
  LR_RC=$?
227
227
  case "$LR_RC" in
228
228
  0) echo "===== LEARNING-REPLAY: PASS =====" >> "$LOG" ;;