ruvnet-brain 4.5.2 → 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.
Files changed (35) hide show
  1. package/README.md +2 -2
  2. package/bin/install.mjs +9 -4
  3. package/kb/lifecycle-evidence-retention.mjs +56 -2
  4. package/kb/verify-citation.mjs +48 -27
  5. package/package.json +1 -1
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/hooks/codex-hooks.json +40 -3
  9. package/plugin/hooks/hook-contracts.json +218 -19
  10. package/plugin/hooks/hooks.json +51 -2
  11. package/plugin/scripts/agentdb-recall.mjs +297 -0
  12. package/plugin/scripts/continuity-hook-policy.mjs +9 -0
  13. package/plugin/scripts/continuity-journal.mjs +51 -52
  14. package/plugin/scripts/ground-ruvnet.sh +17 -3
  15. package/plugin/scripts/hook-shim.mjs +4 -30
  16. package/plugin/scripts/project-capture-queue.mjs +333 -0
  17. package/plugin/scripts/project-progression-contract.mjs +1 -1
  18. package/plugin/scripts/project-progression-hook.mjs +3 -3
  19. package/plugin/scripts/project-progression-outbox.mjs +19 -4
  20. package/plugin/scripts/project-progression-producer.mjs +53 -28
  21. package/plugin/scripts/project-progression-session-start.mjs +44 -4
  22. package/plugin/scripts/project-progression-store.mjs +14 -0
  23. package/plugin/scripts/project-store-resolver.mjs +16 -7
  24. package/plugin/scripts/project-transition-hook.mjs +204 -0
  25. package/plugin/scripts/session-snapshot-hook.mjs +46 -274
  26. package/plugin/scripts/session-start-budget.mjs +2 -2
  27. package/plugin/scripts/session-start-core.mjs +5 -0
  28. package/plugin/scripts/turn-outcome-capture.mjs +246 -70
  29. package/plugin/scripts/turn-transport-journal.mjs +106 -0
  30. package/scripts/learning-replay-cli.mjs +15 -0
  31. package/scripts/learning-replay.mjs +1 -1
  32. package/scripts/nightly-wrapper.sh +5 -5
  33. package/scripts/release-qualification-contract.mjs +87 -0
  34. package/scripts/release-qualification.mjs +10 -1
  35. package/scripts/wired-check.mjs +1 -0
@@ -1,6 +1,9 @@
1
+ import { DETACHED_REPLAY_BUDGET_MS, REPLAY_LOCK_STALE_MS, REPLAY_LOCK_ABANDON_MS, pidAlive, queueCapture, queuedCaptures, queuedWork, processStart, reclaimOrphans, takeReplayLock, refreshReplayLock, adoptReplayLock, releaseReplayLock, replayOutboxDetached, runOutboxReplay, drainCaptureQueue } from './project-capture-queue.mjs';
2
+ export { DETACHED_REPLAY_BUDGET_MS, REPLAY_LOCK_STALE_MS, REPLAY_LOCK_ABANDON_MS, pidAlive, queueCapture, queuedCaptures, queuedWork, processStart, reclaimOrphans, takeReplayLock, refreshReplayLock, adoptReplayLock, releaseReplayLock, replayOutboxDetached, runOutboxReplay, drainCaptureQueue } from './project-capture-queue.mjs';
1
3
  import fs from 'node:fs';
2
4
  import path from 'node:path';
3
- import { spawn, spawnSync } from 'node:child_process';
5
+ import os from 'node:os';
6
+ import { spawnSync } from 'node:child_process';
4
7
  import { fileURLToPath } from 'node:url';
5
8
  import { ProgressionOutbox } from './project-progression-outbox.mjs';
