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.
- package/README.md +2 -2
- package/bin/install.mjs +9 -4
- package/kb/lifecycle-evidence-retention.mjs +56 -2
- package/kb/verify-citation.mjs +48 -27
- 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 +297 -0
- package/plugin/scripts/continuity-hook-policy.mjs +9 -0
- package/plugin/scripts/continuity-journal.mjs +51 -52
- package/plugin/scripts/ground-ruvnet.sh +17 -3
- 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-outbox.mjs +19 -4
- 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-store-resolver.mjs +16 -7
- package/plugin/scripts/project-transition-hook.mjs +204 -0
- package/plugin/scripts/session-snapshot-hook.mjs +46 -274
- package/plugin/scripts/session-start-budget.mjs +2 -2
- package/plugin/scripts/session-start-core.mjs +5 -0
- package/plugin/scripts/turn-outcome-capture.mjs +246 -70
- package/plugin/scripts/turn-transport-journal.mjs +106 -0
- package/scripts/learning-replay-cli.mjs +15 -0
- package/scripts/learning-replay.mjs +1 -1
- package/scripts/nightly-wrapper.sh +5 -5
- package/scripts/release-qualification-contract.mjs +87 -0
- package/scripts/release-qualification.mjs +10 -1
- package/scripts/wired-check.mjs +1 -0
|
@@ -10,9 +10,8 @@
|
|
|
10
10
|
* is the RECORDING tier; distill mines it into episodes/reasoning_patterns/
|
|
11
11
|
* causal_edges, the KNOWLEDGE tier, which stays empty unless it is run)
|
|
12
12
|
*
|
|
13
|
-
* WHERE: the project's `.swarm/memory.db`
|
|
14
|
-
*
|
|
15
|
-
* inside a project — see session-snapshot-hook.mjs's writeSessionSnapshot for why that is trespass.
|
|
13
|
+
* WHERE: only the canonical project's `.swarm/memory.db` (including linked worktrees).
|
|
14
|
+
* An absent store requires persisted opt-in; there is no automatic machine-wide fallback.
|
|
16
15
|
*
|
|
17
16
|
* WHAT (measured facts carried over from the owner's local Claude hook, 2026-09-29):
|
|
18
17
|
* • The Brain's continuation gate continues most turns, so the turn's REAL final Stop carries
|
|
@@ -32,9 +31,8 @@
|
|
|
32
31
|
*
|
|
33
32
|
* LATENCY: Stop runs synchronously in the host's turn, and one `ruflo memory store` costs ~0.7s
|
|
34
33
|
* (measured). So the writes run in ONE detached worker (this file, `--run-steps`), store then
|
|
35
|
-
* distill in order; the hook itself only reads, fingerprints and spawns. A
|
|
36
|
-
*
|
|
37
|
-
* write is visible rather than silent. Advisory always: nothing here throws to the caller.
|
|
34
|
+
* distill in order; the hook itself only reads, fingerprints and spawns. A project-local journal containing redacted content and canonical binding is fsynced BEFORE
|
|
35
|
+
* spawning; fresh sessions replay stable keys without upsert and require exact readback receipts. Advisory always: nothing here throws to the caller.
|
|
38
36
|
*/
|
|
39
37
|
import fs from 'node:fs';
|
|
40
38
|
import os from 'node:os';
|
|
@@ -43,7 +41,9 @@ import crypto from 'node:crypto';
|
|
|
43
41
|
import { spawn, spawnSync } from 'node:child_process';
|
|
44
42
|
import { fileURLToPath } from 'node:url';
|
|
45
43
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
46
|
-
import { userLevelAgentdbHooks } from './continuity-events.mjs';
|
|
44
|
+
import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
|
|
45
|
+
import { resolveProjectStore } from './project-store-resolver.mjs';
|
|
46
|
+
import { digest, journalTurn, readJournal, pendingTurnFiles, acknowledgeJournal, appendReceipt, storeData } from './turn-transport-journal.mjs';
|
|
47
47
|
|
|
48
48
|
export const TURN_NAMESPACE = 'turns';
|
|
49
49
|
export const MIN_OUTCOME_CHARS = 200;
|
|
@@ -87,7 +87,7 @@ export function claudeTurn(lines) {
|
|
|
87
87
|
if ((o?.message?.role || o?.role) !== 'assistant') continue;
|
|
88
88
|
const c = o.message?.content;
|
|
89
89
|
const t = textOf(c).trim();
|
|
90
|
-
if (t) texts.push(t);
|
|
90
|
+
if (t) texts.push(redactText(t));
|
|
91
91
|
if (!Array.isArray(c)) continue;
|
|
92
92
|
for (const u of c) {
|
|
93
93
|
if (!u || u.type !== 'tool_use') continue;
|
|
@@ -138,48 +138,108 @@ export function readSettledTranscript(file, { stableMs = 400, maxMs = 2000, slee
|
|
|
138
138
|
|
|
139
139
|
export function buildTurnRecord({ turn, project, host, session, at = new Date() }) {
|
|
140
140
|
const parts = [`[turn ${at.toISOString()} project=${project} host=${host}]`,
|
|
141
|
-
`OUTCOME: ${turn.finalText.replace(/\s+/g, ' ').slice(0, 2500)}`];
|
|
142
|
-
if (turn.files.length) parts.push(`FILES CHANGED: ${turn.files.slice(0, 25).join(', ')}`);
|
|
143
|
-
if (turn.actions.length) parts.push(`ACTIONS: ${turn.actions.slice(-15).join(' • ')}`);
|
|
141
|
+
`OUTCOME: ${redactText(turn.finalText).replace(/\s+/g, ' ').slice(0, 2500)}`];
|
|
142
|
+
if (turn.files.length) parts.push(`FILES CHANGED: ${turn.files.slice(0, 25).map(redactText).join(', ')}`);
|
|
143
|
+
if (turn.actions.length) parts.push(`ACTIONS: ${turn.actions.slice(-15).map(redactText).join(' • ')}`);
|
|
144
144
|
parts.push(`SESSION: ${session || '?'}`);
|
|
145
|
-
return parts.join(' || ').slice(0, 4000);
|
|
145
|
+
return redactText(parts.join(' || ')).slice(0, 4000);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
-
/**
|
|
149
|
-
export function
|
|
150
|
-
|
|
151
|
-
try { if (fs.statSync(projectDb).isFile()) return { db: projectDb, scope: 'project' }; } catch { /* absent */ }
|
|
152
|
-
const globalDb = env.RUVNET_TURN_GLOBAL_DB || path.join(home, '.claude', 'global-memory', '.swarm', 'memory.db');
|
|
153
|
-
return { db: globalDb, scope: 'global' };
|
|
148
|
+
/** Persisted per-canonical-project/per-path consent, reread at every boundary (no restart). */
|
|
149
|
+
export function turnCapturePolicyFile(brainHome) {
|
|
150
|
+
return path.join(brainHome, 'turn-capture', 'policy.json');
|
|
154
151
|
}
|
|
155
152
|
|
|
156
|
-
const
|
|
153
|
+
const consentMap = (value) => value !== null && typeof value === 'object'
|
|
154
|
+
&& !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
|
|
155
|
+
&& Object.values(value).every((setting) => setting === 'on' || setting === 'off');
|
|
156
|
+
|
|
157
|
+
export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false } = {}) {
|
|
158
|
+
const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs });
|
|
159
|
+
const capturePath = fs.realpathSync.native(projectDir);
|
|
160
|
+
let policy = {};
|
|
161
|
+
const file = brainHome && turnCapturePolicyFile(brainHome);
|
|
162
|
+
if (file && fs.existsSync(file)) {
|
|
163
|
+
try {
|
|
164
|
+
policy = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
165
|
+
if (policy?.schemaVersion !== 1 || !consentMap(policy.projects)
|
|
166
|
+
|| (Object.hasOwn(policy, 'paths') && !consentMap(policy.paths))) throw new Error('invalid policy');
|
|
167
|
+
} catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
|
|
168
|
+
}
|
|
169
|
+
// A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
|
|
170
|
+
const setting = policy.paths?.[capturePath] ?? policy.projects?.[resolved.projectRoot];
|
|
171
|
+
if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
|
|
172
|
+
const db = resolved.canonicalAgentDbPath;
|
|
173
|
+
if (unknownOriginalPath && Object.entries(policy.paths || {}).some(([origin, choice]) => choice === 'off'
|
|
174
|
+
&& (origin === resolved.checkoutRoot || origin.startsWith(`${resolved.checkoutRoot}${path.sep}`)))) {
|
|
175
|
+
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'capture origin cannot be verified under path opt-out' };
|
|
176
|
+
}
|
|
177
|
+
assertTurnStoreFiles(db);
|
|
178
|
+
if (setting === 'off') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
|
|
179
|
+
let exists = false;
|
|
180
|
+
try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
|
|
181
|
+
if (!exists && setting !== 'on') return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
|
|
182
|
+
return { db, scope: 'project', capturePath, projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Revalidation narrows the queue-to-launch window; it does not make SQLite's later open atomic.
|
|
186
|
+
function assertTurnStoreFiles(db) {
|
|
187
|
+
const directory = path.dirname(db);
|
|
188
|
+
let stat;
|
|
189
|
+
try { stat = fs.lstatSync(directory); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
190
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync.native(directory) !== directory)) {
|
|
191
|
+
throw new Error('store directory is not a canonical regular directory');
|
|
192
|
+
}
|
|
193
|
+
for (const file of [db, `${db}-wal`, `${db}-shm`, `${db}-journal`]) {
|
|
194
|
+
let entry;
|
|
195
|
+
try { entry = fs.lstatSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
196
|
+
if (entry && (!entry.isFile() || entry.isSymbolicLink() || entry.nlink > 1)) {
|
|
197
|
+
throw new Error('store or SQLite side file is a symlink, hard link or non-regular file');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const projectName = (projectDir) => redactText(path.basename(path.resolve(projectDir))).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
|
|
157
203
|
|
|
158
204
|
/** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
|
|
159
|
-
export function launchDetached(steps, { receipts }) {
|
|
160
|
-
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps, receipts })], {
|
|
161
|
-
cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...
|
|
205
|
+
export function launchDetached(steps, { receipts, brainHome, projectDir, env = process.env }) {
|
|
206
|
+
const child = spawn(process.execPath, [fileURLToPath(import.meta.url), '--run-steps', JSON.stringify({ steps: steps.map((step) => step.kind === 'store' && step.journalFile ? { kind: 'journal', journalFile: step.journalFile, db: step.args[step.args.indexOf('--path') + 1] } : step), receipts })], {
|
|
207
|
+
cwd: os.tmpdir(), detached: true, stdio: 'ignore', windowsHide: true, env: { ...env, ...RUFLO_ENV, ...(brainHome ? { RUVNET_BRAIN_HOME: brainHome } : {}), ...(projectDir ? { RUVNET_TURN_PROJECT_DIR: projectDir } : {}) },
|
|
162
208
|
});
|
|
163
209
|
child.unref();
|
|
164
210
|
return { launched: true, pid: child.pid };
|
|
165
211
|
}
|
|
166
212
|
|
|
167
|
-
function
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
213
|
+
function readTurnState(stateFile) {
|
|
214
|
+
try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; }
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sameTurnSeen(stateFile, sessionKey, fingerprint, receipts) {
|
|
218
|
+
const previous = readTurnState(stateFile)[sessionKey];
|
|
219
|
+
if (previous?.fingerprint !== fingerprint) return false;
|
|
220
|
+
try {
|
|
221
|
+
const rows = readTail(receipts, 128 * 1024).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
|
|
222
|
+
const receipt = rows.findLast((r) => r.kind === 'store' && r.key === previous.key);
|
|
223
|
+
if (receipt) return receipt.status === 0 && receipt.verified === true;
|
|
224
|
+
} catch { /* queued worker has no receipt yet */ }
|
|
225
|
+
// An in-flight worker gets a bounded grace period; a killed/lost worker cannot consume the turn forever.
|
|
226
|
+
return Date.now() - previous.at < 2 * STEP_TIMEOUT_MS + 10_000;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function markTurnQueued(stateFile, sessionKey, fingerprint, key) {
|
|
230
|
+
const last = readTurnState(stateFile);
|
|
171
231
|
const keys = Object.keys(last);
|
|
172
232
|
for (const k of keys.slice(0, Math.max(0, keys.length - 200))) delete last[k];
|
|
173
233
|
try {
|
|
174
234
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 });
|
|
175
|
-
fs.writeFileSync(stateFile, JSON.stringify({ ...last, [sessionKey]: fingerprint }), { mode: 0o600 });
|
|
235
|
+
fs.writeFileSync(stateFile, JSON.stringify({ ...last, [sessionKey]: { fingerprint, key, at: Date.now() } }), { mode: 0o600 });
|
|
176
236
|
} catch { /* dedupe is best effort; a duplicate record beats a lost one */ }
|
|
177
|
-
return false;
|
|
178
237
|
}
|
|
179
238
|
|
|
180
239
|
/**
|
|
181
240
|
* Capture this turn's outcome (Stop) and/or queue distillation (SessionEnd, PreCompact).
|
|
182
|
-
* Returns a plain report; `
|
|
241
|
+
* Returns a plain report; `queued` means a worker request, never proof the store committed.
|
|
242
|
+
* `skipped` / `distill.skipped` carry the reason whenever nothing is requested.
|
|
183
243
|
*/
|
|
184
244
|
export function captureTurnOutcome({
|
|
185
245
|
projectDir, event, payload = {}, host = 'claude',
|
|
@@ -191,15 +251,21 @@ export function captureTurnOutcome({
|
|
|
191
251
|
settleMs = 2000,
|
|
192
252
|
now = () => new Date(),
|
|
193
253
|
} = {}) {
|
|
194
|
-
const report = { event, host, recorded: false, distill: { queued: false } };
|
|
254
|
+
const report = { event, host, queued: false, recorded: false, distill: { queued: false } };
|
|
195
255
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') {
|
|
196
256
|
return { ...report, skipped: 'RUVNET_TURN_CAPTURE=off', distill: { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
|
|
197
257
|
}
|
|
198
258
|
if (!ruflo) return { ...report, skipped: 'ruflo not found', distill: { queued: false, skipped: 'ruflo not found' } };
|
|
199
|
-
|
|
259
|
+
let target;
|
|
260
|
+
try { target = resolveTurnDb({ projectDir, brainHome }); } catch (error) { return { ...report, skipped: `store resolution failed: ${redactText(error.message)}` }; }
|
|
261
|
+
const { db, scope, projectRoot } = target;
|
|
200
262
|
Object.assign(report, { db, scope });
|
|
263
|
+
if (target.skipped) return { ...report, skipped: target.skipped, distill: { queued: false, skipped: target.skipped } };
|
|
201
264
|
const steps = [];
|
|
202
|
-
const
|
|
265
|
+
const rootStat = fs.statSync(projectRoot);
|
|
266
|
+
const binding = { projectRoot, projectDir: fs.realpathSync.native(projectDir), brainHome, rootIdentity: `${rootStat.dev}:${rootStat.ino}` };
|
|
267
|
+
let dedupe;
|
|
268
|
+
const project = projectName(projectRoot);
|
|
203
269
|
const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
|
|
204
270
|
|
|
205
271
|
// ONE WRITER PER TURN (ADR-100 §3). Measured 2026-10-01 on this repo's real store: 624 `turns` rows
|
|
@@ -228,14 +294,23 @@ export function captureTurnOutcome({
|
|
|
228
294
|
} else {
|
|
229
295
|
const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
|
|
230
296
|
const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
|
|
231
|
-
|
|
297
|
+
const identity = crypto.createHash('sha256').update(`${host}:${db}:${sessionKey}`).digest('hex');
|
|
298
|
+
if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
|
|
232
299
|
else {
|
|
233
300
|
const at = now();
|
|
234
|
-
const key = `turn-${project}-${
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
301
|
+
const key = `turn-${project}-${digest(`${identity}:${fingerprint}`).slice(0, 40)}`;
|
|
302
|
+
dedupe = { stateFile, identity, fingerprint, key };
|
|
303
|
+
let value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
|
|
304
|
+
// Retry uses the original durable content (including its timestamp), never a new value.
|
|
305
|
+
const pendingFile = path.join(path.dirname(db), 'turn-outbox', `${digest(key)}.json`);
|
|
306
|
+
if (fs.existsSync(pendingFile)) {
|
|
307
|
+
try { const pending = readJournal(pendingFile, db); value = pending.value; }
|
|
308
|
+
catch (error) { return { ...report, skipped: `durable turn queue invalid: ${redactText(error.message)}` }; }
|
|
309
|
+
}
|
|
310
|
+
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
|
|
311
|
+
'--path', db, '--no-upsert', '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
312
|
+
// This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
|
|
313
|
+
Object.assign(report, { queued: true, key, value });
|
|
239
314
|
}
|
|
240
315
|
}
|
|
241
316
|
} else report.skipped = `turn outcomes are recorded at Stop, not ${event}`;
|
|
@@ -243,60 +318,161 @@ export function captureTurnOutcome({
|
|
|
243
318
|
if (event === 'SessionEnd' || event === 'PreCompact') {
|
|
244
319
|
if (!fs.existsSync(db)) report.distill = { queued: false, skipped: 'no memory db to distill yet' };
|
|
245
320
|
else {
|
|
246
|
-
steps.push({ kind: 'distill', ruflo, args: ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'] });
|
|
321
|
+
steps.push({ ...binding, kind: 'distill', ruflo, args: ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'] });
|
|
247
322
|
report.distill = { queued: true, db };
|
|
248
323
|
}
|
|
249
324
|
}
|
|
250
325
|
if (!steps.length) return report;
|
|
251
326
|
|
|
252
327
|
try {
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
|
|
328
|
+
// Only explicit persisted consent permits creating a project store directory.
|
|
329
|
+
if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
|
|
330
|
+
if (report.queued) {
|
|
331
|
+
const step = steps.find((item) => item.kind === 'store');
|
|
332
|
+
step.journalFile = journalTurn(step, db, report.key, { onDurability: (evidence) => { report.durability = evidence; } });
|
|
257
333
|
fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
|
|
258
|
-
`${JSON.stringify({ ts: Date.now(), key: report.key,
|
|
334
|
+
`${JSON.stringify({ ts: Date.now(), key: report.key, hash: crypto.createHash('sha256').update(report.value).digest('hex'), len: report.value.length })}\n`, { mode: 0o600 });
|
|
259
335
|
}
|
|
260
|
-
report.launch = launch(steps, { receipts });
|
|
336
|
+
report.launch = launch(steps, { receipts, brainHome, projectDir: binding.projectDir, env });
|
|
337
|
+
if (dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
|
|
261
338
|
} catch (error) {
|
|
262
|
-
return { ...report, recorded: false, distill: { queued: false, skipped: `launch failed: ${error.message}` }, skipped: `launch failed: ${error.message}` };
|
|
339
|
+
return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: `launch failed: ${redactText(error.message)}` }, skipped: `launch failed: ${redactText(error.message)}` };
|
|
263
340
|
}
|
|
264
341
|
return report;
|
|
265
342
|
}
|
|
266
343
|
|
|
267
|
-
/**
|
|
268
|
-
|
|
344
|
+
/** Exact (namespace, key, content) readback. Exit zero alone never proves capture. */
|
|
345
|
+
function readBack({ ruflo, db, key, run, options }) {
|
|
346
|
+
const invocation = rufloInvocation(ruflo, ['memory', 'retrieve', '--key', key, '--namespace', TURN_NAMESPACE, '--value-only', '--path', db]);
|
|
347
|
+
const result = run(invocation.executable, invocation.args, options);
|
|
348
|
+
return result.status === 0 ? String(result.stdout || '').trim() : null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** The detached worker: bounded steps, safe error evidence, exact content readback. */
|
|
352
|
+
export function runSteps({ steps = [], receipts } = {}, { run: suppliedRun = spawnSync, read = readBack,
|
|
353
|
+
env = process.env, deadlineMs, home = os.homedir(),
|
|
354
|
+
projectDir = env.RUVNET_TURN_PROJECT_DIR || process.cwd(),
|
|
355
|
+
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain') } = {}) {
|
|
356
|
+
const run = (binary, args, options) => {
|
|
357
|
+
if (deadlineMs === undefined) return suppliedRun(binary, args, options);
|
|
358
|
+
const remaining = deadlineMs - Date.now();
|
|
359
|
+
if (remaining <= 0) return { status: 1, error: new Error('turn replay deadline exhausted') };
|
|
360
|
+
return suppliedRun(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
|
|
361
|
+
};
|
|
269
362
|
const results = [];
|
|
270
|
-
for (const
|
|
271
|
-
let status = null;
|
|
272
|
-
|
|
273
|
-
const db = step.args[step.args.indexOf(step.kind === 'store' ? '--path' : '--db') + 1];
|
|
363
|
+
for (const queued of steps) {
|
|
364
|
+
let status = 1; let error = null; let verified = false; let db = ''; let key;
|
|
365
|
+
const kind = queued.kind === 'journal' ? 'store' : queued.kind;
|
|
274
366
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
367
|
+
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
|
|
368
|
+
if (kind === 'store' && queued.kind !== 'journal') { const data = storeData(queued); key = data.key; db = data.db; }
|
|
369
|
+
if (queued.kind !== 'journal' && (!queued.projectRoot || !queued.projectDir || !queued.brainHome)) throw new Error('queued step has no canonical project binding');
|
|
370
|
+
const target = resolveTurnDb({ projectDir, brainHome, gitTimeoutMs: 1000 });
|
|
371
|
+
if (target.skipped) throw new Error(target.skipped);
|
|
372
|
+
db = target.db;
|
|
373
|
+
let args; let value; let binding;
|
|
374
|
+
if (kind === 'store') {
|
|
375
|
+
const data = queued.kind === 'journal' ? readJournal(queued.journalFile, db) : storeData(queued);
|
|
376
|
+
if (queued.kind === 'journal' && queued.db !== db) throw new Error('foreign turn queue store rejected');
|
|
377
|
+
if (queued.kind !== 'journal' && data.db !== db) throw new Error('foreign turn store rejected');
|
|
378
|
+
if (data.legacyBrainHome && data.legacyBrainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
379
|
+
if (queued.kind !== 'journal' && queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
380
|
+
binding = queued.kind === 'journal' ? data.binding : queued;
|
|
381
|
+
key = data.key; value = redactText(data.value);
|
|
382
|
+
args = ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE, '--path', db, '--no-upsert', '--provenance', 'agent_output'];
|
|
383
|
+
} else if (kind === 'distill') {
|
|
384
|
+
if (queued.brainHome !== brainHome) throw new Error('queued consent settings differ from trusted caller');
|
|
385
|
+
const recipe = queued.args;
|
|
386
|
+
if (!Array.isArray(recipe) || recipe.slice(0, 3).join(' ') !== 'memory distill run') throw new Error('invalid turn distill operation');
|
|
387
|
+
const seen = new Set();
|
|
388
|
+
for (let i = 3; i < recipe.length; i += 2) {
|
|
389
|
+
const flag = recipe[i]; const value = recipe[i + 1];
|
|
390
|
+
if (seen.has(flag) || !['--db', '--namespace', '--max-entries'].includes(flag) || typeof value !== 'string') throw new Error('invalid turn distill flags');
|
|
391
|
+
seen.add(flag);
|
|
392
|
+
if (flag === '--db' && value !== db || flag === '--namespace' && value !== TURN_NAMESPACE || flag === '--max-entries' && value !== '500') throw new Error('invalid turn distill scope');
|
|
393
|
+
}
|
|
394
|
+
if (!seen.has('--db')) throw new Error('missing turn distill database');
|
|
395
|
+
binding = queued;
|
|
396
|
+
args = ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'];
|
|
397
|
+
} else throw new Error('invalid turn worker operation');
|
|
398
|
+
if (!binding.projectRoot || !binding.projectDir) throw new Error('queued step has no canonical project binding');
|
|
399
|
+
const rootStat = fs.statSync(target.projectRoot);
|
|
400
|
+
if (binding.rootIdentity && binding.rootIdentity !== `${rootStat.dev}:${rootStat.ino}`) throw new Error('queued canonical project identity changed');
|
|
401
|
+
if (binding.projectRoot !== target.projectRoot) throw new Error('queued canonical project/store identity changed');
|
|
402
|
+
const origin = resolveTurnDb({ projectDir: binding.projectDir, brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
403
|
+
if (origin.skipped) throw new Error(origin.skipped);
|
|
404
|
+
if (origin.projectRoot !== target.projectRoot || origin.db !== db) throw new Error('queued canonical capture origin changed');
|
|
405
|
+
const ruflo = resolveRuflo({ env, home });
|
|
406
|
+
if (!ruflo) throw new Error('ruflo not found for durable replay');
|
|
407
|
+
const invocation = rufloInvocation(ruflo, args);
|
|
408
|
+
const remaining = deadlineMs === undefined ? STEP_TIMEOUT_MS : deadlineMs - Date.now();
|
|
409
|
+
if (remaining <= 0) throw new Error('turn replay deadline exhausted');
|
|
410
|
+
const options = { encoding: 'utf8', timeout: Math.min(STEP_TIMEOUT_MS, remaining), cwd: path.dirname(db), windowsHide: true,
|
|
411
|
+
maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: path.dirname(db) } };
|
|
412
|
+
const existing = kind === 'store' && queued.journalFile ? read({ ruflo, db, key, run, options }) : null;
|
|
413
|
+
if (existing !== null && existing !== value) throw new Error('existing turn key content differs; no upsert permitted');
|
|
414
|
+
verified = kind === 'store' && existing === value;
|
|
415
|
+
const r = verified ? { status: 0 } : run(invocation.executable, invocation.args, options);
|
|
416
|
+
status = Number.isInteger(r.status) ? r.status : 1;
|
|
417
|
+
if (kind === 'store' && !verified) verified = read({ ruflo, db, key, run, options }) === value;
|
|
418
|
+
if (verified) status = 0;
|
|
419
|
+
if (!verified && (r.error || status !== 0)) error = redactText(r.error?.message || String(r.stderr || '').trim().split(/\r?\n/)[0] || `ruflo exited ${status}`).slice(0, 300);
|
|
420
|
+
else if (kind === 'store' && !verified) { status = 1; error = 'exact turn key/content readback failed'; }
|
|
421
|
+
} catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
|
|
422
|
+
const row = { at: new Date().toISOString(), kind, db: redactText(db), storeIdentity: digest(db), key, status, error,
|
|
423
|
+
...(kind === 'store' ? { verified } : {}) };
|
|
289
424
|
results.push(row);
|
|
290
425
|
if (receipts) {
|
|
291
|
-
try {
|
|
292
|
-
|
|
293
|
-
fs.appendFileSync(receipts, `${JSON.stringify(row)}\n`, { mode: 0o600 });
|
|
294
|
-
} catch { /* receipts are best effort */ }
|
|
426
|
+
try { appendReceipt(receipts, row); if (verified && queued.journalFile) acknowledgeJournal(queued.journalFile, db); }
|
|
427
|
+
catch { /* failed receipt persistence retains the journal for exact-key retry */ }
|
|
295
428
|
}
|
|
296
429
|
}
|
|
297
430
|
return results;
|
|
298
431
|
}
|
|
299
432
|
|
|
433
|
+
/** Replay only bounded canonical project entries; consent suspends rather than deletes them. */
|
|
434
|
+
export function replayTurnQueue({ projectDir = process.cwd(), env = process.env, home = os.homedir(),
|
|
435
|
+
brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain'), limit = 10,
|
|
436
|
+
launch = launchDetached, synchronous = false, runner, deadlineMs } = {}) {
|
|
437
|
+
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') return { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' };
|
|
438
|
+
try {
|
|
439
|
+
const target = resolveTurnDb({ projectDir, brainHome });
|
|
440
|
+
if (target.skipped) return { queued: false, skipped: target.skipped };
|
|
441
|
+
const steps = pendingTurnFiles(target.db, limit).map((journalFile) => ({ kind: 'journal', journalFile, db: target.db }));
|
|
442
|
+
if (!steps.length) return { queued: false, count: 0 };
|
|
443
|
+
if (synchronous) {
|
|
444
|
+
const results = runSteps({ steps, receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl') }, { env, home, projectDir, brainHome, ...(runner ? { run: runner } : {}), deadlineMs });
|
|
445
|
+
const pending = pendingTurnFiles(target.db, 25).length;
|
|
446
|
+
return { queued: false, count: steps.length, pending, failed: results.filter((row) => row.status !== 0).length, verified: results.filter((row) => row.verified === true).length, results };
|
|
447
|
+
}
|
|
448
|
+
return { queued: true, count: steps.length, pending: steps.length, launch: launch(steps, { receipts: path.join(brainHome, 'turn-capture', 'receipts.jsonl'), projectDir, brainHome, env }) };
|
|
449
|
+
} catch (error) { return { queued: false, skipped: redactText(error.message) }; }
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
|
|
453
|
+
export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now() } = {}) {
|
|
454
|
+
const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
455
|
+
let target;
|
|
456
|
+
try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
|
|
457
|
+
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
|
|
458
|
+
let rows = [];
|
|
459
|
+
try {
|
|
460
|
+
rows = readTail(path.join(brainHome, 'turn-capture', 'receipts.jsonl'), 128 * 1024).flatMap((line) => {
|
|
461
|
+
try { return [JSON.parse(line)]; } catch { return []; }
|
|
462
|
+
}).filter((r) => r.kind === 'store' && (r.storeIdentity === crypto.createHash('sha256').update(target.db).digest('hex') || r.db === target.db) && now - Date.parse(r.at) < 7 * 86_400_000).slice(-20);
|
|
463
|
+
} catch { /* first run */ }
|
|
464
|
+
rows = [...new Map(rows.map((row) => [row.key || row.at, row])).values()];
|
|
465
|
+
let pending = 0;
|
|
466
|
+
try { pending = pendingTurnFiles(target.db, 25).length; } catch { return { state: 'warn', line: 'turn recording durable queue unsafe or unreadable' }; }
|
|
467
|
+
const historical = rows.filter((r) => r.status === 0 && r.verified === undefined);
|
|
468
|
+
const failed = rows.filter((r) => r.status !== 0 || r.verified === false);
|
|
469
|
+
if (failed.length) return { state: 'warn', line: `turn recording failing ${failed.length}/${rows.length} — ${redactText(failed.at(-1).error || 'write has no exact readback evidence').slice(0, 300)}` };
|
|
470
|
+
if (historical.length) return { state: 'unknown', line: `turn recording unverified historical (${historical.length} receipts without exact readback evidence)` };
|
|
471
|
+
if (pending) return { state: 'unknown', line: `turn recording pending durable replay (${pending} entries); not yet proven` };
|
|
472
|
+
return rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
|
|
473
|
+
: { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' };
|
|
474
|
+
}
|
|
475
|
+
|
|
300
476
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--run-steps') {
|
|
301
477
|
try { runSteps(JSON.parse(process.argv[3] || '{}')); } catch { /* a detached worker has no one to report to */ }
|
|
302
478
|
process.exit(0);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
6
|
+
export const turnQueueDirectory = (db) => path.join(path.dirname(db), 'turn-outbox');
|
|
7
|
+
function regular(file, directory = false) {
|
|
8
|
+
const stat = fs.lstatSync(file);
|
|
9
|
+
if (stat.isSymbolicLink() || (!directory && (!stat.isFile() || stat.nlink !== 1))
|
|
10
|
+
|| (directory && (!stat.isDirectory() || fs.realpathSync.native(file) !== file))) throw new Error('unsafe turn journal path');
|
|
11
|
+
}
|
|
12
|
+
// Node's Windows directory handles do not consistently support fsync. Never weaken
|
|
13
|
+
// file fsync or hide unexpected I/O errors; a missing namespace flush is reported.
|
|
14
|
+
export function syncTurnDirectory(dir, { platform = process.platform, io = fs } = {}) {
|
|
15
|
+
let fd;
|
|
16
|
+
try {
|
|
17
|
+
fd = io.openSync(dir, 'r'); io.fsyncSync(fd);
|
|
18
|
+
return { platform, directoryFsync: 'completed' };
|
|
19
|
+
} catch (error) {
|
|
20
|
+
if (platform !== 'win32' || !['EISDIR', 'EPERM', 'EACCES', 'EINVAL', 'ENOTSUP', 'EBADF'].includes(error.code)) throw error;
|
|
21
|
+
return { platform, directoryFsync: 'unavailable', reason: error.code,
|
|
22
|
+
limitation: 'file fsync remains required; directory-entry persistence across power loss is unproven' };
|
|
23
|
+
} finally { if (fd !== undefined) io.closeSync(fd); }
|
|
24
|
+
}
|
|
25
|
+
// Legacy recipes are data extraction inputs only, never executable commands.
|
|
26
|
+
export function storeData(step) {
|
|
27
|
+
if (step?.kind !== 'store' || !Array.isArray(step.args) || step.args[0] !== 'memory' || step.args[1] !== 'store') throw new Error('invalid turn store operation');
|
|
28
|
+
const fields = {}; const aliases = { '-k': 'key', '--key': 'key', '--value': 'value', '-n': 'namespace', '--namespace': 'namespace', '--path': 'db', '--tags': 'tags', '--provenance': 'provenance' };
|
|
29
|
+
let strict = false;
|
|
30
|
+
for (let i = 2; i < step.args.length; i++) {
|
|
31
|
+
const flag = step.args[i];
|
|
32
|
+
if (flag === '--no-upsert') { if (strict) throw new Error('duplicate turn store flag'); strict = true; continue; }
|
|
33
|
+
const field = aliases[flag];
|
|
34
|
+
if (!field || Object.hasOwn(fields, field) || typeof step.args[i + 1] !== 'string') throw new Error('unknown or duplicate turn store flag');
|
|
35
|
+
fields[field] = step.args[++i];
|
|
36
|
+
}
|
|
37
|
+
if (!fields.key || typeof fields.value !== 'string' || fields.namespace !== 'turns' || !path.isAbsolute(fields.db || '')) throw new Error('invalid turn store namespace or data');
|
|
38
|
+
return { key: fields.key, value: fields.value, db: fields.db };
|
|
39
|
+
}
|
|
40
|
+
const bindingOf = (step) => ({ projectRoot: step.projectRoot, projectDir: step.projectDir, rootIdentity: step.rootIdentity });
|
|
41
|
+
function validateBinding(binding) {
|
|
42
|
+
if (!binding || Object.keys(binding).some((key) => !['projectRoot', 'projectDir', 'rootIdentity'].includes(key))
|
|
43
|
+
|| !path.isAbsolute(binding.projectRoot || '') || !path.isAbsolute(binding.projectDir || '')
|
|
44
|
+
|| typeof binding.rootIdentity !== 'string' || !/^\d+:\d+$/.test(binding.rootIdentity)) throw new Error('invalid turn canonical binding');
|
|
45
|
+
}
|
|
46
|
+
export function journalTurn(step, db, key, { platform = process.platform, io = fs, onDurability } = {}) {
|
|
47
|
+
const dir = turnQueueDirectory(db);
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); regular(dir, true);
|
|
49
|
+
const file = path.join(dir, `${digest(key)}.json`);
|
|
50
|
+
const data = storeData(step);
|
|
51
|
+
if (data.db !== db || data.key !== key) throw new Error('turn journal identity mismatch');
|
|
52
|
+
const binding = bindingOf(step); validateBinding(binding);
|
|
53
|
+
const record = { schemaVersion: 2, key, value: data.value, contentDigest: digest(data.value), consentScope: 'canonical-project-and-path', binding };
|
|
54
|
+
let created = false;
|
|
55
|
+
try {
|
|
56
|
+
const fd = io.openSync(file, 'wx', 0o600);
|
|
57
|
+
created = true;
|
|
58
|
+
try { io.writeFileSync(fd, JSON.stringify(record)); io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
59
|
+
} catch (error) {
|
|
60
|
+
if (error.code !== 'EEXIST') throw error;
|
|
61
|
+
const previous = readJournal(file, db);
|
|
62
|
+
if (previous.key !== key || previous.contentDigest !== record.contentDigest) throw new Error('turn journal identity collision');
|
|
63
|
+
// Reflush an existing complete entry: its creator may have died before fsync.
|
|
64
|
+
const fd = io.openSync(file, 'r+');
|
|
65
|
+
try { io.fsyncSync(fd); } finally { io.closeSync(fd); }
|
|
66
|
+
}
|
|
67
|
+
const evidence = { fileFsync: 'completed', entry: created ? 'created-exclusively' : 'existing-exact-match-reflushed', ...syncTurnDirectory(dir, { platform, io }) };
|
|
68
|
+
if (onDurability) onDurability(evidence);
|
|
69
|
+
return file;
|
|
70
|
+
}
|
|
71
|
+
export function readJournal(file, db) {
|
|
72
|
+
const dir = turnQueueDirectory(db); regular(dir, true);
|
|
73
|
+
if (path.dirname(file) !== dir) throw new Error('foreign turn journal rejected');
|
|
74
|
+
regular(file);
|
|
75
|
+
const record = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
76
|
+
let normalized = record;
|
|
77
|
+
if (record.schemaVersion === 1) {
|
|
78
|
+
const data = storeData(record.step);
|
|
79
|
+
if (!record.step.args.includes('--no-upsert')) throw new Error('legacy turn journal requires strict insert');
|
|
80
|
+
if (data.db !== db || data.key !== record.key) throw new Error('legacy turn journal identity mismatch');
|
|
81
|
+
// Legacy job-selected consent settings are never consumed; caller policy governs replay.
|
|
82
|
+
normalized = { schemaVersion: 2, key: data.key, value: data.value, contentDigest: record.contentDigest,
|
|
83
|
+
consentScope: record.consentScope, binding: bindingOf(record.step), legacyBrainHome: record.step.brainHome };
|
|
84
|
+
} else if (Object.keys(record).some((key) => !['schemaVersion', 'key', 'value', 'contentDigest', 'consentScope', 'binding'].includes(key))) throw new Error('unknown turn journal field');
|
|
85
|
+
validateBinding(normalized.binding);
|
|
86
|
+
if (normalized.schemaVersion !== 2 || normalized.consentScope !== 'canonical-project-and-path'
|
|
87
|
+
|| typeof normalized.key !== 'string' || !normalized.key.startsWith('turn-') || normalized.key.length > 250
|
|
88
|
+
|| typeof normalized.value !== 'string' || normalized.value.length > 4000
|
|
89
|
+
|| digest(normalized.value) !== normalized.contentDigest
|
|
90
|
+
|| path.basename(file) !== `${digest(normalized.key)}.json`) throw new Error('invalid turn journal');
|
|
91
|
+
return normalized;
|
|
92
|
+
}
|
|
93
|
+
export function pendingTurnFiles(db, limit = 10) {
|
|
94
|
+
const dir = turnQueueDirectory(db);
|
|
95
|
+
try { regular(dir, true); } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
96
|
+
return fs.readdirSync(dir).filter((name) => /^[a-f0-9]{64}\.json$/.test(name)).sort().slice(0, Math.min(25, Math.max(0, limit))).map((name) => path.join(dir, name));
|
|
97
|
+
}
|
|
98
|
+
export function acknowledgeJournal(file, db, options = {}) {
|
|
99
|
+
readJournal(file, db); fs.unlinkSync(file); return syncTurnDirectory(path.dirname(file), options);
|
|
100
|
+
}
|
|
101
|
+
export function appendReceipt(file, row) {
|
|
102
|
+
fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
|
|
103
|
+
if (fs.existsSync(file)) regular(file);
|
|
104
|
+
const fd = fs.openSync(file, 'a', 0o600);
|
|
105
|
+
try { fs.writeFileSync(fd, `${JSON.stringify(row)}\n`); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
|
106
|
+
}
|
|
@@ -41,6 +41,7 @@ const usage = () => `Usage:
|
|
|
41
41
|
node scripts/learning-replay.mjs [--trap ${TRAP.MEMORY_SEARCH}|${TRAP.POST_TASK}] [--n N] [--host codex|claude-code] [--model MODEL]
|
|
42
42
|
node scripts/learning-replay.mjs --check
|
|
43
43
|
node scripts/learning-replay.mjs --check-portfolio
|
|
44
|
+
node scripts/learning-replay.mjs --measure-portfolio --host codex --model MODEL
|
|
44
45
|
node scripts/learning-replay.mjs --check-mutants
|
|
45
46
|
node scripts/learning-replay.mjs --dry-run
|
|
46
47
|
node scripts/learning-replay.mjs --mutant <${Object.keys(MUTANTS).join('|')}>
|
|
@@ -86,6 +87,19 @@ function printed(label, result) {
|
|
|
86
87
|
return EXIT[result.status] ?? EXIT.UNKNOWN;
|
|
87
88
|
}
|
|
88
89
|
|
|
90
|
+
// A portfolio includes both positive traps AND all four causal mutants. Run every scenario even
|
|
91
|
+
// after a failure, then let the unchanged evidence checker decide the result; child exit 1 is the
|
|
92
|
+
// expected outcome of a causal mutant, and is never by itself a successful portfolio measurement.
|
|
93
|
+
export async function measurePortfolio({ host, model, execute = main, check = checkPortfolio }) {
|
|
94
|
+
for (const trap of [TRAP.MEMORY_SEARCH, TRAP.POST_TASK]) {
|
|
95
|
+
await execute(['--trap', trap, '--n', '3', '--host', host, '--model', model]);
|
|
96
|
+
for (const mutant of ['delete-lesson', 'brain-off-treated']) {
|
|
97
|
+
await execute(['--trap', trap, '--mutant', mutant, '--n', '1', '--host', host, '--model', model]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return printed(`${INVARIANT}-PORTFOLIO`, check());
|
|
101
|
+
}
|
|
102
|
+
|
|
89
103
|
export async function main(argv = process.argv.slice(2)) {
|
|
90
104
|
const { has, arg } = parse(argv);
|
|
91
105
|
if (has('--help') || has('-h')) {
|
|
@@ -113,6 +127,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
113
127
|
}
|
|
114
128
|
if (has('--check-portfolio')) return printed(`${INVARIANT}-PORTFOLIO`, checkPortfolio());
|
|
115
129
|
if (has('--check-mutants')) return printed(`${INVARIANT}-MUTANTS`, checkMutantArtifacts());
|
|
130
|
+
if (has('--measure-portfolio')) return measurePortfolio({ host, model });
|
|
116
131
|
|
|
117
132
|
const source = checkSourceIdentity();
|
|
118
133
|
if (!source.clean) {
|
|
@@ -8,7 +8,7 @@ export * from './learning-replay-contract.mjs';
|
|
|
8
8
|
export * from './learning-replay-execution.mjs';
|
|
9
9
|
export * from './learning-replay-fixture.mjs';
|
|
10
10
|
export * from './learning-replay-proof.mjs';
|
|
11
|
-
export { main } from './learning-replay-cli.mjs';
|
|
11
|
+
export { main, measurePortfolio } from './learning-replay-cli.mjs';
|
|
12
12
|
|
|
13
13
|
const invokedDirectly = process.argv[1]
|
|
14
14
|
&& path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
@@ -177,14 +177,14 @@ sh scripts/memdb-health.sh "$RUVNET_PROJECT_MEMORY_DB" >> "$LOG" 2>&1 \
|
|
|
177
177
|
&& echo "===== memdb-health canary: OK =====" >> "$LOG" \
|
|
178
178
|
|| echo "===== memdb-health canary: UNHEALTHY (see line above) =====" >> "$LOG"
|
|
179
179
|
|
|
180
|
-
# ── LEARNING-REPLAY: the D4 counterfactual
|
|
181
|
-
#
|
|
180
|
+
# ── LEARNING-REPLAY: the D4 counterfactual portfolio (ADR-058 §D4). This manual author run spends
|
|
181
|
+
# real model tokens: two N=3 positive traps and four N=1 causal mutants, with two arms per run.
|
|
182
182
|
#
|
|
183
183
|
# It runs HERE, and not on a GitHub runner, because the trap needs credentials a bare runner does not
|
|
184
184
|
# have — a real model session AND the real global `ruflo` binary (it records into a fixture project's
|
|
185
185
|
# .swarm/memory.db and refreshes it with `ruflo memory distill`). .github/workflows/learning-replay.yml
|
|
186
|
-
# is the currency gate on the
|
|
187
|
-
# on staleness
|
|
186
|
+
# is the currency gate on the evidence this writes; if evidence stops being refreshed, it goes red
|
|
187
|
+
# on staleness. This wrapper is an author diagnostic, not an installed scheduler or publisher.
|
|
188
188
|
#
|
|
189
189
|
# Best-effort, same shape as the canaries below: it never blocks the rebuild. Its exit code is not
|
|
190
190
|
# thrown away though — it is written to the log by name, because 0/1/3/4 are four different facts
|
|
@@ -222,7 +222,7 @@ echo "===== RELEASE-CONVERGENCE watchdog — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
|
222
222
|
|| echo "[release-watchdog] exited non-zero — see above; nightly continues" >> "$LOG"
|
|
223
223
|
|
|
224
224
|
echo "===== LEARNING-REPLAY counterfactual trap — $(date -u +%FT%TZ) =====" >> "$LOG"
|
|
225
|
-
"$NODE_BIN" scripts/learning-replay.mjs --
|
|
225
|
+
"$NODE_BIN" scripts/learning-replay.mjs --measure-portfolio --host codex --model gpt-6.1-sol >> "$LOG" 2>&1
|
|
226
226
|
LR_RC=$?
|
|
227
227
|
case "$LR_RC" in
|
|
228
228
|
0) echo "===== LEARNING-REPLAY: PASS =====" >> "$LOG" ;;
|