ruvnet-brain 4.6.1 → 4.6.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/bin/nightly-refresh.mjs +5 -1
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/scripts/agentdb-recall-process.mjs +118 -0
- package/plugin/scripts/agentdb-recall-reader.mjs +22 -0
- package/plugin/scripts/agentdb-recall.mjs +82 -99
- package/plugin/scripts/continuity-events.mjs +6 -5
- package/plugin/scripts/continuity-journal.mjs +22 -16
- package/plugin/scripts/developer-update-maintenance.mjs +133 -6
- package/plugin/scripts/developer-update.mjs +37 -7
- package/plugin/scripts/native-turn-transcript.mjs +51 -0
- package/plugin/scripts/nightly-scheduler.mjs +27 -6
- package/plugin/scripts/project-capture-queue.mjs +2 -0
- package/plugin/scripts/project-memory-enrollment.mjs +203 -0
- package/plugin/scripts/project-progression-reader.mjs +4 -4
- package/plugin/scripts/project-progression-session-start.mjs +7 -27
- package/plugin/scripts/project-progression-store.mjs +18 -6
- package/plugin/scripts/project-store-resolver.mjs +44 -6
- package/plugin/scripts/session-snapshot-hook.mjs +7 -0
- package/plugin/scripts/session-start-core.mjs +6 -0
- package/plugin/scripts/turn-outcome-capture.mjs +33 -19
- package/plugin/scripts/turn-transport-journal.mjs +3 -1
- package/scripts/release-qualification-contract.mjs +3 -3
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/** Consent-gated lazy enrollment. Hooks queue derived events before a detached native bootstrap. */
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
|
+
import { fileURLToPath } from 'node:url';
|
|
8
|
+
import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
|
|
9
|
+
import { resolveTurnDb, turnCapturePolicyFile } from './turn-outcome-capture.mjs';
|
|
10
|
+
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
11
|
+
import { redactText } from './continuity-events.mjs';
|
|
12
|
+
import { enrichStateWithObservation } from './project-progression-hook.mjs';
|
|
13
|
+
import { redactProgression } from './project-progression-contract.mjs';
|
|
14
|
+
import { normalizeHostEvent } from './hook-input.mjs';
|
|
15
|
+
import { maskExcludedPaths, privateProgressionState } from './turn-capture-privacy.mjs';
|
|
16
|
+
|
|
17
|
+
const canonical = (value) => { try { return fs.realpathSync.native(value); } catch { return path.resolve(value); } };
|
|
18
|
+
export const ENROLLMENT_NAMESPACE = 'project-enrollment';
|
|
19
|
+
const receiptName = '.memory-enrollment.json';
|
|
20
|
+
const queueName = '.memory-enrollment-pending';
|
|
21
|
+
|
|
22
|
+
export function enrollmentPlan({ projectDir, env = process.env, deadlineAt = Infinity,
|
|
23
|
+
temporaryRoots = [os.tmpdir(), '/tmp', '/private/tmp'], systemRoots = ['/System', '/Library', '/usr', '/etc', '/var', '/private/var', '/proc', '/sys', '/dev'] } = {}) {
|
|
24
|
+
const home = canonical(env.HOME || env.USERPROFILE || os.homedir());
|
|
25
|
+
const brainHome = canonical(env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'));
|
|
26
|
+
const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: 500, deadlineAt });
|
|
27
|
+
// The consent guard and the plan must agree on which roots are excluded from default enrollment.
|
|
28
|
+
const consent = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 500, deadlineAt, enrollmentRoots: { temporaryRoots, systemRoots } });
|
|
29
|
+
if (consent.skipped) return { ...resolution, state: 'disabled', reason: consent.skipped };
|
|
30
|
+
let policy = {};
|
|
31
|
+
try { policy = JSON.parse(fs.readFileSync(turnCapturePolicyFile(brainHome), 'utf8')); } catch { /* resolveTurnDb validates any present policy */ }
|
|
32
|
+
const explicit = policy.paths?.[canonical(projectDir)] ?? policy.projects?.[resolution.projectRoot];
|
|
33
|
+
const hasDb = fs.existsSync(resolution.canonicalAgentDbPath);
|
|
34
|
+
// RUVNET_TURN_CAPTURE=off means turn capture is off: it never creates a store. (It does not suspend progression or
|
|
35
|
+
// continuity capture on an existing store; queueBoundary additionally withholds the assistant text while it is off.)
|
|
36
|
+
if (!hasDb && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { ...resolution, state: 'disabled', reason: 'RUVNET_TURN_CAPTURE=off' };
|
|
37
|
+
if (!hasDb && explicit !== 'on') {
|
|
38
|
+
if (resolution.kind !== 'git') return { ...resolution, state: 'disabled', reason: 'non-Git enrollment requires explicit project opt-in' };
|
|
39
|
+
if (!globalEnrollmentAllowed({ resolution, home, brainHome, temporaryRoots, systemRoots })) {
|
|
40
|
+
return { ...resolution, state: 'disabled', reason: 'global enrollment excludes home, temporary, system and Brain cache roots' };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
let enrollmentPending = false;
|
|
44
|
+
const receipt = path.join(resolution.projectRoot, '.swarm', receiptName);
|
|
45
|
+
if (fs.existsSync(receipt)) {
|
|
46
|
+
const stat = fs.lstatSync(receipt);
|
|
47
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) throw new Error('unsafe enrollment receipt');
|
|
48
|
+
// An unparsable receipt is a torn write from a killed hook: it means "not ready", never "unavailable forever".
|
|
49
|
+
try { enrollmentPending = JSON.parse(fs.readFileSync(receipt, 'utf8')).state === 'pending'; } catch { enrollmentPending = true; }
|
|
50
|
+
}
|
|
51
|
+
return { ...resolution, state: hasDb && !enrollmentPending ? 'existing' : 'pending', brainHome, contentPathExcludes: consent.contentPathExcludes || [] };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Create-if-absent. A hook killed mid-write leaves a torn FINAL file, which every reader tolerates (an unparsable
|
|
55
|
+
* receipt means pending; an unparsable queue entry is preserved as .corrupt). No hard-link or rename dance: a link
|
|
56
|
+
* window leaves nlink 2 on a killed hook, which the readers' nlink check would then reject forever. */
|
|
57
|
+
function createExclusive(file, text) {
|
|
58
|
+
let fd;
|
|
59
|
+
try { fd = fs.openSync(file, 'wx', 0o600); } catch (error) { if (error.code === 'EEXIST') return; throw error; }
|
|
60
|
+
try { fs.writeFileSync(fd, text); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function safeDirectory(directory) {
|
|
64
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
65
|
+
const stat = fs.lstatSync(directory);
|
|
66
|
+
if (!stat.isDirectory() || stat.isSymbolicLink() || canonical(directory) !== directory) throw new Error('enrollment directory is not canonical');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Only a small derived outcome is queued; never prompts, transcripts, tool input or environment. */
|
|
70
|
+
function queueBoundary(plan, { event, payload, host, projectDir, env = process.env }) {
|
|
71
|
+
if (!event || typeof payload?.session_id !== 'string' || !payload.session_id) return false;
|
|
72
|
+
const directory = path.join(plan.projectRoot, '.swarm', queueName);
|
|
73
|
+
safeDirectory(directory);
|
|
74
|
+
const turnCaptureOff = String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off';
|
|
75
|
+
const text = turnCaptureOff ? '' : String(payload.last_assistant_message || '');
|
|
76
|
+
const filtered = maskExcludedPaths(text, plan.contentPathExcludes, projectDir);
|
|
77
|
+
const last = filtered === text ? redactText(text).slice(0, 12000) : '[REDACTED:excluded-resource-outcome]';
|
|
78
|
+
const normalized = normalizeHostEvent(payload) || payload;
|
|
79
|
+
const observed = enrichStateWithObservation({ commands: [] }, { ...normalized, hook_event_name: event },
|
|
80
|
+
{ contentPathExcludes: plan.contentPathExcludes, projectDir }).commands[0];
|
|
81
|
+
const tool = observed ? { tool_name: redactText(observed.tool), tool_input: {
|
|
82
|
+
...(observed.command ? { command: redactText(observed.command) } : {}),
|
|
83
|
+
...(observed.filePath ? { file_path: redactText(observed.filePath) } : {}) },
|
|
84
|
+
tool_response: JSON.parse(redactText(JSON.stringify(Object.fromEntries(Object.entries(observed)
|
|
85
|
+
.filter(([key]) => ['outcome', 'exitCode', 'error', 'signal', 'interrupted', 'isError', 'stdout', 'stderr', 'result'].includes(key)))))) } : {};
|
|
86
|
+
let strategic = {};
|
|
87
|
+
if (payload.projectProgression || payload.project_progression) {
|
|
88
|
+
const extension = redactProgression(payload.projectProgression || payload.project_progression).value;
|
|
89
|
+
extension.completeProjectState = privateProgressionState(extension.completeProjectState, plan.contentPathExcludes, projectDir);
|
|
90
|
+
strategic = { projectProgression: extension };
|
|
91
|
+
}
|
|
92
|
+
const boundary = { projectDir: canonical(projectDir), event, host,
|
|
93
|
+
payload: { session_id: payload.session_id, hook_event_name: event, ...tool, ...strategic, ...(last ? { last_assistant_message: last } : {}) } };
|
|
94
|
+
const digest = crypto.createHash('sha256').update(JSON.stringify(boundary)).digest('hex');
|
|
95
|
+
const file = path.join(directory, `${digest}.json`);
|
|
96
|
+
createExclusive(file, JSON.stringify(boundary));
|
|
97
|
+
const dirFd = fs.openSync(directory, 'r'); try { fs.fsyncSync(dirFd); } finally { fs.closeSync(dirFd); }
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function ensureProjectMemory({ projectDir, env = process.env, event, payload, host = env.RUVNET_HOOK_HOST || 'claude', deadlineAt = Infinity,
|
|
102
|
+
launch = launchEnrollment, planOptions = {} } = {}) {
|
|
103
|
+
try {
|
|
104
|
+
const plan = enrollmentPlan({ ...planOptions, projectDir, env, deadlineAt });
|
|
105
|
+
if (plan.state === 'disabled') return plan;
|
|
106
|
+
if (plan.state === 'existing') {
|
|
107
|
+
const queue = path.join(plan.projectRoot, '.swarm', queueName);
|
|
108
|
+
if (!fs.existsSync(queue) || !fs.readdirSync(queue).some((name) => /^[a-f0-9]{64}\.json$/.test(name))) return plan;
|
|
109
|
+
}
|
|
110
|
+
safeDirectory(path.join(plan.projectRoot, '.swarm'));
|
|
111
|
+
if (plan.state === 'pending') {
|
|
112
|
+
const receipt = path.join(plan.projectRoot, '.swarm', receiptName);
|
|
113
|
+
createExclusive(receipt, JSON.stringify({ schemaVersion: 1, state: 'pending', canonicalAgentDbPath: plan.canonicalAgentDbPath }));
|
|
114
|
+
}
|
|
115
|
+
const queued = plan.state === 'pending' && queueBoundary(plan, { event, payload, host, projectDir, env });
|
|
116
|
+
const launched = launch({ projectDir, env });
|
|
117
|
+
return { ...plan, state: plan.state === 'existing' ? 'existing' : 'pending', queued, launched };
|
|
118
|
+
} catch (error) { return { state: 'unavailable', reason: error.message }; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function launchEnrollment({ projectDir, env }) {
|
|
122
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--enroll', canonical(projectDir)], {
|
|
123
|
+
cwd: os.tmpdir(), env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined }, detached: true, windowsHide: true, stdio: 'ignore',
|
|
124
|
+
});
|
|
125
|
+
child.unref();
|
|
126
|
+
return Boolean(child.pid);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Exact native append and same-path readback; existing stores are never initialized or replaced. */
|
|
130
|
+
export async function enrollProjectMemory({ projectDir, env = process.env, run = spawnSync, replay } = {}) {
|
|
131
|
+
const plan = enrollmentPlan({ projectDir, env });
|
|
132
|
+
if (plan.state === 'disabled') return plan;
|
|
133
|
+
const swarm = path.join(plan.projectRoot, '.swarm');
|
|
134
|
+
safeDirectory(swarm);
|
|
135
|
+
const lock = path.join(swarm, '.memory-enrollment.lock');
|
|
136
|
+
const token = `${process.pid} ${crypto.randomUUID()}`;
|
|
137
|
+
try { fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 }); } catch (error) {
|
|
138
|
+
if (error.code !== 'EEXIST') throw error;
|
|
139
|
+
const stat = fs.lstatSync(lock);
|
|
140
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return { ...plan, state: 'pending', reason: 'unsafe enrollment lock' };
|
|
141
|
+
const previous = fs.readFileSync(lock, 'utf8');
|
|
142
|
+
const pid = Number(previous.split(' ')[0]);
|
|
143
|
+
let dead = false;
|
|
144
|
+
if (Number.isSafeInteger(pid) && pid > 0) try { process.kill(pid, 0); } catch (failure) { dead = failure.code === 'ESRCH'; }
|
|
145
|
+
if (!dead || fs.readFileSync(lock, 'utf8') !== previous) return { ...plan, state: 'pending', reason: 'enrollment worker busy' };
|
|
146
|
+
fs.unlinkSync(lock);
|
|
147
|
+
try { fs.writeFileSync(lock, token, { flag: 'wx', mode: 0o600 }); } catch { return { ...plan, state: 'pending', reason: 'enrollment worker busy' }; }
|
|
148
|
+
}
|
|
149
|
+
const workerDeadlineAt = Date.now() + 60000;
|
|
150
|
+
const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'rnb-enrollment-worker-'));
|
|
151
|
+
try {
|
|
152
|
+
const binary = resolveRuflo({ env, home: env.HOME || os.homedir() });
|
|
153
|
+
if (!binary) return { ...plan, state: 'pending', reason: 'global Ruflo unavailable' };
|
|
154
|
+
const key = `project-bootstrap-${plan.projectIdentity.id}`;
|
|
155
|
+
const value = JSON.stringify({ schemaVersion: 1, projectIdentity: plan.projectIdentity });
|
|
156
|
+
const invoke = (args) => { const call = rufloInvocation(binary, args); return run(call.executable, call.args,
|
|
157
|
+
{ cwd: scratch, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined }, encoding: 'utf8', timeout: Math.max(1, Math.min(30000, workerDeadlineAt - Date.now())), maxBuffer: 256 * 1024, shell: false }); };
|
|
158
|
+
if (plan.state !== 'existing') {
|
|
159
|
+
invoke(['memory', 'store', '--key', key, '--value', value, '--namespace', ENROLLMENT_NAMESPACE,
|
|
160
|
+
'--path', plan.canonicalAgentDbPath, '--require-native', '--append-only', '--no-upsert', '--no-embedding']);
|
|
161
|
+
// Concurrent workers may lose the strict insertion race; exact readback still establishes readiness.
|
|
162
|
+
const result = invoke(['memory', 'retrieve', '--key', key, '--namespace', ENROLLMENT_NAMESPACE,
|
|
163
|
+
'--path', plan.canonicalAgentDbPath, '--value-only']);
|
|
164
|
+
if (result.status !== 0 || String(result.stdout || '').trim() !== value) return { ...plan, state: 'pending', reason: 'native bootstrap readback unavailable' };
|
|
165
|
+
const receipt = path.join(swarm, receiptName);
|
|
166
|
+
const temporary = `${receipt}.${crypto.randomUUID()}.tmp`;
|
|
167
|
+
fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, state: 'ready', key,
|
|
168
|
+
canonicalAgentDbPath: plan.canonicalAgentDbPath, verifiedAt: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
|
|
169
|
+
fs.renameSync(temporary, receipt);
|
|
170
|
+
}
|
|
171
|
+
const queue = path.join(swarm, queueName);
|
|
172
|
+
if (fs.existsSync(queue)) {
|
|
173
|
+
safeDirectory(queue);
|
|
174
|
+
const capture = replay || (await import('./session-snapshot-hook.mjs')).runSessionSnapshotHook;
|
|
175
|
+
for (const name of fs.readdirSync(queue).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).slice(0, 32)) {
|
|
176
|
+
if (Date.now() > workerDeadlineAt - 8000) break;
|
|
177
|
+
const file = path.join(queue, name);
|
|
178
|
+
const stat = fs.lstatSync(file);
|
|
179
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > 256000) continue;
|
|
180
|
+
let boundary;
|
|
181
|
+
try { boundary = JSON.parse(fs.readFileSync(file, 'utf8')); } catch {
|
|
182
|
+
// Preserve the evidence, stop it blocking every later replay.
|
|
183
|
+
try { fs.renameSync(file, `${file}.corrupt`); } catch { /* retry next boundary */ }
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
// Original path privacy is rechecked, never replaced by the primary worktree identity.
|
|
187
|
+
const consent = enrollmentPlan({ projectDir: boundary.projectDir, env });
|
|
188
|
+
if (consent.state === 'disabled' || consent.canonicalAgentDbPath !== plan.canonicalAgentDbPath) continue;
|
|
189
|
+
const result = capture(boundary.projectDir, boundary.event, { rawInput: JSON.stringify(boundary.payload), host: boundary.host,
|
|
190
|
+
env, enrollMemory: () => ({ state: 'existing' }) });
|
|
191
|
+
if (result?.turn?.queued || result?.turn?.recorded || result?.continuity?.recorded > 0 || result?.progressionCaptured || result?.deferredToReplayer) fs.unlinkSync(file);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { ...plan, state: 'ready' };
|
|
195
|
+
} finally {
|
|
196
|
+
fs.rmSync(scratch, { recursive: true, force: true });
|
|
197
|
+
try { if (fs.readFileSync(lock, 'utf8') === token) fs.unlinkSync(lock); } catch { /* ownership changed */ }
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--enroll') {
|
|
202
|
+
enrollProjectMemory({ projectDir: process.argv[3] }).catch(() => { /* pending queue survives; retry at the next boundary */ });
|
|
203
|
+
}
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
import fs from 'node:fs';
|
|
44
44
|
import { loadNodeSqlite } from './node-sqlite.mjs';
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
const ACTIVE_ROW_SQL = "(status = 'active' OR status IS NULL)";
|
|
46
|
+
/** Installed Ruflo live-memory-row.js visibility; bind current time on every read. */
|
|
47
|
+
const ACTIVE_ROW_SQL = "(status = 'active' OR status IS NULL) AND (expires_at IS NULL OR expires_at > ?)";
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
50
|
* Every SQLite database file begins with this 16-byte header (sqlite.org/fileformat.html §1.3):
|
|
@@ -224,7 +224,7 @@ export function openProgressionReader(dbPath, { deadlineAt = Infinity, signal }
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
const query = (statement, params) => {
|
|
227
|
-
try { return statement.all(...params); } catch (error) {
|
|
227
|
+
try { return statement.all(Date.now(), ...params); } catch (error) {
|
|
228
228
|
throw new ProgressionReaderUnavailable(`read failed: ${error.message}`);
|
|
229
229
|
}
|
|
230
230
|
};
|
|
@@ -236,7 +236,7 @@ export function openProgressionReader(dbPath, { deadlineAt = Infinity, signal }
|
|
|
236
236
|
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new TypeError('maxEntries must be a positive safe integer');
|
|
237
237
|
let rows = [];
|
|
238
238
|
if (Number.isFinite(deadlineAt) || signal) {
|
|
239
|
-
for (const row of listStatement.iterate(namespace)) {
|
|
239
|
+
for (const row of listStatement.iterate(Date.now(), namespace)) {
|
|
240
240
|
checkDeadline(); rows.push(row);
|
|
241
241
|
if (rows.length > maxEntries) throw new Error('progression enumeration exceeds its bound');
|
|
242
242
|
}
|
|
@@ -124,29 +124,6 @@ function resultText(result, field) {
|
|
|
124
124
|
return Buffer.isBuffer(value) ? value.toString('utf8') : String(value ?? '');
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
function initializeCanonicalStore(store, resolution) {
|
|
128
|
-
const key = 'project-continuity-bootstrap-v1';
|
|
129
|
-
const namespace = 'project-progression-control';
|
|
130
|
-
const value = JSON.stringify({
|
|
131
|
-
schema: 'ruvnet-brain.project-continuity-bootstrap',
|
|
132
|
-
schemaVersion: 1,
|
|
133
|
-
projectIdentity: resolution.projectIdentity,
|
|
134
|
-
});
|
|
135
|
-
store.run([
|
|
136
|
-
'memory', 'store', '--key', key, '--value', value,
|
|
137
|
-
'--namespace', namespace, '--no-upsert', '--provenance', 'system_observation',
|
|
138
|
-
'--path', resolution.canonicalAgentDbPath,
|
|
139
|
-
]);
|
|
140
|
-
const readback = store.run([
|
|
141
|
-
'memory', 'retrieve', '--key', key, '--namespace', namespace,
|
|
142
|
-
'--value-only', '--path', resolution.canonicalAgentDbPath,
|
|
143
|
-
]);
|
|
144
|
-
if (resultStatus(readback) !== 0 || resultText(readback, 'stdout') !== value
|
|
145
|
-
|| !fs.existsSync(resolution.canonicalAgentDbPath)) {
|
|
146
|
-
throw new Error('canonical AgentDB initialization failed');
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
|
|
150
127
|
/**
|
|
151
128
|
* One line, only when there is something to say. Durable-but-uncommitted snapshots are evidence the
|
|
152
129
|
* session must know about: they will be committed at the next capture boundary, and until then the
|
|
@@ -267,10 +244,13 @@ export function restoreProgressionForSession({
|
|
|
267
244
|
const failed = miss('outbox-replay');
|
|
268
245
|
return { ...failed, context: `${failed.context} Replay is suspended by capture consent; no older checkpoint was injected.` };
|
|
269
246
|
}
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
247
|
+
// ADR-105 rule 6: restoring a session never creates a store. A project's store is created only by the
|
|
248
|
+
// consent-gated native enrollment (project-memory-enrollment.mjs), never by SessionStart and never when
|
|
249
|
+
// consent is absent or RUVNET_TURN_CAPTURE=off.
|
|
250
|
+
if (initializing) return {
|
|
251
|
+
status: 'unavailable', reason: 'not-enrolled', severity: 'info',
|
|
252
|
+
context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nNo canonical project store exists yet; it is created by consent-gated native enrollment at the first captured boundary. Nothing to restore.',
|
|
253
|
+
};
|
|
274
254
|
if (!suspended) {
|
|
275
255
|
const queue = drainCaptureQueue({ projectDir, budgetMs: Math.max(0, deadlineAt - Date.now()),
|
|
276
256
|
deadlineAt, signal, env, makeStoreFactory: () => () => store });
|
|
@@ -358,9 +358,8 @@ export class ProjectProgressionStore {
|
|
|
358
358
|
this.deadlineAt = deadlineAt;
|
|
359
359
|
this.signal = signal;
|
|
360
360
|
this.resolution = resolveProjectStore({ projectDir, requestedStorePath, deadlineAt });
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
catch (error) { this.legacyDebris = { removed: [], refused: [{ path: null, reason: error.message }] }; }
|
|
361
|
+
// Opening continuity is never authorization to remove another store or its artifacts.
|
|
362
|
+
this.legacyDebris = { removed: [], refused: [], skipped: 'automatic cleanup disabled; existing stores preserved' };
|
|
364
363
|
this.brainHome = brainHome;
|
|
365
364
|
this.rufloBinary = rufloBinary;
|
|
366
365
|
this.runner = runner;
|
|
@@ -387,7 +386,7 @@ export class ProjectProgressionStore {
|
|
|
387
386
|
cwd,
|
|
388
387
|
encoding: 'utf8',
|
|
389
388
|
timeout: 120_000,
|
|
390
|
-
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0' },
|
|
389
|
+
env: { ...process.env, RUFLO_DAEMON_AUTOSTART: '0', CLAUDE_FLOW_MEMORY_PATH: undefined },
|
|
391
390
|
});
|
|
392
391
|
} finally {
|
|
393
392
|
fs.rmSync(cwd, { recursive: true, force: true });
|
|
@@ -416,11 +415,24 @@ export class ProjectProgressionStore {
|
|
|
416
415
|
this.requireCaptureConsent(snapshot);
|
|
417
416
|
const stored = this.run([
|
|
418
417
|
'memory', 'store', '--key', snapshot.eventKey, '--value', JSON.stringify(snapshot),
|
|
419
|
-
'--namespace', PROGRESSION_NAMESPACE, '--no-upsert', '--provenance', 'system_observation',
|
|
418
|
+
'--namespace', PROGRESSION_NAMESPACE, '--no-upsert', '--require-native', '--append-only', '--provenance', 'system_observation',
|
|
420
419
|
'--path', this.resolution.canonicalAgentDbPath,
|
|
421
420
|
]);
|
|
422
421
|
const alreadyStored = resultStatus(stored) !== 0;
|
|
423
|
-
if (
|
|
422
|
+
if (alreadyStored) {
|
|
423
|
+
const diagnostic = `${resultText(stored, 'stderr')}\n${resultText(stored, 'stdout')}`.replace(/\x1b\[[0-9;]*m/g, '');
|
|
424
|
+
const isDuplicate = line => {
|
|
425
|
+
const reason = line.trim().replace(/^\[ERROR\]\s*/, '').replace(/^Failed to store:\s*/, '');
|
|
426
|
+
return reason === 'immutable append rejected: logical key already exists'
|
|
427
|
+
|| reason === `key "${snapshot.eventKey}" already exists in namespace "${PROGRESSION_NAMESPACE}" — pass upsert=true (--upsert on the CLI) to update it`;
|
|
428
|
+
};
|
|
429
|
+
const lines = diagnostic.split(/\r?\n/).map(line => line.trim()).filter(Boolean);
|
|
430
|
+
const duplicate = lines.some(isDuplicate) && lines.filter(line => /^\[ERROR\]|^Failed to store:/i.test(line)).every(isDuplicate)
|
|
431
|
+
&& !/\b(?:unavailable|refus(?:ed|ing|al)|permission denied|native WAL|unsafe write)\b/i.test(diagnostic);
|
|
432
|
+
if (resultStatus(stored) !== 1 || stored.error || stored.signal || !duplicate) {
|
|
433
|
+
throw new Error(`progression native append failed: ${resultText(stored, 'stderr').trim() || resultText(stored, 'stdout').trim() || 'unknown error'}`);
|
|
434
|
+
}
|
|
435
|
+
} else onPhase('stored');
|
|
424
436
|
|
|
425
437
|
// THE READ-BACK, through the fast path when it is available.
|
|
426
438
|
//
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from 'node:crypto';
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import os from 'node:os';
|
|
4
5
|
import { execFileSync } from 'node:child_process';
|
|
5
6
|
|
|
6
7
|
function sha256(value) {
|
|
@@ -47,21 +48,36 @@ function gitValue(cwd, args, gitTimeoutMs, deadlineAt) {
|
|
|
47
48
|
return execFileSync('git', args, {
|
|
48
49
|
cwd,
|
|
49
50
|
encoding: 'utf8',
|
|
50
|
-
stdio: ['ignore', 'pipe', '
|
|
51
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
52
|
+
env: { ...process.env, LC_ALL: 'C', LANG: 'C' },
|
|
51
53
|
...(Number.isFinite(remaining) || gitTimeoutMs
|
|
52
54
|
? { timeout: Math.max(1, Math.floor(Math.min(gitTimeoutMs || Infinity, remaining))), killSignal: 'SIGKILL' } : {}),
|
|
53
55
|
}).trim();
|
|
54
56
|
} catch (error) {
|
|
55
57
|
// A timed-out Git identity check is not evidence for a non-git project.
|
|
56
58
|
if (error.code === 'ETIMEDOUT') throw new Error('Git project identity timed out');
|
|
57
|
-
return null;
|
|
59
|
+
if (error.code === 'ENOENT' && !(() => { for (let c = cwd; ; c = path.dirname(c)) { if (fs.existsSync(path.join(c, '.git'))) return true; if (path.dirname(c) === c) return false; } })()) return null; // no git binary and no .git marker: not a Git project
|
|
60
|
+
if (error.status === 128 && /^fatal: not a git repository\b/m.test(String(error.stderr || ''))) {
|
|
61
|
+
let cursor = cwd;
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (fs.existsSync(path.join(cursor, '.git'))) throw new Error('Git project identity unavailable: invalid Git marker');
|
|
64
|
+
const parent = path.dirname(cursor);
|
|
65
|
+
if (parent === cursor) break;
|
|
66
|
+
cursor = parent;
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
throw new Error('Git project identity unavailable');
|
|
58
71
|
}
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
function gitProject(projectDir, gitTimeoutMs, deadlineAt) {
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
75
|
+
// One process, two lines (common dir, then checkout root): each Git spawn is a load-sensitive cost on the prompt path.
|
|
76
|
+
const identity = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir', '--show-toplevel'], gitTimeoutMs, deadlineAt);
|
|
77
|
+
if (identity === null) return null;
|
|
78
|
+
const [commonValue, checkoutValue, ...extra] = identity.split('\n');
|
|
79
|
+
if (extra.length) throw new Error('Git project identity incomplete');
|
|
80
|
+
if (!commonValue || !checkoutValue) throw new Error('Git project identity incomplete');
|
|
65
81
|
const gitCommonDir = canonicalDirectory(commonValue, 'Git common directory');
|
|
66
82
|
const checkoutRoot = canonicalDirectory(checkoutValue, 'Git checkout root');
|
|
67
83
|
if (path.basename(gitCommonDir) !== '.git') {
|
|
@@ -74,6 +90,18 @@ function gitProject(projectDir, gitTimeoutMs, deadlineAt) {
|
|
|
74
90
|
};
|
|
75
91
|
}
|
|
76
92
|
|
|
93
|
+
function nonGitRoot(input) {
|
|
94
|
+
// A home directory that does not exist (CI sandboxes, relocated HOME) must not make project resolution throw.
|
|
95
|
+
const home = (() => { try { return fs.realpathSync.native(os.homedir()); } catch { return path.resolve(os.homedir()); } })();
|
|
96
|
+
let cursor = input;
|
|
97
|
+
while (cursor !== path.dirname(cursor) && cursor !== home) {
|
|
98
|
+
if (fs.existsSync(path.join(cursor, '.swarm', 'memory.db'))
|
|
99
|
+
|| ['AGENTS.md', 'package.json', 'Cargo.toml', 'pyproject.toml'].some((marker) => fs.existsSync(path.join(cursor, marker)))) return cursor;
|
|
100
|
+
cursor = path.dirname(cursor);
|
|
101
|
+
}
|
|
102
|
+
return input;
|
|
103
|
+
}
|
|
104
|
+
|
|
77
105
|
export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath, gitTimeoutMs, deadlineAt = Infinity } = {}) {
|
|
78
106
|
const canonicalInput = canonicalDirectory(projectDir, 'projectDir');
|
|
79
107
|
if (gitTimeoutMs !== undefined && (!Number.isSafeInteger(gitTimeoutMs) || gitTimeoutMs <= 0)) throw new TypeError('gitTimeoutMs must be a positive integer');
|
|
@@ -81,7 +109,7 @@ export function resolveProjectStore({ projectDir = process.cwd(), requestedStore
|
|
|
81
109
|
const resolved = git ?? {
|
|
82
110
|
gitCommonDir: null,
|
|
83
111
|
checkoutRoot: canonicalInput,
|
|
84
|
-
projectRoot: canonicalInput,
|
|
112
|
+
projectRoot: nonGitRoot(canonicalInput),
|
|
85
113
|
};
|
|
86
114
|
const kind = git ? 'git' : 'non-git';
|
|
87
115
|
const canonicalAgentDbPath = path.join(resolved.projectRoot, '.swarm', 'memory.db');
|
|
@@ -116,3 +144,13 @@ export function resolveProjectStore({ projectDir = process.cwd(), requestedStore
|
|
|
116
144
|
projectIdentity,
|
|
117
145
|
});
|
|
118
146
|
}
|
|
147
|
+
|
|
148
|
+
/** Default enrollment applies only to Git developer roots; explicit consent is evaluated by callers. */
|
|
149
|
+
export function globalEnrollmentAllowed({ resolution, home = os.homedir(), brainHome = path.join(home, '.cache', 'ruvnet-brain'),
|
|
150
|
+
temporaryRoots = [os.tmpdir(), '/tmp', '/private/tmp'],
|
|
151
|
+
systemRoots = ['/System', '/Library', '/usr', '/etc', '/var', '/private/var', '/proc', '/sys', '/dev'] } = {}) {
|
|
152
|
+
const canonical = (value) => { try { return fs.realpathSync.native(value); } catch { return path.resolve(value); } };
|
|
153
|
+
const root = resolution.projectRoot;
|
|
154
|
+
return resolution.kind === 'git' && root !== canonical(home) && root !== path.parse(root).root
|
|
155
|
+
&& ![...temporaryRoots, ...systemRoots, brainHome].some((excluded) => isWithin(canonical(excluded), root));
|
|
156
|
+
}
|
|
@@ -15,6 +15,7 @@ import { projectDirectory } from './project-identity.mjs';
|
|
|
15
15
|
import { buildProjectProgression } from './project-progression-producer.mjs';
|
|
16
16
|
import { ProjectProgressionStore } from './project-progression-store.mjs';
|
|
17
17
|
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
18
|
+
import { ensureProjectMemory } from './project-memory-enrollment.mjs';
|
|
18
19
|
import { resolveTurnDb, captureTurnOutcome } from './turn-outcome-capture.mjs';
|
|
19
20
|
import { captureContinuityEvents, stopNotice } from './continuity-journal.mjs';
|
|
20
21
|
import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
|
|
@@ -129,6 +130,7 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
129
130
|
spawnReplay = replayOutboxDetached,
|
|
130
131
|
ordered = null,
|
|
131
132
|
captureEvents = captureContinuityEvents,
|
|
133
|
+
enrollMemory = ensureProjectMemory,
|
|
132
134
|
deadlineAt: inheritedDeadlineAt = Infinity,
|
|
133
135
|
signal,
|
|
134
136
|
} = {}) {
|
|
@@ -136,6 +138,11 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
136
138
|
const checkDeadline = () => { if (signal?.aborted || now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
|
|
137
139
|
const suspended = (reason) => ({ metadataWritten: false, progressionCaptured: false, receipt: null, skipped: reason,
|
|
138
140
|
turn: { event, recorded: false, skipped: reason }, continuity: { event, recorded: 0, launched: false, skipped: reason } });
|
|
141
|
+
let enrollmentPayload = {};
|
|
142
|
+
try { enrollmentPayload = rawInput ? JSON.parse(rawInput) : {}; } catch { /* malformed payload */ }
|
|
143
|
+
const enrollment = enrollMemory({ projectDir, env, event, payload: enrollmentPayload, host, deadlineAt });
|
|
144
|
+
if (enrollment.state === 'pending') return { ...suspended('native project memory enrollment pending'), enrollment };
|
|
145
|
+
if (enrollment.state === 'disabled' || enrollment.state === 'unavailable') return { ...suspended(enrollment.reason), enrollment };
|
|
139
146
|
// Consent is checked before metadata, transcript inspection or any durable capture queue.
|
|
140
147
|
try {
|
|
141
148
|
checkDeadline();
|
|
@@ -26,6 +26,7 @@ import fs from 'node:fs';
|
|
|
26
26
|
import os from 'node:os';
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
|
+
import { enrollmentPlan } from './project-memory-enrollment.mjs';
|
|
29
30
|
import { restoreWithBrief } from './continuity-brief.mjs';
|
|
30
31
|
import { turnRecordingStatus } from './turn-outcome-capture.mjs';
|
|
31
32
|
import {
|
|
@@ -189,6 +190,11 @@ export async function runSessionStart({
|
|
|
189
190
|
}
|
|
190
191
|
};
|
|
191
192
|
|
|
193
|
+
// Report only. SessionStart never creates or bootstraps a store (ADR-105 rule 6); enrollment starts at the first
|
|
194
|
+
// consented captured boundary, so a session that merely started leaves no store behind.
|
|
195
|
+
let enrollment;
|
|
196
|
+
try { enrollment = enrollmentPlan({ projectDir: cwd, env, deadlineAt: hookDeadlineAt }); } catch (error) { enrollment = { state: 'unavailable', reason: error.message }; }
|
|
197
|
+
if (enrollment.state === 'pending' || enrollment.state === 'unavailable') emit(`[RuvNet Brain — TURN CAPTURE] Project memory enrollment ${enrollment.state}; restoration is not yet proven.`);
|
|
192
198
|
const restoreStart = Date.now();
|
|
193
199
|
const restoreDeadlineAt = Math.min(hookDeadlineAt, restoreStart + STAGE_BUDGETS_MS.restore);
|
|
194
200
|
const restoreController = new AbortController();
|
|
@@ -23,11 +23,9 @@
|
|
|
23
23
|
* • Content = assistant outcome text, files changed, Bash descriptions. NEVER raw user text (a
|
|
24
24
|
* 2026-07-13 measurement: prompt echoes made 87% of a store noise). Trivial turns are skipped.
|
|
25
25
|
*
|
|
26
|
-
* CODEX:
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* unknown, and the rollout records observed locally (custom_tool_call / function_call with free-form
|
|
30
|
-
* inputs) give no stable file-change shape. Codex records therefore carry the outcome text only.
|
|
26
|
+
* CODEX: native response_item JSONL is normalized mechanically through native-turn-transcript.
|
|
27
|
+
* SessionEnd and PreCompact recover a closing outcome when Stop payload text is absent. Tool IDs
|
|
28
|
+
* correlate observable gate outcomes; opaque custom orchestration inputs remain unclassified.
|
|
31
29
|
*
|
|
32
30
|
* LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
|
|
33
31
|
* (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
|
|
@@ -40,9 +38,11 @@ import path from 'node:path';
|
|
|
40
38
|
import crypto from 'node:crypto';
|
|
41
39
|
import { spawn, spawnSync } from 'node:child_process';
|
|
42
40
|
import { fileURLToPath } from 'node:url';
|
|
41
|
+
import { nativeTurnLines } from './native-turn-transcript.mjs';
|
|
43
42
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
43
|
+
import { rufloCwdFor, rufloScratchRoot } from './project-progression-store.mjs';
|
|
44
44
|
import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
|
|
45
|
-
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
45
|
+
import { resolveProjectStore, globalEnrollmentAllowed } from './project-store-resolver.mjs';
|
|
46
46
|
import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
|
|
47
47
|
import { contentPathExcludes, pathIsExcluded, privateTurn, maskExcludedPaths, captureFailureReason, firstTurnCaptureNotice } from './turn-capture-privacy.mjs';
|
|
48
48
|
|
|
@@ -162,7 +162,7 @@ const consentMap = (value) => value !== null && typeof value === 'object'
|
|
|
162
162
|
&& !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
163
163
|
&& Object.values(value).every((setting) => setting === 'on' || setting === 'off');
|
|
164
164
|
|
|
165
|
-
export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false, deadlineAt = Infinity, signal } = {}) {
|
|
165
|
+
export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false, deadlineAt = Infinity, signal, enrollmentRoots = {} } = {}) {
|
|
166
166
|
if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
|
|
167
167
|
const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs, deadlineAt });
|
|
168
168
|
if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
|
|
@@ -173,12 +173,14 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
|
|
|
173
173
|
try {
|
|
174
174
|
policy = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
175
175
|
if (policy?.schemaVersion !== 1 || !consentMap(policy.projects)
|
|
176
|
+
|| (Object.hasOwn(policy, 'default') && !['on', 'off'].includes(policy.default))
|
|
176
177
|
|| (Object.hasOwn(policy, 'paths') && !consentMap(policy.paths))) throw new Error('invalid policy');
|
|
177
178
|
contentPathExcludes(policy.contentPathExcludes);
|
|
178
179
|
} catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
|
|
179
180
|
}
|
|
180
181
|
// A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
|
|
181
|
-
const
|
|
182
|
+
const explicitSetting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
|
|
183
|
+
const setting = explicitSetting ?? policy.default;
|
|
182
184
|
if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
|
|
183
185
|
const db = resolved.canonicalAgentDbPath;
|
|
184
186
|
const exclusions = contentPathExcludes(policy.contentPathExcludes);
|
|
@@ -191,6 +193,9 @@ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTi
|
|
|
191
193
|
if (setting === 'off') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
|
|
192
194
|
let exists = false;
|
|
193
195
|
try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
|
|
196
|
+
if (!exists && setting === 'on' && explicitSetting !== 'on' && !globalEnrollmentAllowed({ resolution: resolved, brainHome, ...enrollmentRoots })) {
|
|
197
|
+
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'global capture consent requires a safe Git project root; explicit project/path opt-in required' };
|
|
198
|
+
}
|
|
194
199
|
if (!exists && setting !== 'on') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
|
|
195
200
|
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on',
|
|
196
201
|
contentPathExcludes: exclusions };
|
|
@@ -292,14 +297,14 @@ export function captureTurnOutcome({
|
|
|
292
297
|
if (deferTo) {
|
|
293
298
|
report.skipped = 'deferred: an enabled user-level Stop writer explicitly targets the canonical turn store; actual native delivery is unproven';
|
|
294
299
|
report.deferredToUserLevel = true;
|
|
295
|
-
} else if (
|
|
300
|
+
} else if (['Stop', 'SessionEnd', 'PreCompact'].includes(event)) {
|
|
296
301
|
const sessionKey = String(payload.session_id || payload.transcript_path || '');
|
|
297
302
|
const message = typeof payload.last_assistant_message === 'string' ? payload.last_assistant_message.trim() : '';
|
|
298
303
|
let turn = { finalText: '', files: [], actions: [] };
|
|
299
|
-
if (
|
|
304
|
+
if (typeof payload.transcript_path === 'string' && payload.transcript_path) {
|
|
300
305
|
// With the closing message in hand, the transcript is read immediately (tool calls are already
|
|
301
306
|
// flushed); without it, wait for the closing message to land, bounded.
|
|
302
|
-
try { turn = claudeTurn(readTranscript(payload.transcript_path, { maxMs: message ? 0 : settleMs })); } catch { /* unreadable */ }
|
|
307
|
+
try { turn = claudeTurn(nativeTurnLines(readTranscript(payload.transcript_path, { maxMs: message || event !== 'Stop' ? 0 : settleMs }), host)); } catch { /* unreadable */ }
|
|
303
308
|
}
|
|
304
309
|
if (message && (message.length >= MIN_OUTCOME_CHARS || message.length >= turn.finalText.length)) turn.finalText = message;
|
|
305
310
|
turn = privateTurn(turn, target.contentPathExcludes, target.capturePath);
|
|
@@ -323,12 +328,12 @@ export function captureTurnOutcome({
|
|
|
323
328
|
catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
|
|
324
329
|
}
|
|
325
330
|
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
|
|
326
|
-
'--path', db, '--no-upsert', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
331
|
+
'--path', db, '--no-upsert', '--require-native', '--append-only', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
327
332
|
// This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
|
|
328
333
|
Object.assign(report, { queued: true, key, value });
|
|
329
334
|
}
|
|
330
335
|
}
|
|
331
|
-
} else report.skipped = `turn
|
|
336
|
+
} else report.skipped = `no turn outcome capture at ${event}`;
|
|
332
337
|
|
|
333
338
|
if (event === 'SessionEnd' || event === 'PreCompact') {
|
|
334
339
|
if (!fs.existsSync(db)) report.distill = { queued: false, skipped: 'no memory db to distill yet' };
|
|
@@ -376,7 +381,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
376
381
|
};
|
|
377
382
|
const results = [];
|
|
378
383
|
for (const queued of steps) {
|
|
379
|
-
let status = 1; let error = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
|
|
384
|
+
let status = 1; let error = null; let skipped = null; let verified = false; let db = ''; let key; let privacyPatterns = [];
|
|
380
385
|
const kind = queued.kind === 'journal' ? 'store' : queued.kind;
|
|
381
386
|
try {
|
|
382
387
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
|
|
@@ -396,7 +401,7 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
396
401
|
binding = queued.kind === 'journal' ? data.binding : queued;
|
|
397
402
|
key = data.key; value = maskExcludedPaths(redactText(data.value), target.contentPathExcludes, target.capturePath);
|
|
398
403
|
if (value !== redactText(data.value)) throw new Error('content exclusions changed; immutable turn retained');
|
|
399
|
-
args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--provenance', 'agent_output'];
|
|
404
|
+
args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--require-native', '--append-only', '--provenance', 'agent_output'];
|
|
400
405
|
} else if (kind === 'distill') {
|
|
401
406
|
if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
402
407
|
const recipe = queued.args;
|
|
@@ -424,21 +429,30 @@ export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spa
|
|
|
424
429
|
const invocation = rufloInvocation(ruflo, args);
|
|
425
430
|
const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
|
|
426
431
|
if (remaining <= 0) throw new Error('turn replay deadline exhausted');
|
|
427
|
-
|
|
428
|
-
|
|
432
|
+
// Measured 2026-10-10 with global Ruflo 3.56.3: cwd=<project>/.swarm litters ruvector.db/.claude-flow there, and
|
|
433
|
+
// CLAUDE_FLOW_MEMORY_PATH=<.swarm> makes Ruflo create a second agentdb-memory.db beside the canonical store.
|
|
434
|
+
// The explicit --path already binds the store, so run from private scratch with neither.
|
|
435
|
+
const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), windowsHide: true,
|
|
436
|
+
cwd: rufloCwdFor(db, { root: rufloScratchRoot({ ...env, RUVNET_BRAIN_HOME: brainHome }) }),
|
|
437
|
+
maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV } };
|
|
438
|
+
delete options.env.CLAUDE_FLOW_MEMORY_PATH;
|
|
429
439
|
const existing = kind === 'store' && queued.journalFile ? read({ ruflo, db, key, run, options }) : null;
|
|
430
440
|
if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
|
|
431
441
|
verified = kind === 'store' && existing === value;
|
|
432
442
|
const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
|
|
433
443
|
status = Number.isInteger(r.status) ? r.status : 1;
|
|
434
|
-
if (kind === 'store' && !verified) verified = read({ ruflo, db, key, run, options }) === value;
|
|
444
|
+
if (kind === 'store' && !verified && status === 0) verified = read({ ruflo, db, key, run, options }) === value;
|
|
435
445
|
if (verified) status = 0;
|
|
446
|
+
if (kind === 'distill' && status === 0) {
|
|
447
|
+
const match = /(?:^|\n)\s*skipped:\s*([^\n]+)/i.exec(String(r.stdout || '').replace(/\x1b\[[0-9;]*m/g, ''));
|
|
448
|
+
if (match) skipped = redactText(match[1]).slice(0, 300);
|
|
449
|
+
}
|
|
436
450
|
if (!verified && (r.error || status !== 0)) error = captureFailureReason(r, status);
|
|
437
451
|
else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
|
|
438
452
|
} catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
|
|
439
453
|
if (error) error = maskExcludedPaths(error, privacyPatterns, projectDir);
|
|
440
454
|
const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
|
|
441
|
-
...(kind === 'store' ? { verified } : {}) };
|
|
455
|
+
...(kind === 'store' ? { verified } : {}), ...(skipped ? { skipped } : {}) };
|
|
442
456
|
results.push(row);
|
|
443
457
|
if (receipts) {
|
|
444
458
|
try { appendReceipt(receipts, row); if (verified && queued.journalFile) acknowledgeJournal(queued.journalFile, db); }
|