6
9
  import {
@@ -12,7 +15,7 @@ import { projectDirectory } from './project-identity.mjs';
12
15
  import { buildProjectProgression } from './project-progression-producer.mjs';
13
16
  import { ProjectProgressionStore } from './project-progression-store.mjs';
14
17
  import { resolveProjectStore } from './project-store-resolver.mjs';
15
- import { captureTurnOutcome } from './turn-outcome-capture.mjs';
18
+ import { resolveTurnDb, captureTurnOutcome } from './turn-outcome-capture.mjs';
16
19
  import { captureContinuityEvents, stopNotice } from './continuity-journal.mjs';
17
20
 
18
21
  /**
@@ -79,7 +82,7 @@ export function writeSessionSnapshot(projectDir, event) {
79
82
  * A deadline-bounded `ruflo` runner. The store's own 120s per-call timeout is right for a deliberate
80
83
  * CLI invocation and far too generous for a lifecycle hook, so the remaining budget caps every call.
81
84
  */
82
- function boundedStoreFactory(deadlineAt) {
85
+ export function boundedStoreFactory(deadlineAt) {
83
86
  return (options) => new ProjectProgressionStore({
84
87
  ...options,
85
88
  runner: (binary, args, runOptions) => {
@@ -113,6 +116,7 @@ function boundedStoreFactory(deadlineAt) {
113
116
  */
114
117
  export function runSessionSnapshotHook(projectDir, event, {
115
118
  rawInput = '',
119
+ env = process.env,
116
120
  host = process.env.RUVNET_HOOK_HOST || 'claude',
117
121
  captureProgression = captureProjectTransition,
118
122
  produce = buildProjectProgression,
@@ -125,22 +129,32 @@ export function runSessionSnapshotHook(projectDir, event, {
125
129
  ordered = null,
126
130
  captureEvents = captureContinuityEvents,
127
131
  } = {}) {
132
+ const suspended = (reason) => ({ metadataWritten: false, progressionCaptured: false, receipt: null, skipped: reason,
133
+ turn: { event, recorded: false, skipped: reason }, continuity: { event, recorded: 0, launched: false, skipped: reason } });
134
+ // Consent is checked before metadata, transcript inspection or any durable capture queue.
135
+ try {
136
+ const consent = resolveTurnDb({ projectDir, brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain'),
137
+ gitTimeoutMs: Math.max(1, Math.min(500, budgetMs)) });
138
+ if (consent.skipped) return suspended(consent.skipped);
139
+ } catch (error) {
140
+ return suspended(`capture consent unavailable: ${error.message}`);
141
+ }
128
142
  // The detached worker re-runs a QUEUED boundary; its session receipt was already written then.
129
143
  const metadataWritten = writeMetadata ? writeSessionSnapshot(projectDir, event) : false;
130
144
  let payload;
131
145
  try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
132
- // TURN OUTCOMES FIRST, and independent of `.swarm`: every turn in every repository is recorded
133
- // (a project without `.swarm` records to the machine-wide db outside it — turn-outcome-capture.mjs).
146
+ // TURN OUTCOMES FIRST: consent and canonical store availability govern whether a turn is queued
147
+ // (a project without a store requires persisted opt-in — turn-outcome-capture.mjs).
134
148
  // It only reads and spawns a detached writer, so it costs the progression budget below nothing.
135
149
  let turn;
136
- try { turn = captureTurn({ projectDir, event, payload, host }); } catch (error) {
150
+ try { turn = captureTurn({ projectDir, event, payload, host, env }); } catch (error) {
137
151
  turn = { recorded: false, skipped: `turn capture failed: ${error.message}` };
138
152
  }
139
153
  // MATERIAL EVENTS (continuity-journal.mjs): commits, releases, gates, findings, decisions, lessons —
140
154
  // journalled to the durable outbox with one fsync and committed by a detached drainer. Like turn
141
155
  // capture it is independent of the progression lock below and costs this boundary only a read.
142
156
  let continuity;
143
- try { continuity = captureEvents({ projectDir, event, payload, host }); } catch (error) {
157
+ try { continuity = captureEvents({ projectDir, event, payload, host, env }); } catch (error) {
144
158
  continuity = { recorded: 0, skipped: `continuity capture failed: ${error.message}` };
145
159
  }
146
160
  const idle = { metadataWritten, progressionCaptured: false, receipt: null, turn, continuity };
@@ -149,7 +163,7 @@ export function runSessionSnapshotHook(projectDir, event, {
149
163
  if (payload.hook_event_name !== event) {
150
164
  throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
151
165
  }
152
- const result = captureProgression({ host, payload, projectDir });
166
+ const result = captureProgression({ host, payload, projectDir, storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
153
167
  return { ...idle, progressionCaptured: true, receipt: result.receipt };
154
168
  }
155
169
 
@@ -187,7 +201,10 @@ export function runSessionSnapshotHook(projectDir, event, {
187
201
  // `ordered` is the worker's own re-entry: it already holds the lock and is draining in order.
188
202
  let token = ordered;
189
203
  const handOff = (why) => {
190
- const queued = queueCapture({ projectDir: root, event, host, payload });
204
+ let frozen;
205
+ try { frozen = produce({ resolution, projectDir, payload, host, trigger: event }); } catch { frozen = null; }
206
+ const queued = frozen?.projectProgression ? queueCapture({ projectDir: root, originProjectDir: projectDir, env, event, host,
207
+ payload: { session_id: payload.session_id, hook_event_name: event, projectProgression: frozen.projectProgression } }) : null;
191
208
  const handed = queued ? spawnReplay({ projectDir: root, token }) : false;
192
209
  if (!handed && token && token !== ordered) releaseReplayLock(root, token);
193
210
  return { ...idle, replayed: 0, progressionCaptured: false, deferredToReplayer: Boolean(queued),
@@ -208,7 +225,7 @@ export function runSessionSnapshotHook(projectDir, event, {
208
225
  let handedLock = false;
209
226
  try {
210
227
  const deadlineAt = now() + budgetMs;
211
- const storeFactory = makeStoreFactory(deadlineAt);
228
+ const storeFactory = (options) => makeStoreFactory(deadlineAt)({ ...options, env });
212
229
  let replayed = 0;
213
230
  if (budgetMs >= REPLAY_MIN_BUDGET_MS) {
214
231
  try {
@@ -226,7 +243,7 @@ export function runSessionSnapshotHook(projectDir, event, {
226
243
 
227
244
  let produced;
228
245
  try {
229
- produced = produce({ resolution, payload, host, trigger: event });
246
+ produced = produce({ resolution, projectDir, payload, host, trigger: event });
230
247
  } catch (error) {
231
248
  return { ...idle, replayed, skipped: `producer failed: ${error.message}` };
232
249
  }
@@ -271,276 +288,31 @@ export function runSessionSnapshotHook(projectDir, event, {
271
288
  }
272
289
  }
273
290
 
274
- /**
275
- * How long the detached worker may spend per step. The lock is refreshed between steps and goes stale
276
- * after REPLAY_LOCK_STALE_MS, which is more than twice a step, so a live worker never looks dead.
277
- */
278
- export const DETACHED_REPLAY_BUDGET_MS = 45_000;
279
- export const REPLAY_LOCK_STALE_MS = 120_000;
280
- const REPLAY_LOCK = '.progression-replay.lock';
281
- const QUEUE_PREFIX = '.progression-capture-queue-';
282
- const lockPath = (projectDir) => path.join(projectDir, '.swarm', REPLAY_LOCK);
283
- // The lock's FIRST line is the owner token; a second `pid <n>` line names the process holding it.
284
- const readLock = (projectDir) => { try { return fs.readFileSync(lockPath(projectDir), 'utf8').split('\n')[0].trim(); } catch { return null; } };
285
- const CLAIM_PREFIX = '.progression-capture-claimed-';
286
- /** After this long a stale lock is taken over even if its holder pid looks alive (pid reuse, a wedged process). */
287
- export const REPLAY_LOCK_ABANDON_MS = 30 * 60_000;
288
-
289
- /** Is a process with this pid alive? EPERM means alive but not ours. Never throws. */
290
- export function pidAlive(pid) {
291
- if (!Number.isSafeInteger(pid) || pid <= 0) return false;
292
- try { process.kill(pid, 0); return true; } catch (error) { return error?.code === 'EPERM'; }
293
- }
294
- const seqOf = (name) => Number((/(\d{12})\.json$/.exec(name) || [])[1] ?? 0);
295
- const swarmEntries = (projectDir) => { try { return fs.readdirSync(path.join(projectDir, '.swarm')); } catch { return []; } };
296
- // 4.4.0 named queue files by wall clock: `<prefix><15-digit ms>-<hrtime>-<pid>-<n>.json`. Open 4.4.0
297
- // sessions keep queuing in that format after the update, so the two formats coexist for a while.
298
- const LEGACY_QUEUE = /^\d{15}-/;
299
- 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>-
300
- const mtimeOf = (projectDir, name) => { try { return fs.statSync(path.join(projectDir, '.swarm', name)).mtimeMs; } catch { return Infinity; } };
301
-
302
- /**
303
- * Queue one boundary's capture for the worker (0600, inside the project's own .swarm). ORDER IS THE
304
- * ORDER OF EXCLUSIVE CREATION: the name is the next sequence number after every queued or claimed one,
305
- * created with O_EXCL and retried on collision — never a clock, which can step backwards or wrap.
306
- */
307
- export function queueCapture({ projectDir, event, host, payload }) {
308
- const body = JSON.stringify({ event, host, payload });
309
- for (let attempt = 0; attempt < 64; attempt += 1) {
310
- const seq = Math.max(0, ...swarmEntries(projectDir).filter((n) => n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)).map(seqOf)) + 1;
311
- const file = path.join(projectDir, '.swarm', `${QUEUE_PREFIX}${String(seq).padStart(12, '0')}.json`);
312
- try { fs.writeFileSync(file, body, { flag: 'wx', mode: 0o600 }); return file; } catch (error) {
313
- if (error?.code !== 'EEXIST') return null;
314
- }
315
- }
316
- return null;
317
- }
318
-
319
- /**
320
- * Unclaimed queued captures, in queue order. While any 4.4.0 (timestamp-named) entry is present the
321
- * order is creation time (mtime, then name) — by NAME every 4.4.1 sequence file would sort before every
322
- * 4.4.0 one, replaying newer captures before older ones across the upgrade window. Once the legacy
323
- * entries are gone the order is the sequence alone, independent of any clock.
324
- */
325
- export function queuedCaptures(projectDir) {
326
- const all = swarmEntries(projectDir).filter((n) => (n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)) && n.endsWith('.json'));
327
- const queued = all.filter((n) => n.startsWith(QUEUE_PREFIX));
328
- const mixed = all.some((n) => LEGACY_QUEUE.test(queueTail(n)));
329
- const ordered = mixed
330
- ? 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)
331
- : queued.sort();
332
- return ordered.map((n) => path.join(projectDir, '.swarm', n));
333
- }
334
-
335
- /** All older work a boundary must wait behind: unclaimed captures plus captures a worker has claimed. */
336
- export function queuedWork(projectDir) {
337
- return swarmEntries(projectDir).filter((n) => (n.startsWith(QUEUE_PREFIX) || n.startsWith(CLAIM_PREFIX)) && n.endsWith('.json')).length;
338
- }
339
-
340
- /**
341
- * A process's START TIME, as a filename-safe token, or null where it cannot be read (no `ps`, e.g.
342
- * Windows). With the pid it identifies the process: a reused pid has a different start time.
343
- */
344
- export function processStart(pid) {
345
- try {
346
- // TZ and locale PINNED: `lstart` prints local time in the locale's format, so two workers with
347
- // different settings would record the same live process differently and read it as pid reuse.
348
- const r = spawnSync('ps', ['-o', 'lstart=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000, windowsHide: true,
349
- env: { ...process.env, TZ: 'UTC', LC_ALL: 'C', LANG: 'C' } });
350
- const s = String(r.stdout || '').replace(/[^A-Za-z0-9]/g, '');
351
- return r.status === 0 && s ? s : null;
352
- } catch { return null; }
353
- }
354
- let selfStart;
355
- const ownStart = () => (selfStart === undefined ? (selfStart = processStart(process.pid)) : selfStart);
356
-
357
- /**
358
- * Claim a queued capture by atomic rename; null if taken. The claim's name records pid, start time and
359
- * the queue file's ORIGINAL mtime (its creation order, which the mixed upgrade window sorts by); the
360
- * claim file's own mtime is then set to the claim time, from which the orphan ceiling counts.
361
- */
362
- function claimQueued(file) {
363
- let queuedAt = 0;
364
- try { queuedAt = Math.floor(fs.statSync(file).mtimeMs); } catch { return null; }
365
- const claimed = path.join(path.dirname(file), `${CLAIM_PREFIX}${process.pid}-${ownStart() || 'na'}-${queuedAt}-${path.basename(file).slice(QUEUE_PREFIX.length)}`);
366
- try { fs.renameSync(file, claimed); } catch { return null; }
367
- try { const t = new Date(); fs.utimesSync(claimed, t, t); } catch { /* the ceiling then counts from queue time: earlier, never later */ }
368
- return claimed;
369
- }
370
- const unclaimedName = (claimed) => path.join(path.dirname(claimed), `${QUEUE_PREFIX}${queueTail(path.basename(claimed))}`);
371
- /** Put a claim back in the queue: rename (atomic, needs no hard links), restoring its creation order. */
372
- const returnClaim = (claimed) => {
373
- const queuedAt = Number(path.basename(claimed).slice(CLAIM_PREFIX.length).split('-')[2]);
374
- const back = unclaimedName(claimed);
375
- try { fs.renameSync(claimed, back); } catch { return false; }
376
- if (Number.isFinite(queuedAt) && queuedAt > 0) { try { const t = new Date(queuedAt); fs.utimesSync(back, t, t); } catch { /* best effort */ } }
377
- return true;
378
- };
379
-
380
- /**
381
- * Return to the queue every claim whose worker is gone: its pid is dead, OR the pid now belongs to a
382
- * different process (start time differs — pid reuse), OR the claim is older than REPLAY_LOCK_ABANDON_MS
383
- * whatever the pid says (a reused pid where no start time can be read, a wedged worker). Without the
384
- * last two a reused pid stranded a claim forever and every Stop spawned a worker that could not run it.
385
- */
386
- export function reclaimOrphans(projectDir, { isAlive = pidAlive, startOf = processStart, now = Date.now() } = {}) {
387
- let n = 0;
388
- for (const name of swarmEntries(projectDir).filter((x) => x.startsWith(CLAIM_PREFIX))) {
389
- const [pidText, start] = name.slice(CLAIM_PREFIX.length).split('-');
390
- const pid = Number(pidText);
391
- const claimed = path.join(projectDir, '.swarm', name);
392
- const abandoned = now - mtimeOf(projectDir, name) > REPLAY_LOCK_ABANDON_MS;
393
- let gone = abandoned || !isAlive(pid);
394
- if (!gone && start && start !== 'na') {
395
- const current = startOf(pid);
396
- gone = Boolean(current) && current !== start;
397
- }
398
- if (gone && returnClaim(claimed)) n += 1;
399
- }
400
- return n;
401
- }
402
-
403
- const lockFacts = (file) => { const st = fs.statSync(file); return { content: fs.readFileSync(file, 'utf8'), mtimeMs: st.mtimeMs, ino: st.ino }; };
404
- const sameFacts = (a, b) => a.content === b.content && a.mtimeMs === b.mtimeMs && a.ino === b.ino;
405
- /** The pid ACTUALLY holding a lock: the `pid <n>` line a worker writes for itself, else the token's pid. */
406
- const holderPid = (content) => {
407
- const line = /^pid (\d+)$/m.exec(String(content));
408
- return Number(line ? line[1] : String(content).trim().split('-')[0]);
409
- };
410
-
411
- /**
412
- * Take the lock. Returns this holder's TOKEN (`<pid>-<time>-<random>`), or null.
413
- * • Free → exclusive create.
414
- * • Fresh (refreshed within REPLAY_LOCK_STALE_MS) → null.
415
- * • Stale but its holder pid is ALIVE → null until REPLAY_LOCK_ABANDON_MS: a laptop asleep mid-step,
416
- * or a long step, is not a dead worker, and taking over would put two workers on one job. The holder
417
- * pid is the WORKER's own (it rewrites the lock on start), not the hook that spawned it and exited.
418
- * • Otherwise taken over: the stale file is renamed aside and VERIFIED to be the very file judged
419
- * stale (content, mtime, inode). If a successor's fresh lock was moved instead (it took over between
420
- * our check and our rename), it is put back — never over a third lock — and we back off. One winner.
421
- */
422
- export function takeReplayLock(projectDir, now = Date.now(), { isAlive = pidAlive, beforeRename = null } = {}) {
423
- const lock = lockPath(projectDir);
424
- const token = `${process.pid}-${now}-${Math.random().toString(36).slice(2, 10)}`;
425
- const create = () => { fs.writeFileSync(lock, `${token}\npid ${process.pid}\n`, { flag: 'wx', mode: 0o600 }); return token; };
426
- try { return create(); } catch { /* held, or stale */ }
427
- let seen;
428
- try { seen = lockFacts(lock); } catch { try { return create(); } catch { return null; } }
429
- const age = now - seen.mtimeMs;
430
- if (age <= REPLAY_LOCK_STALE_MS) return null;
431
- if (age <= REPLAY_LOCK_ABANDON_MS && isAlive(holderPid(seen.content))) return null;
432
- beforeRename?.();
433
- const aside = `${lock}.stale-${token}`;
434
- try { fs.renameSync(lock, aside); } catch { return null; }
435
- let moved = null;
436
- try { moved = lockFacts(aside); } catch { /* vanished */ }
437
- if (!moved || !sameFacts(moved, seen)) {
438
- // Put the successor's lock back without ever overwriting a third holder's: a hard link where the
439
- // filesystem has them, else an exclusive copy.
440
- try { fs.linkSync(aside, lock); } catch {
441
- try { fs.copyFileSync(aside, lock, fs.constants.COPYFILE_EXCL); } catch { /* a third holder exists; the successor sees it lost the lock and stops */ }
442
- }
443
- try { fs.rmSync(aside, { force: true }); } catch { /* best effort */ }
444
- return null;
445
- }
446
- try { fs.rmSync(aside, { force: true }); } catch { /* best effort */ }
447
- try { return create(); } catch { return null; }
448
- }
449
-
450
- /** Heartbeat: refresh the lock's mtime if (and only if) this holder still owns it. */
451
- export function refreshReplayLock(projectDir, token) {
452
- if (!token || readLock(projectDir) !== token) return false;
453
- try { const t = new Date(); fs.utimesSync(lockPath(projectDir), t, t); return true; } catch { return false; }
454
- }
455
-
456
- /** A worker that inherited the lock records ITS OWN pid on it, keeping the owner token. */
457
- export function adoptReplayLock(projectDir, token) {
458
- if (!token || readLock(projectDir) !== token) return false;
459
- try { fs.writeFileSync(lockPath(projectDir), `${token}\npid ${process.pid}\n`, { mode: 0o600 }); return true; } catch { return false; }
460
- }
461
-
462
- /** Release ONLY a lock this holder owns; a successor's lock is never deleted. */
463
- export function releaseReplayLock(projectDir, token) {
464
- if (!token || readLock(projectDir) !== token) return false;
465
- try { fs.rmSync(lockPath(projectDir), { force: true }); return true; } catch { return false; }
466
- }
467
-
468
- /** Hand the lock (or take it, if free) to a detached worker. Returns whether one was started. Never throws. */
469
- export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn } = {}) {
470
- const held = token || takeReplayLock(projectDir);
471
- if (!held) return false;
472
- try {
473
- const child = spawnFn(process.execPath, [fileURLToPath(import.meta.url), '--replay-outbox'], {
474
- cwd: projectDir, detached: true, stdio: 'ignore', windowsHide: true,
475
- env: { ...process.env, RUVNET_REPLAY_LOCK_TOKEN: held },
476
- });
477
- child.unref?.();
478
- return true;
479
- } catch {
480
- releaseReplayLock(projectDir, held);
481
- return false;
482
- }
483
- }
484
-
485
- /**
486
- * The detached worker's body, holding the lock `token`: record its own pid on the lock, return orphaned
487
- * claims to the queue, replay the outbox, then run every queued capture IN ORDER — each CLAIMED by
488
- * atomic rename first, so no other worker can run it too, and each re-entering the boundary as
489
- * `ordered`, so it replays before it produces. Ownership is re-checked before every step and right
490
- * after each claim; a worker that lost the lock puts an unstarted claim back and stops. A finished
491
- * claim is the claimer's own and is deleted. Releases only its own lock, then re-checks for captures
492
- * queued while it held it.
493
- */
494
- export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
495
- makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null } = {}) {
496
- let held = token || takeReplayLock(projectDir);
497
- let replayed = 0;
498
- for (let round = 0; held && round < 8; round += 1) {
499
- try {
500
- if (!adoptReplayLock(projectDir, held)) return replayed;
501
- reclaimOrphans(projectDir);
502
- const resolution = resolveProjectStore({ projectDir });
503
- const store = makeStoreFactory(now() + budgetMs)({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath });
504
- for (const snapshot of store.outbox.pendingSnapshots()) {
505
- if (!refreshReplayLock(projectDir, held)) return replayed;
506
- store.outbox.markCommitted(store.appendExact(snapshot));
507
- replayed += 1;
508
- }
509
- for (const file of queuedCaptures(projectDir)) {
510
- if (!refreshReplayLock(projectDir, held)) return replayed;
511
- const claimed = claimQueued(file);
512
- if (!claimed) continue;
513
- onClaim?.(claimed);
514
- if (!refreshReplayLock(projectDir, held)) {
515
- returnClaim(claimed);
516
- return replayed;
517
- }
518
- let job = null;
519
- try { job = JSON.parse(fs.readFileSync(claimed, 'utf8')); } catch { /* torn: dropped below */ }
520
- try {
521
- if (job) runCapture(projectDir, job.event, { rawInput: JSON.stringify(job.payload), host: job.host,
522
- budgetMs, makeStoreFactory, now, ordered: held, writeMetadata: false,
523
- captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
524
- captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) });
525
- } catch { /* a failed capture leaves its own snapshot durable in the outbox */ }
526
- try { fs.rmSync(claimed, { force: true }); } catch { /* best effort */ }
527
- }
528
- } catch { /* the debt stays durable; the next boundary hands it on again */ } finally {
529
- releaseReplayLock(projectDir, held);
530
- }
531
- held = queuedWork(projectDir) ? takeReplayLock(projectDir) : null;
532
- }
533
- return replayed;
534
- }
535
-
536
291
  if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs') && process.argv[2] === '--replay-outbox') {
537
292
  try { runOutboxReplay({ projectDir: process.cwd() }); } catch { /* the debt stays durable in the outbox */ }
293
+ } else if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')
294
+ && ['UserPromptSubmit', 'PreToolUse', 'PostToolUse', 'PostToolUseFailure', 'SubagentStop'].includes(process.argv[2])) {
295
+ // Compatibility entrypoint uses the same minimized transition producer as direct registrations.
296
+ // Finish evaluating this module before importing its transition consumer.
297
+ void (async () => {
298
+ try {
299
+ const { runProjectTransitionHook } = await import('./project-transition-hook.mjs');
300
+ const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
301
+ const result = runProjectTransitionHook(payload.cwd || projectDirectory(), process.argv[2], { payload });
302
+ if (result.state === 'pending') process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition remains pending; exact readback was not verified.' }));
303
+ } catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
304
+ })();
538
305
  } else if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
539
306
  // projectDirectory() is the SAME derivation the Console's detector uses. Deriving it here
540
307
  // independently is what let this hook write a receipt the Console then reported as missing (#85).
541
308
  const rawInput = fs.readFileSync(0, 'utf8');
542
309
  try {
543
- const result = runSessionSnapshotHook(projectDirectory(), process.argv[2] || 'SessionEnd', { rawInput });
310
+ let originProjectDir = projectDirectory();
311
+ try {
312
+ const cwd = JSON.parse(rawInput || '{}').cwd;
313
+ if (typeof cwd === 'string' && path.isAbsolute(cwd)) originProjectDir = cwd;
314
+ } catch { /* malformed input keeps the host's project fallback */ }
315
+ const result = runSessionSnapshotHook(originProjectDir, process.argv[2] || 'SessionEnd', { rawInput });
544
316
  // FAIL LOUDLY, NEVER SILENTLY — AND ONCE. When recording is stuck (events pending past STUCK_AFTER_MS,
545
317
  // a quarantined conflict, a corrupt outbox line, a cap drop) Claude Code shows this systemMessage, at
546
318
  // most once per session per condition (stopNotice; it used to repeat at every turn). "Not applicable"
@@ -31,9 +31,9 @@ export function sessionStartTimeoutMs(hooksJsonPath = HOOKS_JSON_PATH) {
31
31
  }
32
32
 
33
33
  // Per-stage budgets, ms. Every stage session-start-core.mjs (or continuity's restore) can run
34
- // appears here exactly once. Reviewer-mandated ceilings: restore <= 1000, banner <= 200.
34
+ // appears here exactly once. Restore includes bounded durable replay; banner remains <= 200.
35
35
  export const STAGE_BUDGETS_MS = {
36
- restore: 1000, // continuity lane's project-progression restore — NOT this lane's code
36
+ restore: 3500, // measured turn replay (1.5–2s) + progression write/readback + bounded restore
37
37
  misc: 250, // settings/nightly/health/console-offer/auto-pref/star — small fs reads
38
38
  // The cache read itself is budgeted at 100ms internally (session-start-issue-alert.mjs's own
39
39
  // ISSUE_POINTER_BUDGET_MS, per correction #2's exact wording); this stage's total also carries
@@ -27,6 +27,7 @@ import os from 'node:os';
27
27
  import path from 'node:path';
28
28
  import { fileURLToPath } from 'node:url';
29
29
  import { restoreWithBrief } from './continuity-brief.mjs';
30
+ import { turnRecordingStatus } from './turn-outcome-capture.mjs';
30
31
  import {
31
32
  read, json, exists, mkdir, write, runNode,
32
33
  } from './session-start-fsutil.mjs';
@@ -120,6 +121,7 @@ export async function runSessionStart({
120
121
  || s.startsWith('[RuvNet Brain v')
121
122
  || s.startsWith('USER-LEVEL:')
122
123
  || s.startsWith('[ASCII→SVG]')
124
+ || s.startsWith('[RuvNet Brain — TURN RECORDING]')
123
125
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
124
126
  || s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
125
127
  || s.startsWith('[RuvNet Brain — COME UP TO SPEED')
@@ -183,6 +185,9 @@ export async function runSessionStart({
183
185
  emit('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]');
184
186
  emit('The SessionStart restore boundary failed unexpectedly. Do not claim project state was restored; verify the canonical store before relying on remembered state.');
185
187
  }
188
+ // Turn health remains visible even when the separate progression restore fails.
189
+ const turnStatus = turnRecordingStatus({ projectDir: cwd, env, home });
190
+ if (turnStatus.state === 'warn') emit(`[RuvNet Brain — TURN RECORDING] ${turnStatus.line}`);
186
191
  // Opt-in, matching the pre-existing `trace()` convention below: several other tests assert
187
192
  // SessionStart's stderr is EMPTY in the clean case (hook-battery.test.mjs, hook-hardening.test.mjs
188
193
  // — a real contract, not incidental), so this must never write unconditionally. `restore` shares