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.
- package/README.md +1 -1
- package/bin/install.mjs +4 -4
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +40 -3
- package/plugin/hooks/hook-contracts.json +218 -19
- package/plugin/hooks/hooks.json +51 -2
- package/plugin/scripts/agentdb-recall.mjs +101 -30
- package/plugin/scripts/continuity-hook-policy.mjs +9 -0
- package/plugin/scripts/continuity-journal.mjs +33 -33
- package/plugin/scripts/ground-ruvnet.sh +5 -5
- package/plugin/scripts/hook-shim.mjs +4 -30
- package/plugin/scripts/project-capture-queue.mjs +333 -0
- package/plugin/scripts/project-progression-contract.mjs +1 -1
- package/plugin/scripts/project-progression-hook.mjs +3 -3
- package/plugin/scripts/project-progression-producer.mjs +53 -28
- package/plugin/scripts/project-progression-session-start.mjs +44 -4
- package/plugin/scripts/project-progression-store.mjs +14 -0
- package/plugin/scripts/project-transition-hook.mjs +204 -0
- package/plugin/scripts/session-snapshot-hook.mjs +44 -272
- package/plugin/scripts/session-start-budget.mjs +2 -2
- package/plugin/scripts/turn-outcome-capture.mjs +125 -47
- package/plugin/scripts/turn-transport-journal.mjs +106 -0
- package/scripts/release-qualification-contract.mjs +13 -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,
|
|
@@ -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)
|
|
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] =
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
|
189
|
-
completed
|
|
190
|
-
inProgress
|
|
191
|
-
blockers: [],
|
|
192
|
-
failures: [],
|
|
193
|
-
decisions,
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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: { ...
|
|
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
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 });
|