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.
@@ -0,0 +1,333 @@
1
+ /** Canonical capture queue, fencing and bounded replay; no independent writer authority. */
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import os from 'node:os';
5
+ import { spawn, spawnSync } from 'node:child_process';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { developmentHooksSuspended } from './development-maintenance.mjs';
8
+ import { resolveTurnDb } from './turn-outcome-capture.mjs';
9
+ import { buildProjectProgression } from './project-progression-producer.mjs';
10
+ import { resolveProjectStore } from './project-store-resolver.mjs';
11
+ import { redactProgression } from './project-progression-contract.mjs';
12
+ import { ProgressionOutbox } from './project-progression-outbox.mjs';
13
+ import { captureNormalizedTransition } from './project-transition-hook.mjs';
14
+ import { boundedStoreFactory, runSessionSnapshotHook } from './session-snapshot-hook.mjs';
15
+
16
+ /**
17
+ * How long the detached worker may spend per step. The lock is refreshed between steps and goes stale
18
+ * after REPLAY_LOCK_STALE_MS, which is more than twice a step, so a live worker never looks dead.
19
+ */
20
+ export const DETACHED_REPLAY_BUDGET_MS = 45_000;
21
+ export const REPLAY_LOCK_STALE_MS = 120_000;
22
+ const REPLAY_LOCK = '.progression-replay.lock';
23
+ const QUEUE_PREFIX = '.progression-capture-queue-';
24
+ const lockPath = (projectDir) => path.join(projectDir, '.swarm', REPLAY_LOCK);
25
+ // The lock's FIRST line is the owner token; a second `pid <n>` line names the process holding it.
26
+ const readLock = (projectDir) => { try { return fs.readFileSync(lockPath(projectDir), 'utf8').split('\n')[0].trim(); } catch { return null; } };
27
+ const CLAIM_PREFIX = '.progression-capture-claimed-';
28
+ /** After this long a stale lock is taken over even if its holder pid looks alive (pid reuse, a wedged process). */
29
+ export const REPLAY_LOCK_ABANDON_MS = 30 * 60_000;
30
+
31
+ /** Is a process with this pid alive? EPERM means alive but not ours. Never throws. */
32
+ export function pidAlive(pid) {
33
+ if (!Number.isSafeInteger(pid) || pid <= 0) return false;
34
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
35
+ }
36
+ const seqOf = (name) => Number((/(\d{12})\.json$/.exec(name) || [])[1] ?? 0);
37
+ const swarmEntries = (projectDir) => { try { return fs.readdirSync(path.join(projectDir, '.swarm')); } catch { return []; } };
38
+ // 4.4.0 named queue files by wall clock: `<prefix><15-digit ms>-<hrtime>-<pid>-<n>.json`. Open 4.4.0
39
+ // sessions keep queuing in that format after the update, so the two formats coexist for a while.
40
+ const LEGACY_QUEUE = /^\d{15}-/;
41
+ const queueTail = (name) => (name.startsWith(QUEUE_PREFIX) ? name.slice(QUEUE_PREFIX.length) : name.slice(CLAIM_PREFIX.length).replace(/^\d+-[A-Za-z0-9]*-\d+-/, '')); // <pid>-<start>-<queuedAt>-
42
+ const mtimeOf = (projectDir, name) => { try { return fs.statSync(path.join(projectDir, '.swarm', name)).mtimeMs; } catch { return Infinity; } };
43
+
44
+ /**
45
+ * Queue one boundary's capture for the worker (0600, inside the project's own .swarm). ORDER IS THE
46
+ * ORDER OF EXCLUSIVE CREATION: the name is the next sequence number after every queued or claimed one,
47
+ * created with O_EXCL and retried on collision — never a clock, which can step backwards or wrap.
48
+ */
49
+ export function queueCapture({ projectDir, originProjectDir = projectDir, event, host, payload, env = process.env }) {
50
+ try {
51
+ const consent = resolveTurnDb({ projectDir: originProjectDir, requestedStorePath: path.join(projectDir, '.swarm', 'memory.db'),
52
+ brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain') });
53
+ if (consent.skipped) return null;
54
+ } catch { return null; }
55
+ // Freeze legacy callers at the original boundary too, before dropping host payload.
56
+ let progression = payload?.projectProgression;
57
+ if (!progression && !payload?.normalizedTransition) {
58
+ try { progression = buildProjectProgression({ resolution: resolveProjectStore({ projectDir: originProjectDir }), projectDir: originProjectDir, payload, host, trigger: event }).projectProgression; } catch { return null; }
59
+ }
60
+ // This queue is durable: never serialize arbitrary host prompts, tool input or output.
61
+ const minimized = { session_id: payload?.session_id, hook_event_name: event,
62
+ ...(progression ? { projectProgression: progression } : {}),
63
+ ...(payload?.normalizedTransition ? { normalizedTransition: payload.normalizedTransition } : {}) };
64
+ const body = JSON.stringify(redactProgression({ event, host, originProjectDir,
65
+ queuedAt: new Date().toISOString(), payload: minimized }).value);
66
+ for (let attempt = 0; attempt < 64; attempt += 1) {
67
+ const seq = Math.max(0, ...swarmEntries(projectDir).filter((n) => n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)).map(seqOf)) + 1;
68
+ const file = path.join(projectDir, '.swarm', `${QUEUE_PREFIX}${String(seq).padStart(12, '0')}.json`);
69
+ try {
70
+ const fd = fs.openSync(file, 'wx', 0o600);
71
+ try { fs.writeFileSync(fd, body); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
72
+ return file;
73
+ } catch (error) {
74
+ if (error?.code !== 'EEXIST') return null;
75
+ }
76
+ }
77
+ return null;
78
+ }
79
+
80
+ /**
81
+ * Unclaimed queued captures, in queue order. While any 4.4.0 (timestamp-named) entry is present the
82
+ * order is creation time (mtime, then name) — by NAME every 4.4.1 sequence file would sort before every
83
+ * 4.4.0 one, replaying newer captures before older ones across the upgrade window. Once the legacy
84
+ * entries are gone the order is the sequence alone, independent of any clock.
85
+ */
86
+ export function queuedCaptures(projectDir) {
87
+ const all = swarmEntries(projectDir).filter((n) => (n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)) && n.endsWith('.json'));
88
+ const queued = all.filter((n) => n.startsWith(QUEUE_PREFIX));
89
+ const mixed = all.some((n) => LEGACY_QUEUE.test(queueTail(n)));
90
+ const ordered = mixed
91
+ ? queued.map((n) => [mtimeOf(projectDir, n), n]).sort((a, b) => a[0] - b[0] || (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0)).map(([, n]) => n)
92
+ : queued.sort();
93
+ return ordered.map((n) => path.join(projectDir, '.swarm', n));
94
+ }
95
+
96
+ /** All older work a boundary must wait behind: unclaimed captures plus captures a worker has claimed. */
97
+ export function queuedWork(projectDir) {
98
+ return swarmEntries(projectDir).filter((n) => (n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)) && n.endsWith('.json')).length;
99
+ }
100
+
101
+ /**
102
+ * A process's START TIME, as a filename-safe token, or null where it cannot be read (no `ps`, e.g.
103
+ * Windows). With the pid it identifies the process: a reused pid has a different start time.
104
+ */
105
+ export function processStart(pid) {
106
+ try {
107
+ // TZ and locale PINNED: `lstart` prints local time in the locale's format, so two workers with
108
+ // different settings would record the same live process differently and read it as pid reuse.
109
+ const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, windowsHide: true,
110
+ env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
111
+ const s = String(r.stdout || '').replace(/[^A-Za-z0-9]/g, '');
112
+ return r.status === 0 && s ? s : null;
113
+ } catch { return null; }
114
+ }
115
+ let selfStart;
116
+ const ownStart = () => (selfStart === undefined ? (selfStart = processStart(process.pid)) : selfStart);
117
+
118
+ /**
119
+ * Claim a queued capture by atomic rename; null if taken. The claim's name records pid, start time and
120
+ * the queue file's ORIGINAL mtime (its creation order, which the mixed upgrade window sorts by); the
121
+ * claim file's own mtime is then set to the claim time, from which the orphan ceiling counts.
122
+ */
123
+ function claimQueued(file) {
124
+ let queuedAt = 0;
125
+ try { queuedAt = Math.floor(fs.statSync(file).mtimeMs); } catch { return null; }
126
+ const claimed = path.join(path.dirname(file), `${CLAIM_PREFIX}${process.pid}-${ownStart() || 'na'}-${queuedAt}-${path.basename(file).slice(QUEUE_PREFIX.length)}`);
127
+ try { fs.renameSync(file, claimed); } catch { return null; }
128
+ try { const t = new Date(); fs.utimesSync(claimed, t, t); } catch { /* the ceiling then counts from queue time: earlier, never later */ }
129
+ return claimed;
130
+ }
131
+ const unclaimedName = (claimed) => path.join(path.dirname(claimed), `${QUEUE_PREFIX}${queueTail(path.basename(claimed))}`);
132
+ /** Put a claim back in the queue: rename (atomic, needs no hard links), restoring its creation order. */
133
+ const returnClaim = (claimed) => {
134
+ const queuedAt = Number(path.basename(claimed).slice(CLAIM_PREFIX.length).split('-')[2]);
135
+ const back = unclaimedName(claimed);
136
+ try { fs.renameSync(claimed, back); } catch { return false; }
137
+ if (Number.isFinite(queuedAt) && queuedAt > 0) { try { const t = new Date(queuedAt); fs.utimesSync(back, t, t); } catch { /* best effort */ } }
138
+ return true;
139
+ };
140
+
141
+ /**
142
+ * Return to the queue every claim whose worker is gone: its pid is dead, OR the pid now belongs to a
143
+ * different process (start time differs — pid reuse), OR the claim is older than REPLAY_LOCK_ABANDON_MS
144
+ * whatever the pid says (a reused pid where no start time can be read, a wedged worker). Without the
145
+ * last two a reused pid stranded a claim forever and every Stop spawned a worker that could not run it.
146
+ */
147
+ export function reclaimOrphans(projectDir, { isAlive = pidAlive, startOf = processStart, now = Date.now() } = {}) {
148
+ let n = 0;
149
+ for (const name of swarmEntries(projectDir).filter((x) => x.startsWith(CLAIM_PREFIX))) {
150
+ const [pidText, start] = name.slice(CLAIM_PREFIX.length).split('-');
151
+ const pid = Number(pidText);
152
+ const claimed = path.join(projectDir, '.swarm', name);
153
+ const abandoned = now - mtimeOf(projectDir, name) > REPLAY_LOCK_ABANDON_MS;
154
+ let gone = abandoned || !isAlive(pid);
155
+ if (!gone && start && start !== 'na') {
156
+ const current = startOf(pid);
157
+ gone = Boolean(current) && current !== start;
158
+ }
159
+ if (gone && returnClaim(claimed)) n += 1;
160
+ }
161
+ return n;
162
+ }
163
+
164
+ const lockFacts = (file) => { const st = fs.statSync(file); return { content: fs.readFileSync(file, 'utf8'), mtimeMs: st.mtimeMs, ino: st.ino }; };
165
+ const sameFacts = (a, b) => a.content === b.content && a.mtimeMs === b.mtimeMs && a.ino === b.ino;
166
+ /** The pid ACTUALLY holding a lock: the `pid <n>` line a worker writes for itself, else the token's pid. */
167
+ const holderPid = (content) => {
168
+ const line = /^pid (\d+)$/m.exec(String(content));
169
+ return Number(line ? line[1] : String(content).trim().split('-')[0]);
170
+ };
171
+
172
+ /**
173
+ * Take the lock. Returns this holder's TOKEN (`<pid>-<time>-<random>`), or null.
174
+ * • Free → exclusive create.
175
+ * • Fresh (refreshed within REPLAY_LOCK_STALE_MS) → null.
176
+ * • Stale but its holder pid is ALIVE → null until REPLAY_LOCK_ABANDON_MS: a laptop asleep mid-step,
177
+ * or a long step, is not a dead worker, and taking over would put two workers on one job. The holder
178
+ * pid is the WORKER's own (it rewrites the lock on start), not the hook that spawned it and exited.
179
+ * • Otherwise taken over: the stale file is renamed aside and VERIFIED to be the very file judged
180
+ * stale (content, mtime, inode). If a successor's fresh lock was moved instead (it took over between
181
+ * our check and our rename), it is put back — never over a third lock — and we back off. One winner.
182
+ */
183
+ export function takeReplayLock(projectDir, now = Date.now(), { isAlive = pidAlive, beforeRename = null } = {}) {
184
+ const lock = lockPath(projectDir);
185
+ const token = `${process.pid}-${now}-${Math.random().toString(36).slice(2, 10)}`;
186
+ const create = () => { fs.writeFileSync(lock, `${token}\npid ${process.pid}\n`, { flag: 'wx', mode: 0o600 }); return token; };
187
+ try { return create(); } catch { /* held, or stale */ }
188
+ let seen;
189
+ try { seen = lockFacts(lock); } catch { try { return create(); } catch { return null; } }
190
+ const age = now - seen.mtimeMs;
191
+ if (age <= REPLAY_LOCK_STALE_MS) return null;
192
+ if (age <= REPLAY_LOCK_ABANDON_MS && isAlive(holderPid(seen.content))) return null;
193
+ beforeRename?.();
194
+ const aside = `${lock}.stale-${token}`;
195
+ try { fs.renameSync(lock, aside); } catch { return null; }
196
+ let moved = null;
197
+ try { moved = lockFacts(aside); } catch { /* vanished */ }
198
+ if (!moved || !sameFacts(moved, seen)) {
199
+ // Put the successor's lock back without ever overwriting a third holder's: a hard link where the
200
+ // filesystem has them, else an exclusive copy.
201
+ try { fs.linkSync(aside, lock); } catch {
202
+ try { fs.copyFileSync(aside, lock, fs.constants.COPYFILE_EXCL); } catch { /* a third holder exists; the successor sees it lost the lock and stops */ }
203
+ }
204
+ try { fs.rmSync(aside, { force: true }); } catch { /* best effort */ }
205
+ return null;
206
+ }
207
+ try { fs.rmSync(aside, { force: true }); } catch { /* best effort */ }
208
+ try { return create(); } catch { return null; }
209
+ }
210
+
211
+ /** Heartbeat: refresh the lock's mtime if (and only if) this holder still owns it. */
212
+ export function refreshReplayLock(projectDir, token) {
213
+ if (!token || readLock(projectDir) !== token) return false;
214
+ try { const t = new Date(); fs.utimesSync(lockPath(projectDir), t, t); return true; } catch { return false; }
215
+ }
216
+
217
+ /** A worker that inherited the lock records ITS OWN pid on it, keeping the owner token. */
218
+ export function adoptReplayLock(projectDir, token) {
219
+ if (!token || readLock(projectDir) !== token) return false;
220
+ try { fs.writeFileSync(lockPath(projectDir), `${token}\npid ${process.pid}\n`, { mode: 0o600 }); return true; } catch { return false; }
221
+ }
222
+
223
+ /** Release ONLY a lock this holder owns; a successor's lock is never deleted. */
224
+ export function releaseReplayLock(projectDir, token) {
225
+ if (!token || readLock(projectDir) !== token) return false;
226
+ try { fs.rmSync(lockPath(projectDir), { force: true }); return true; } catch { return false; }
227
+ }
228
+
229
+ /** Hand the lock (or take it, if free) to a detached worker. Returns whether one was started. Never throws. */
230
+ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn } = {}) {
231
+ const held = token || takeReplayLock(projectDir);
232
+ if (!held) return false;
233
+ try {
234
+ const child = spawnFn(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'session-snapshot-hook.mjs'), '--replay-outbox'], {
235
+ cwd: projectDir, detached: true, stdio: 'ignore', windowsHide: true,
236
+ env: { ...process.env, RUVNET_REPLAY_LOCK_TOKEN: held },
237
+ });
238
+ child.unref?.();
239
+ return true;
240
+ } catch {
241
+ releaseReplayLock(projectDir, held);
242
+ return false;
243
+ }
244
+ }
245
+
246
+ /**
247
+ * The detached worker's body, holding the lock `token`: record its own pid on the lock, return orphaned
248
+ * claims to the queue, replay the outbox, then run every queued capture IN ORDER — each CLAIMED by
249
+ * atomic rename first, so no other worker can run it too, and each re-entering the boundary as
250
+ * `ordered`, so it replays before it produces. Ownership is re-checked before every step and right
251
+ * after each claim; a worker that lost the lock puts an unstarted claim back and stops. A finished
252
+ * claim is the claimer's own and is deleted. Releases only its own lock, then re-checks for captures
253
+ * queued while it held it.
254
+ */
255
+ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
256
+ makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null,
257
+ captureNormalized = captureNormalizedTransition, onCaptured = null } = {}) {
258
+ const deadlineAt = now() + budgetMs;
259
+ if (developmentHooksSuspended(projectDir)) return 0;
260
+ const brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
261
+ try {
262
+ const consent = resolveTurnDb({ projectDir, brainHome });
263
+ if (consent.skipped && !consent.skipped.startsWith('no project memory db')) return 0;
264
+ } catch { return 0; }
265
+ let held = token || takeReplayLock(projectDir);
266
+ let replayed = 0;
267
+ for (let round = 0; held && round < 8 && now() < deadlineAt; round += 1) {
268
+ try {
269
+ if (!adoptReplayLock(projectDir, held)) return replayed;
270
+ reclaimOrphans(projectDir);
271
+ const resolution = resolveProjectStore({ projectDir });
272
+ const store = makeStoreFactory(deadlineAt)({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath });
273
+ for (const snapshot of store.outbox.pendingSnapshots()) {
274
+ if (now() >= deadlineAt) return replayed;
275
+ if (!refreshReplayLock(projectDir, held)) return replayed;
276
+ store.outbox.markCommitted(store.appendExact(snapshot));
277
+ replayed += 1;
278
+ }
279
+ for (const file of queuedCaptures(projectDir)) {
280
+ if (now() >= deadlineAt) return replayed;
281
+ if (!refreshReplayLock(projectDir, held)) return replayed;
282
+ const claimed = claimQueued(file);
283
+ if (!claimed) continue;
284
+ onClaim?.(claimed);
285
+ if (!refreshReplayLock(projectDir, held)) {
286
+ returnClaim(claimed);
287
+ return replayed;
288
+ }
289
+ let job = null;
290
+ try { job = JSON.parse(fs.readFileSync(claimed, 'utf8')); } catch { /* torn: dropped below */ }
291
+ let committed = false;
292
+ try {
293
+ if (job) {
294
+ // Pre-upgrade raw queues lack origin identity and cannot be truthfully reconstructed.
295
+ if (runCapture === runSessionSnapshotHook && (!job.originProjectDir || (!job.payload?.projectProgression && !job.payload?.normalizedTransition))) { returnClaim(claimed); return replayed; }
296
+ const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome });
297
+ if (developmentHooksSuspended(job.originProjectDir || projectDir)
298
+ || (consent.skipped && !consent.skipped.startsWith('no project memory db'))) { returnClaim(claimed); return replayed; }
299
+ const options = { rawInput: JSON.stringify(job.payload), host: job.host,
300
+ budgetMs: Math.max(0, deadlineAt - now()), makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
301
+ captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
302
+ captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) };
303
+ const result = job.payload?.normalizedTransition ? captureNormalized(job, options)
304
+ : runCapture(job.originProjectDir || projectDir, job.event, options);
305
+ committed = result?.progressionCaptured === true && Boolean(result.receipt);
306
+ if (committed) onCaptured?.(result);
307
+ }
308
+ } catch { /* retain the queue until an exact-readback receipt exists */ }
309
+ if (!committed) { returnClaim(claimed); return replayed; }
310
+ try { fs.rmSync(claimed, { force: true }); } catch { /* best effort */ }
311
+ }
312
+ } catch { /* the debt stays durable; the next boundary hands it on again */ } finally {
313
+ releaseReplayLock(projectDir, held);
314
+ }
315
+ held = now() < deadlineAt && queuedWork(projectDir) ? takeReplayLock(projectDir) : null;
316
+ }
317
+ if (held) releaseReplayLock(projectDir, held);
318
+ return replayed;
319
+ }
320
+
321
+
322
+ /** Synchronous bounded startup drain. Pending debt must downgrade restore, never disappear. */
323
+ export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } = {}) {
324
+ const startedAt = Date.now();
325
+ const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: Math.max(1, Math.min(300, budgetMs)) });
326
+ const root = resolution.projectRoot;
327
+ const replayed = runOutboxReplay({ ...options, projectDir: root, budgetMs: Math.max(0, budgetMs - (Date.now() - startedAt)) });
328
+ let outboxPending = 0;
329
+ try { outboxPending = new ProgressionOutbox({ projectRoot: root }).pendingSnapshots().length; } catch { return { state: 'degraded', replayed, pending: null }; }
330
+ const pending = queuedWork(root) + outboxPending;
331
+ return { state: pending ? 'pending' : 'settled', replayed, pending };
332
+ }
333
+
@@ -304,7 +304,7 @@ function mergeHeads(heads) {
304
304
  ...heads[0].completeProjectState,
305
305
  sourceIdentity: heads[0].sourceIdentity,
306
306
  journalHeads: [heads[0].eventKey],
307
- resumeConflicts: [],
307
+ resumeConflicts: heads[0].completeProjectState.resumeConflicts,
308
308
  });
