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
@@ -197,29 +197,9 @@ if (!entry) {
197
197
  // forgets to declare one fails toward the pre-ADR-054 behaviour rather than toward silent death.
198
198
  if (BRAIN_OFF && entry.offBehavior === 'silence') process.exit(0);
199
199
 
200
- // The ground hook is registered on every prompt. On Windows, starting Git Bash and then jq can
201
- // consume most of the hook's five-second declaration before an unrelated prompt reaches the
202
- // shell body's "emit nothing" verdict. Read a bounded copy here, in the Node process that is
203
- // already running, and skip the interpreter entirely only when BOTH prompt intent and project
204
- // state prove that no advisory can fire. The regex deliberately over-approximates the shell gates:
205
- // false positives take the established body; false negatives would be a product defect.
200
+ // Read a bounded payload before dispatch. The ground body owns the quiet-prompt
201
+ // decision because it resolves canonical memory beyond the hook process's cwd.
206
202
  let hookInput = null;
207
- const GROUND_RELEVANT = /ruvnet|ruflo|ruvector|\brvf\b|agentdb|agenticow|rulake|ruview|rupixel|ruv-fann|agentic-flow|synthlang|dspy|qudag|safla|metaharness|cve-bench|sparc|swarm|claude-flow|pinecone|pgvector|chroma|weaviate|faiss|milvus|qdrant|hnswlib|annoy|vector|langchain|llama|autogen|crew-ai|semantic-kernel|embedding|retrieval|prompt compression|token cost|post-quantum|quantum-resistant|\badr\b|decision|architect|design|plan|spec|refactor|migrat|implement|build|write|add|change|fix|update|deploy|create|enhance|set up|setup|wire|integrate|test|coverage|audit|review|benchmark|lint|scan|debug|optimi|app|feature|service|system|backend|frontend|\bapi\b|module|pipeline|infra|database|schema|workflow|roadmap|milestone|autonomous|unattended|do not stop|keep working|keep going|soak run|harness|quality|readiness|evolve|self-improv|hardening|cheaper|cheap|lower cost|compute arbitrage|cascade|scorecard|score .*repo/i;
208
-
209
- function projectCanSpeakWithoutPrompt() {
210
- if (process.env.RUVNET_AUTONOMOUS === '1') return true;
211
- try {
212
- if (fs.existsSync(path.join(process.cwd(), '.claude-flow')) ||
213
- fs.existsSync(path.join(process.cwd(), '.swarm'))) return true;
214
- for (const name of ['package.json', '.mcp.json']) {
215
- try {
216
- if (/claude-flow|ruflo/i.test(fs.readFileSync(path.join(process.cwd(), name), 'utf8'))) return true;
217
- } catch { /* absent/unreadable project metadata cannot create an advisory */ }
218
- }
219
- } catch { /* fail toward running the body below */ return true; }
220
- return false;
221
- }
222
-
223
203
  function readHookInput(limit) {
224
204
  return new Promise((resolve) => {
225
205
  const chunks = [];
@@ -398,14 +378,8 @@ function dispatchHook() {
398
378
  if (entry.stdinBytes) {
399
379
  readHookInput(entry.stdinBytes).then((input) => {
400
380
  hookInput = input;
401
- if (hookId === 'ground-ruvnet') {
402
- let text = input.toString('utf8');
403
- try {
404
- const parsed = JSON.parse(text);
405
- text = parsed?.prompt ?? parsed?.user_prompt ?? parsed?.input ?? text;
406
- } catch { /* raw/malformed input is classified as-is */ }
407
- if (!GROUND_RELEVANT.test(String(text)) && !projectCanSpeakWithoutPrompt()) process.exit(0);
408
- }
381
+ // The body resolves canonical project memory before its quiet return. A cwd-only
382
+ // preflight here would hide eligible memory in nested directories and worktrees.
409
383
  process.exit(dispatchHook());
410
384
  }).catch(() => {
411
385
  hookInput = Buffer.alloc(0);
@@ -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,
@@ -23,10 +23,22 @@ export class ProgressionOutbox {
23
23
  appendRecord(record) {
24
24
  fs.mkdirSync(path.dirname(this.path), { recursive: true, mode: 0o700 });
25
25
  const noFollow = fs.constants.O_NOFOLLOW ?? 0;
26
- const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_WRONLY | noFollow, 0o600);
26
+ const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_RDWR | noFollow, 0o600);
27
27
  try {
28
+ let separator = '';
29
+ const size = fs.fstatSync(fd).size;
30
+ if (size) {
31
+ const last = Buffer.alloc(1);
32
+ fs.readSync(fd, last, 0, 1, size - 1);
33
+ if (last[0] !== 10) {
34
+ const content = fs.readFileSync(fd, 'utf8');
35
+ try { JSON.parse(content.slice(content.lastIndexOf('\n') + 1)); }
36
+ catch { throw new Error('incomplete outbox final record; preserve and recover the torn tail before appending'); }
37
+ separator = '\n';
38
+ }
39
+ }
28
40
  fs.fchmodSync(fd, 0o600);
29
- writeAll(fd, `${JSON.stringify(record)}\n`);
41
+ writeAll(fd, `${separator}${JSON.stringify(record)}\n`);
30
42
  this.fsync(fd);
31
43
  } finally {
32
44
  fs.closeSync(fd);
@@ -58,8 +70,11 @@ export class ProgressionOutbox {
58
70
  if (!fs.existsSync(this.path)) return [];
59
71
  const content = fs.readFileSync(this.path, 'utf8');
60
72
  const lines = content.split('\n');
61
- if (lines.at(-1) !== '') lines.pop();
62
- else lines.pop();
73
+ if (lines.at(-1) === '') lines.pop();
74
+ else {
75
+ // A missing delimiter is not a missing record; only a crash-torn JSON suffix is incomplete.
76
+ try { JSON.parse(lines.at(-1)); } catch { lines.pop(); }
77
+ }
63
78
  return lines.filter(Boolean).map((line, index) => {
64
79
  try { return JSON.parse(line); } catch { throw new Error(`malformed outbox record at line ${index + 1}`); }
65
80
  });
@@ -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