309
309
  }
310
310
 
@@ -63,7 +63,7 @@ export function readProgressionAdapterVersion() {
63
63
  return requireString(parsed?.version, 'progression adapter version');
64
64
  }
65
65
 
66
- function normalizeSourceIdentity(value, checkoutRoot) {
66
+ function normalizeSourceIdentity(value, checkoutRoot, projectDir) {
67
67
  requireRecord(value, 'sourceIdentity');
68
68
  const checkoutPath = requireString(value.checkoutPath, 'sourceIdentity.checkoutPath');
69
69
  let canonicalCheckout;
@@ -73,7 +73,7 @@ function normalizeSourceIdentity(value, checkoutRoot) {
73
73
  if (canonicalCheckout !== checkoutRoot) {
74
74
  throw new Error('source identity checkout path does not match the active checkout path');
75
75
  }
76
- return { ...value, checkoutPath: canonicalCheckout };
76
+ return { ...value, checkoutPath: canonicalCheckout, capturePath: fs.realpathSync.native(projectDir) };
77
77
  }
78
78
 
79
79
  const OBSERVATION_TEXT_LIMIT = 4_096;
@@ -189,7 +189,7 @@ export function captureProjectTransition({
189
189
 
190
190
  const sourceIdentity = normalizeSourceIdentity(
191
191
  aliased(progression, 'sourceIdentity', 'source_identity'),
192
- store.resolution.checkoutRoot,
192
+ store.resolution.checkoutRoot, projectDir,
193
193
  );
194
194
  const snapshot = createProgressionSnapshot({
195
195
  projectIdentity: store.resolution.projectIdentity,
@@ -29,6 +29,7 @@
29
29
  * within those bounds.
30
30
  */
31
31
  import crypto from 'node:crypto';
32
+ import fs from 'node:fs';
32
33
  import path from 'node:path';
33
34
  import { digestCanonical, fieldAuthorityAllows, redactProgression, restoreProjectProgression } from './project-progression-contract.mjs';
34
35
  import { readOwnerNote, readSourceIdentity, readTranscriptReference, readWorkLedger } from './project-progression-sources.mjs';
@@ -94,10 +95,14 @@ function committedHeads(canonicalAgentDbPath, projectIdentity) {
94
95
  }
95
96
  return snapshots;
96
97
  });
97
- if (!result.ok) return { heads: [], readPath: `unavailable (${result.reason})` };
98
+ if (!result.ok) {
99
+ if (fs.existsSync(canonicalAgentDbPath)) throw new Error(`prior progression read unavailable: ${result.reason}`);
100
+ return { heads: [], state: null, readPath: `unavailable (${result.reason})` };
101
+ }
98
102
  const restored = restoreProjectProgression(result.value, { expectedProjectIdentity: projectIdentity });
103
+ if (result.value.length && !restored.ok) throw new Error('prior progression has no verifiable coherent state');
99
104
  const byKey = new Map(result.value.map((snapshot) => [snapshot?.eventKey, snapshot]));
100
- return { heads: restored.heads.map((key) => byKey.get(key)).filter(Boolean), readPath: 'node:sqlite' };
105
+ return { heads: restored.heads.map((key) => byKey.get(key)).filter(Boolean), state: restored.state, readPath: 'node:sqlite' };
101
106
  }
102
107
 
103
108
  function uniqueStrings(values) {
@@ -115,6 +120,7 @@ function uniqueStrings(values) {
115
120
  */
116
121
  export function buildProjectProgression({
117
122
  resolution,
123
+ projectDir = resolution?.checkoutRoot,
118
124
  payload = {},
119
125
  host = 'claude',
120
126
  env = process.env,
@@ -123,20 +129,21 @@ export function buildProjectProgression({
123
129
  } = {}) {
124
130
  if (!resolution || typeof resolution !== 'object') throw new TypeError('resolution must be a project store resolution');
125
131
  const source = readSourceIdentity({ checkoutRoot: resolution.checkoutRoot, kind: resolution.kind });
132
+ source.identity.capturePath = fs.realpathSync.native(projectDir);
126
133
  const ledger = readWorkLedger({ projectId: resolution.projectIdentity.id, env });
127
134
  const note = readOwnerNote(() => ownerNoteRows(resolution.canonicalAgentDbPath, path.basename(resolution.projectRoot)));
128
135
  const transcript = readTranscriptReference(payload.transcript_path, { host });
129
- const { heads } = committedHeads(resolution.canonicalAgentDbPath, resolution.projectIdentity);
136
+ const { heads, state: priorState } = committedHeads(resolution.canonicalAgentDbPath, resolution.projectIdentity);
130
137
 
131
138
  const priorSequence = heads.reduce((highest, head) => Math.max(highest, head.sequence ?? 0), 0);
132
- const priorState = heads.length === 1 ? heads[0].completeProjectState : null;
133
139
 
134
- const provenance = {};
140
+ const provenance = { ...(priorState?.provenance ?? {}) };
135
141
  const record = (field, sourceName) => {
136
142
  if (sourceName !== 'none' && !fieldAuthorityAllows(field === 'sourceIdentity' ? 'sourceIdentity' : field, sourceName)) {
137
143
  throw new Error(`source ${sourceName} is not authoritative for progression field ${field}`);
138
144
  }
139
- provenance[field] = marker(sourceName);
145
+ provenance[field] = sourceName === 'prior-head' && priorState?.provenance?.[field]
146
+ ? priorState.provenance[field] : marker(sourceName);
140
147
  };
141
148
 
142
149
  // GOAL — the ledger's oldest open item is what the user actually committed to. A coherent prior
@@ -144,7 +151,7 @@ export function buildProjectProgression({
144
151
  // no durable goal exists. Neither contextual source is an instruction.
145
152
  let currentGoal = ledger.open[0] ?? null;
146
153
  if (currentGoal) record('currentGoal', 'ledger');
147
- else if (typeof priorState?.currentGoal === 'string' && priorState.currentGoal) {
154
+ else if (priorState) {
148
155
  currentGoal = priorState.currentGoal;
149
156
  record('currentGoal', 'prior-head');
150
157
  } else if (note?.excerpt) {
@@ -159,12 +166,12 @@ export function buildProjectProgression({
159
166
  // Transcript text is evidence/context, never an invented structured action.
160
167
  let nextAction = ledger.open[1] ?? ledger.open[0] ?? null;
161
168
  if (nextAction) record('nextAction', 'ledger');
162
- else if (typeof priorState?.nextAction === 'string' && priorState.nextAction) {
169
+ else if (priorState) {
163
170
  nextAction = priorState.nextAction;
164
171
  record('nextAction', 'prior-head');
165
172
  } else record('nextAction', 'none');
166
173
 
167
- const decisions = [];
174
+ const decisions = [...(priorState?.decisions ?? [])];
168
175
  if (ledger.objective && typeof ledger.objective.text === 'string' && ledger.objective.text) {
169
176
  decisions.push({ text: ledger.objective.text, state: ledger.objective.state ?? null, source: 'ledger' });
170
177
  record('decisions', 'ledger');
@@ -173,31 +180,49 @@ export function buildProjectProgression({
173
180
  record('decisions', 'owner-note');
174
181
  } else record('decisions', 'prior-head');
175
182
 
176
- record('plan', ledger.present ? 'ledger' : 'prior-head');
177
- record('completed', ledger.present ? 'ledger' : 'prior-head');
178
- record('inProgress', ledger.present ? 'ledger' : 'prior-head');
183
+ // A partial ledger speaks only for its own matching items; absence is not deletion.
184
+ const priorPlan = priorState?.plan ?? [];
185
+ const doneIds = new Set(ledger.done.map((text) => text.slice(0, 64)));
186
+ const openIds = new Set(ledger.open.map((text) => text.slice(0, 64)));
187
+ const ownedDone = new Set(priorPlan.filter((item) => item?.source === 'ledger' && doneIds.has(item.id)).map((item) => item.id));
188
+ const plan = priorPlan.map((item) => item?.source !== 'ledger' ? item
189
+ : doneIds.has(item.id) ? { ...item, status: 'done' }
190
+ : openIds.has(item.id) ? { ...item, status: 'open' } : item);
191
+ for (const text of ledger.open) {
192
+ const id = text.slice(0, 64);
193
+ if (!plan.some((item) => item?.source === 'ledger' && item.id === id)) plan.push({ id, status: 'open', source: 'ledger' });
194
+ }
195
+ const completed = [...(priorState?.completed ?? [])];
196
+ for (const text of ledger.done) if (!completed.includes(text)) completed.push(text);
197
+ const inProgress = uniqueStrings([...(priorState?.inProgress ?? []).filter((text) => !ownedDone.has(text.slice(0, 64))), ...ledger.open]);
198
+ for (const field of ['plan', 'completed', 'inProgress']) {
199
+ record(field, ledger.open.length || ledger.done.length ? 'ledger' : 'prior-head');
200
+ if (priorState && (ledger.open.length || ledger.done.length)) provenance[field] = { source: 'prior-head', authoritative: priorState.provenance?.[field]?.authoritative ?? true, sources: ['prior-head', 'ledger'] };
201
+ }
179
202
  record('changedFiles', 'git');
180
203
  record('sourceIdentity', 'git');
181
204
 
205
+ // Reducer annotations describe the prior heads, not application state in the new snapshot.
206
+ const { sourceIdentity: priorSource, journalHeads: priorHeads, ...carriedState } = priorState ?? {};
182
207
  const completeProjectState = {
208
+ ...carriedState,
183
209
  currentGoal,
184
210
  nextAction,
185
211
  acceptanceContract: priorState?.acceptanceContract ?? null,
186
- activeProcess: 'ProjectContinuity',
187
- activeStep: trigger ?? 'unknown',
188
- plan: ledger.open.map((text) => ({ id: text.slice(0, 64), status: 'open', source: 'ledger' })),
189
- completed: uniqueStrings(ledger.done),
190
- inProgress: uniqueStrings(ledger.open),
191
- blockers: [],
192
- failures: [],
193
- decisions,
194
- // The three digests already identify the tree exactly; enumerating paths here would duplicate
195
- // that and, for an untracked file, would put a filename we were never asked to keep into a row.
196
- changedFiles: [],
197
- commands: [],
198
- proofArtifacts: [],
199
- untested: [],
200
- resumeConflicts: [],
212
+ activeProcess: priorState ? priorState.activeProcess : 'ProjectContinuity',
213
+ activeStep: priorState ? priorState.activeStep : trigger ?? 'unknown',
214
+ plan,
215
+ completed,
216
+ inProgress,
217
+ blockers: priorState?.blockers ?? [],
218
+ failures: priorState?.failures ?? [],
219
+ decisions: [...new Map(decisions.map((decision) => [digestCanonical(decision), decision])).values()],
220
+ // Retain previously recorded paths; the new tree's digests never invent additional filenames.
221
+ changedFiles: priorState?.changedFiles ?? [],
222
+ commands: priorState?.commands ?? [],
223
+ proofArtifacts: priorState?.proofArtifacts ?? [],
224
+ untested: priorState?.untested ?? [],
225
+ resumeConflicts: priorState?.resumeConflicts ?? [],
201
226
  provenance,
202
227
  evidence: {
203
228
  workLedger: { file: ledger.file, present: ledger.present, open: ledger.open.length, done: ledger.done.length },
@@ -236,7 +261,7 @@ export function buildProjectProgression({
236
261
  source: projectProgression.sourceIdentity,
237
262
  });
238
263
  const priorMeaning = heads.length === 1 ? digestCanonical({
239
- state: { ...priorState, activeStep: null, evidence: null },
264
+ state: { ...carriedState, activeStep: null, evidence: null },
240
265
  source: heads[0].sourceIdentity,
241
266
  }) : null;
242
267
 
@@ -1,9 +1,12 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import os from 'node:os';
3
4
  import { spawnSync } from 'node:child_process';
4
5
  import { ProjectProgressionStore } from './project-progression-store.mjs';
5
6
  import { resolveProjectStore } from './project-store-resolver.mjs';
6
7
  import { withProgressionReader } from './project-progression-reader.mjs';
8
+ import { drainCaptureQueue, queuedWork } from './session-snapshot-hook.mjs';
9
+ import { replayTurnQueue } from './turn-outcome-capture.mjs';
7
10
  import { STAGE_BUDGETS_MS } from './session-start-budget.mjs';
8
11
 
9
12
  const PROGRESSION_NAMESPACE = 'project-progression';
@@ -219,20 +222,57 @@ export function restoreProgressionForSession({
219
222
  };
220
223
  const makeStore = storeFactory ?? ((options) => new ProjectProgressionStore({
221
224
  ...options,
225
+ env,
222
226
  runner: boundedRunner,
223
227
  }));
224
228
  store = makeStore({
225
229
  projectDir,
226
230
  requestedStorePath: resolution.canonicalAgentDbPath,
227
231
  });
232
+ const home = env.HOME || env.USERPROFILE || os.homedir();
233
+ const turns = replayTurnQueue({ projectDir, env, home, synchronous: true, runner: boundedRunner, deadlineMs: deadlineAt });
234
+ if (turns.pending > 0 || turns.failed > 0) {
235
+ const failed = miss('outbox-replay');
236
+ return { ...failed, pendingTurns: turns.pending,
237
+ context: `${failed.context} Durable turn recording remains pending; no older checkpoint was injected.` };
238
+ }
239
+ const suspended = ['persisted turn capture opt-out', 'turn capture policy unreadable or invalid'].includes(turns.skipped);
240
+ if (turns.skipped && !suspended && !['RUVNET_TURN_CAPTURE=off',
241
+ 'no project memory db; persisted opt-in required'].includes(turns.skipped)) return miss('outbox-replay');
242
+ if (suspended && initializing) return {
243
+ status: 'unavailable', reason: 'capture-suspended', severity: 'info',
244
+ context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nCapture consent suspends replay; no canonical store was created.',
245
+ };
246
+ if (suspended && (store.pendingReplayCount?.() > 0 || queuedWork(resolution.projectRoot) > 0)) {
247
+ const failed = miss('outbox-replay');
248
+ return { ...failed, context: `${failed.context} Replay is suspended by capture consent; no older checkpoint was injected.` };
249
+ }
228
250
  if (initializing) {
229
251
  fs.mkdirSync(path.dirname(resolution.canonicalAgentDbPath), { recursive: true, mode: 0o700 });
230
252
  initializeCanonicalStore(store, resolution);
231
253
  }
232
- // COMMITTED ROWS ONLY (ADR-073 §5). Replay is a write, a write is a `ruflo memory store`
233
- // process, and one of those costs more than this entire boundary's budget. Pending durable
234
- // snapshots are REPORTED below and replayed at the next capture boundary or by /checkpoint.
235
- const restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: false, projectToBound: true });
254
+ if (!suspended) {
255
+ const queue = drainCaptureQueue({ projectDir, budgetMs: Math.max(0, deadlineAt - Date.now()),
256
+ makeStoreFactory: () => () => store });
257
+ if (queue.state !== 'settled') {
258
+ const failed = miss('outbox-replay');
259
+ return { ...failed, pendingReplay: queue.pending,
260
+ context: `${failed.context} Durable capture work remains pending; no older checkpoint was injected.` };
261
+ }
262
+ }
263
+ // A pending snapshot is newer observable work. Never label an older committed head restored
264
+ // while that work remains unverified. Replay uses the same managed exact-readback store path.
265
+ let restored;
266
+ try {
267
+ restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: !suspended, projectToBound: true });
268
+ } catch (error) {
269
+ if (store.pendingReplayCount?.() > 0) {
270
+ const failed = miss('outbox-replay');
271
+ return { ...failed, pendingReplay: store.pendingReplayCount(),
272
+ context: `${failed.context} ${store.pendingReplayCount()} durable snapshot(s) remain pending; no older checkpoint was injected.` };
273
+ }
274
+ throw error;
275
+ }
236
276
  if (!validResume(restored)) return miss('malformed-store');
237
277
  const summaryNotice = restored.projected
238
278
  ? '\n[BOUNDED CONTINUITY SUMMARY] The merged current goal and next action are preserved exactly; '
@@ -9,6 +9,7 @@ import {
9
9
  restoreProjectProgression,
10
10
  validateProgressionSnapshot,
11
11
  } from './project-progression-contract.mjs';
12
+ import { resolveTurnDb } from './turn-outcome-capture.mjs';
12
13
  import { resolveProjectStore } from './project-store-resolver.mjs';
13
14
  import { withProgressionReader } from './project-progression-reader.mjs';
14
15
  import { resolveRuflo, rufloInvocation, RUFLO_MISSING } from './ruflo-bin.mjs';
@@ -323,6 +324,8 @@ export class ProjectProgressionStore {
323
324
  constructor({
324
325
  projectDir,
325
326
  requestedStorePath,
327
+ env = process.env,
328
+ brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain'),
326
329
  rufloBinary = resolveRuflo(),
327
330
  runner = defaultRunner,
328
331
  clock = () => new Date().toISOString(),
@@ -336,6 +339,7 @@ export class ProjectProgressionStore {
336
339
  // Best effort: a cleanup that cannot run must never stop a capture or a restore.
337
340
  try { this.legacyDebris = cleanLegacyRufloDebris(path.dirname(this.resolution.canonicalAgentDbPath)); }
338
341
  catch (error) { this.legacyDebris = { removed: [], refused: [{ path: null, reason: error.message }] }; }
342
+ this.brainHome = brainHome;
339
343
  this.rufloBinary = rufloBinary;
340
344
  this.runner = runner;
341
345
  this.clock = clock;
@@ -374,8 +378,17 @@ export class ProjectProgressionStore {
374
378
  if (!verdict.ok) throw new Error(`invalid progression snapshot: ${verdict.errors.join(', ')}`);
375
379
  }
376
380
 
381
+ requireCaptureConsent(snapshot) {
382
+ const capturePath = snapshot.sourceIdentity.capturePath;
383
+ const target = resolveTurnDb({ projectDir: capturePath ?? snapshot.sourceIdentity.checkoutPath,
384
+ brainHome: this.brainHome, requestedStorePath: this.resolution.canonicalAgentDbPath,
385
+ unknownOriginalPath: !capturePath });
386
+ if (target.skipped) throw new Error(`progression capture suspended: ${target.skipped}`);
387
+ }
388
+
377
389
  appendExact(snapshot, { onPhase = () => {} } = {}) {
378
390
  this.validateSnapshot(snapshot);
391
+ this.requireCaptureConsent(snapshot);
379
392
  const stored = this.run([
380
393
  'memory', 'store', '--key', snapshot.eventKey, '--value', JSON.stringify(snapshot),
381
394
  '--namespace', PROGRESSION_NAMESPACE, '--no-upsert', '--provenance', 'system_observation',
@@ -429,6 +442,7 @@ export class ProjectProgressionStore {
429
442
 
430
443
  capture(snapshot, { onPhase = () => {} } = {}) {
431
444
  this.validateSnapshot(snapshot);
445
+ this.requireCaptureConsent(snapshot);
432
446
  this.outbox.appendSnapshot(snapshot);
433
447
  onPhase('outbox-fsynced');
434
448
  const receipt = this.appendExact(snapshot, { onPhase });