ruvnet-brain 4.5.2 → 4.5.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 +2 -2
- package/bin/install.mjs +5 -0
- 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/scripts/agentdb-recall.mjs +226 -0
- package/plugin/scripts/continuity-journal.mjs +20 -21
- package/plugin/scripts/ground-ruvnet.sh +15 -1
- package/plugin/scripts/project-progression-outbox.mjs +19 -4
- package/plugin/scripts/project-store-resolver.mjs +16 -7
- package/plugin/scripts/session-snapshot-hook.mjs +2 -2
- package/plugin/scripts/session-start-core.mjs +5 -0
- package/plugin/scripts/turn-outcome-capture.mjs +154 -56
- 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 +74 -0
- package/scripts/release-qualification.mjs +10 -1
- package/scripts/wired-check.mjs +1 -0
|
@@ -67,6 +67,13 @@ if printf '%s' "$TEXT" | grep -qiE '\[Your previous response|\[Request interrupt
|
|
|
67
67
|
exit 0
|
|
68
68
|
fi
|
|
69
69
|
|
|
70
|
+
# Read canonical memory before the quiet path; useful evidence may exist even
|
|
71
|
+
# for a worktree with no local .swarm. Acks/off/no-match keep the cheap return.
|
|
72
|
+
_ADB_OUT=""
|
|
73
|
+
if [ "${RUVNET_AGENTDB_FIRST:-on}" != "off" ]; then
|
|
74
|
+
_ADB_OUT=$(printf '%s' "$INPUT" | "${RUVNET_NODE_BIN:-node}" "$(dirname "$0")/agentdb-recall.mjs" 2>/dev/null)
|
|
75
|
+
fi
|
|
76
|
+
|
|
70
77
|
# ── QUIET-PROMPT FAST PATH. ────────────────────────────────────────────────────────────────────
|
|
71
78
|
# A hook whose output contract is silence must not pay the full stack-currency/project-state scan
|
|
72
79
|
# before discovering that nothing can fire. This mattered on a packed Windows install: immediately
|
|
@@ -83,7 +90,7 @@ QUICK_RELEVANT=0
|
|
|
83
90
|
if printf '%s' "$TEXT" | grep -qiE 'ruvnet|ruflo|ruvector|rvf|agentdb|agenticow|rulake|ruview|rupixel|ruv-fann|agentic-flow|synthlang|dspy|qudag|safla|metaharness|cve-bench|sparc|swarm|claude-flow|pinecone|pgvector|chroma|weaviate|faiss|milvus|qdrant|hnswlib|annoy|vector|langchain|llama|autogen|crew-ai|semantic-kernel|embedding|retrieval|prompt compression|token cost|post-quantum|quantum-resistant|adr|decision|architect|design|plan|spec|refactor|migrat|implement|build|write|add|change|fix|update|deploy|create|enhance|set up|setup|wire|integrate|test|coverage|audit|review|benchmark|lint|scan|debug|optimi|app|feature|service|system|backend|frontend|api|module|pipeline|infra|database|schema|workflow|roadmap|milestone|autonomous|unattended|do not stop|keep working|keep going|soak run|harness|quality|readiness|evolve|self-improv|hardening|cheaper|cheap|lower cost|compute arbitrage|cascade|scorecard|score .*repo'; then
|
|
84
91
|
QUICK_RELEVANT=1
|
|
85
92
|
fi
|
|
86
|
-
if [ "$QUICK_RUFLO" -eq 0 ] && [ "$QUICK_RELEVANT" -eq 0 ] && [ "${RUVNET_AUTONOMOUS:-0}" != "1" ]; then
|
|
93
|
+
if [ "$QUICK_RUFLO" -eq 0 ] && [ "$QUICK_RELEVANT" -eq 0 ] && [ "${RUVNET_AUTONOMOUS:-0}" != "1" ] && [ -z "$_ADB_OUT" ]; then
|
|
87
94
|
exit 0
|
|
88
95
|
fi
|
|
89
96
|
|
|
@@ -138,6 +145,13 @@ inj_seen() {
|
|
|
138
145
|
}
|
|
139
146
|
inj_mark() { [ -n "$INJ_DIR" ] && mkdir -p "$INJ_DIR" 2>/dev/null && : > "$INJ_DIR/$1" 2>/dev/null; return 0; }
|
|
140
147
|
|
|
148
|
+
# ── Canonical AgentDB recall (ADR-101): every nontrivial human prompt. ──────────────
|
|
149
|
+
# Priority 0 and digest dedupe preserve safety output and avoid re-sending evidence.
|
|
150
|
+
if [ -n "$_ADB_OUT" ]; then
|
|
151
|
+
_ADB_ID=$(printf '%s\n' "$_ADB_OUT" | head -n 1 | tr -cd 'A-Za-z0-9' | cut -c1-12)
|
|
152
|
+
[ -n "$_ADB_ID" ] && printf '%s\n' "$_ADB_OUT" | sed '1d' | out_to "0-0-agentdb-recall-$_ADB_ID"
|
|
153
|
+
fi
|
|
154
|
+
|
|
141
155
|
# ── Gate 0: STACK WATCHDOG (always fires) — filesystem ground truth, not impressions. ───────────
|
|
142
156
|
# Runs in the project's cwd every prompt, FROM the loaded plugin's own dir — so $CLAUDE_PLUGIN_ROOT
|
|
143
157
|
# is the RUNNING (in-memory) version by construction, never the staged disk copy. Checks what's
|
|
@@ -23,10 +23,22 @@ export class ProgressionOutbox {
|
|
|
23
23
|
appendRecord(record) {
|
|
24
24
|
fs.mkdirSync(path.dirname(this.path), { recursive: true, mode: 0o700 });
|
|
25
25
|
const noFollow = fs.constants.O_NOFOLLOW ?? 0;
|
|
26
|
-
const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.
|
|
26
|
+
const fd = fs.openSync(this.path, fs.constants.O_APPEND | fs.constants.O_CREAT | fs.constants.O_RDWR | noFollow, 0o600);
|
|
27
27
|
try {
|
|
28
|
+
let separator = '';
|
|
29
|
+
const size = fs.fstatSync(fd).size;
|
|
30
|
+
if (size) {
|
|
31
|
+
const last = Buffer.alloc(1);
|
|
32
|
+
fs.readSync(fd, last, 0, 1, size - 1);
|
|
33
|
+
if (last[0] !== 10) {
|
|
34
|
+
const content = fs.readFileSync(fd, 'utf8');
|
|
35
|
+
try { JSON.parse(content.slice(content.lastIndexOf('\n') + 1)); }
|
|
36
|
+
catch { throw new Error('incomplete outbox final record; preserve and recover the torn tail before appending'); }
|
|
37
|
+
separator = '\n';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
28
40
|
fs.fchmodSync(fd, 0o600);
|
|
29
|
-
writeAll(fd, `${JSON.stringify(record)}\n`);
|
|
41
|
+
writeAll(fd, `${separator}${JSON.stringify(record)}\n`);
|
|
30
42
|
this.fsync(fd);
|
|
31
43
|
} finally {
|
|
32
44
|
fs.closeSync(fd);
|
|
@@ -58,8 +70,11 @@ export class ProgressionOutbox {
|
|
|
58
70
|
if (!fs.existsSync(this.path)) return [];
|
|
59
71
|
const content = fs.readFileSync(this.path, 'utf8');
|
|
60
72
|
const lines = content.split('\n');
|
|
61
|
-
if (lines.at(-1)
|
|
62
|
-
else
|
|
73
|
+
if (lines.at(-1) === '') lines.pop();
|
|
74
|
+
else {
|
|
75
|
+
// A missing delimiter is not a missing record; only a crash-torn JSON suffix is incomplete.
|
|
76
|
+
try { JSON.parse(lines.at(-1)); } catch { lines.pop(); }
|
|
77
|
+
}
|
|
63
78
|
return lines.filter(Boolean).map((line, index) => {
|
|
64
79
|
try { return JSON.parse(line); } catch { throw new Error(`malformed outbox record at line ${index + 1}`); }
|
|
65
80
|
});
|
|
@@ -40,21 +40,24 @@ function isWithin(root, candidate) {
|
|
|
40
40
|
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function gitValue(cwd, args) {
|
|
43
|
+
function gitValue(cwd, args, gitTimeoutMs) {
|
|
44
44
|
try {
|
|
45
45
|
return execFileSync('git', args, {
|
|
46
46
|
cwd,
|
|
47
47
|
encoding: 'utf8',
|
|
48
48
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
49
|
+
...(gitTimeoutMs ? { timeout: gitTimeoutMs, killSignal: 'SIGKILL' } : {}),
|
|
49
50
|
}).trim();
|
|
50
|
-
} catch {
|
|
51
|
+
} catch (error) {
|
|
52
|
+
// A timed-out Git identity check is not evidence for a non-git project.
|
|
53
|
+
if (error.code === 'ETIMEDOUT') throw new Error('Git project identity timed out');
|
|
51
54
|
return null;
|
|
52
55
|
}
|
|
53
56
|
}
|
|
54
57
|
|
|
55
|
-
function gitProject(projectDir) {
|
|
56
|
-
const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
57
|
-
const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel']);
|
|
58
|
+
function gitProject(projectDir, gitTimeoutMs) {
|
|
59
|
+
const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir'], gitTimeoutMs);
|
|
60
|
+
const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel'], gitTimeoutMs);
|
|
58
61
|
if (!commonValue || !checkoutValue) return null;
|
|
59
62
|
const gitCommonDir = canonicalDirectory(commonValue, 'Git common directory');
|
|
60
63
|
const checkoutRoot = canonicalDirectory(checkoutValue, 'Git checkout root');
|
|
@@ -68,9 +71,10 @@ function gitProject(projectDir) {
|
|
|
68
71
|
};
|
|
69
72
|
}
|
|
70
73
|
|
|
71
|
-
export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath } = {}) {
|
|
74
|
+
export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath, gitTimeoutMs } = {}) {
|
|
72
75
|
const canonicalInput = canonicalDirectory(projectDir, 'projectDir');
|
|
73
|
-
|
|
76
|
+
if (gitTimeoutMs !== undefined && (!Number.isSafeInteger(gitTimeoutMs) || gitTimeoutMs <= 0)) throw new TypeError('gitTimeoutMs must be a positive integer');
|
|
77
|
+
const git = gitProject(canonicalInput, gitTimeoutMs);
|
|
74
78
|
const resolved = git ?? {
|
|
75
79
|
gitCommonDir: null,
|
|
76
80
|
checkoutRoot: canonicalInput,
|
|
@@ -82,6 +86,11 @@ export function resolveProjectStore({ projectDir = process.cwd(), requestedStore
|
|
|
82
86
|
if (!isWithin(resolved.projectRoot, resolvedAgentDbPath)) {
|
|
83
87
|
throw new Error('store symlink escape rejected');
|
|
84
88
|
}
|
|
89
|
+
// A hard link passes every path check yet writes into a foreign inode (ADR-0102 G-053, measured: the
|
|
90
|
+
// Stop boundary's progression/continuity writers changed a hard-linked foreign store).
|
|
91
|
+
let storeStat = null;
|
|
92
|
+
try { storeStat = fs.statSync(resolvedAgentDbPath); } catch { /* not created yet */ }
|
|
93
|
+
if (storeStat && storeStat.nlink > 1) throw new Error('store hard link rejected');
|
|
85
94
|
if (requestedStorePath !== undefined) {
|
|
86
95
|
if (typeof requestedStorePath !== 'string' || !requestedStorePath.trim()) {
|
|
87
96
|
throw new TypeError('requestedStorePath must be a non-empty path');
|
|
@@ -129,8 +129,8 @@ export function runSessionSnapshotHook(projectDir, event, {
|
|
|
129
129
|
const metadataWritten = writeMetadata ? writeSessionSnapshot(projectDir, event) : false;
|
|
130
130
|
let payload;
|
|
131
131
|
try { payload = rawInput ? JSON.parse(rawInput) : {}; } catch { payload = {}; }
|
|
132
|
-
// TURN OUTCOMES FIRST
|
|
133
|
-
// (a project without
|
|
132
|
+
// TURN OUTCOMES FIRST: consent and canonical store availability govern whether a turn is queued
|
|
133
|
+
// (a project without a store requires persisted opt-in — turn-outcome-capture.mjs).
|
|
134
134
|
// It only reads and spawns a detached writer, so it costs the progression budget below nothing.
|
|
135
135
|
let turn;
|
|
136
136
|
try { turn = captureTurn({ projectDir, event, payload, host }); } catch (error) {
|
|
@@ -27,6 +27,7 @@ import os from 'node:os';
|
|
|
27
27
|
import path from 'node:path';
|
|
28
28
|
import { fileURLToPath } from 'node:url';
|
|
29
29
|
import { restoreWithBrief } from './continuity-brief.mjs';
|
|
30
|
+
import { turnRecordingStatus } from './turn-outcome-capture.mjs';
|
|
30
31
|
import {
|
|
31
32
|
read, json, exists, mkdir, write, runNode,
|
|
32
33
|
} from './session-start-fsutil.mjs';
|
|
@@ -120,6 +121,7 @@ export async function runSessionStart({
|
|
|
120
121
|
|| s.startsWith('[RuvNet Brain v')
|
|
121
122
|
|| s.startsWith('USER-LEVEL:')
|
|
122
123
|
|| s.startsWith('[ASCII→SVG]')
|
|
124
|
+
|| s.startsWith('[RuvNet Brain — TURN RECORDING]')
|
|
123
125
|
|| s.startsWith('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]')
|
|
124
126
|
|| s.startsWith('[RuvNet Brain — PROJECT CONTINUITY RESTORED]')
|
|
125
127
|
|| s.startsWith('[RuvNet Brain — COME UP TO SPEED')
|
|
@@ -183,6 +185,9 @@ export async function runSessionStart({
|
|
|
183
185
|
emit('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]');
|
|
184
186
|
emit('The SessionStart restore boundary failed unexpectedly. Do not claim project state was restored; verify the canonical store before relying on remembered state.');
|
|
185
187
|
}
|
|
188
|
+
// Turn health remains visible even when the separate progression restore fails.
|
|
189
|
+
const turnStatus = turnRecordingStatus({ projectDir: cwd, env, home });
|
|
190
|
+
if (turnStatus.state === 'warn') emit(`[RuvNet Brain — TURN RECORDING] ${turnStatus.line}`);
|
|
186
191
|
// Opt-in, matching the pre-existing `trace()` convention below: several other tests assert
|
|
187
192
|
// SessionStart's stderr is EMPTY in the clean case (hook-battery.test.mjs, hook-hardening.test.mjs
|
|
188
193
|
// — a real contract, not incidental), so this must never write unconditionally. `restore` shares
|
|
@@ -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
|
|
@@ -33,7 +32,7 @@
|
|
|
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
34
|
* distill in order; the hook itself only reads, fingerprints and spawns. A breadcrumb line is
|
|
36
|
-
*
|
|
35
|
+
* containing only key, hash and length is appended BEFORE the spawn; exact readback receipts make a lost
|
|
37
36
|
* write is visible rather than silent. Advisory always: nothing here throws to the caller.
|
|
38
37
|
*/
|
|
39
38
|
import fs from 'node:fs';
|
|
@@ -43,7 +42,8 @@ import crypto from 'node:crypto';
|
|
|
43
42
|
import { spawn, spawnSync } from 'node:child_process';
|
|
44
43
|
import { fileURLToPath } from 'node:url';
|
|
45
44
|
import { resolveRuflo, rufloInvocation } from './ruflo-bin.mjs';
|
|
46
|
-
import { userLevelAgentdbHooks } from './continuity-events.mjs';
|
|
45
|
+
import { redactText, userLevelAgentdbHooks } from './continuity-events.mjs';
|
|
46
|
+
import { resolveProjectStore } from './project-store-resolver.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,22 +138,63 @@ 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 } = {}) {
|
|
158
|
+
const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs });
|
|
159
|
+
let policy = {};
|
|
160
|
+
const file = brainHome && turnCapturePolicyFile(brainHome);
|
|
161
|
+
if (file && fs.existsSync(file)) {
|
|
162
|
+
try {
|
|
163
|
+
policy = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
164
|
+
if (policy?.schemaVersion !== 1 || !consentMap(policy.projects)
|
|
165
|
+
|| (Object.hasOwn(policy, 'paths') && !consentMap(policy.paths))) throw new Error('invalid policy');
|
|
166
|
+
} catch { return { skipped: 'turn capture policy unreadable or invalid', projectRoot: resolved.projectRoot }; }
|
|
167
|
+
}
|
|
168
|
+
// A path rule wins over a project rule, allowing a linked checkout/subdirectory to opt out.
|
|
169
|
+
const setting = policy.paths?.[fs.realpathSync.native(projectDir)] ?? policy.projects?.[resolved.projectRoot];
|
|
170
|
+
if (setting !== undefined && !['on', 'off'].includes(setting)) return { skipped: 'invalid turn capture consent', projectRoot: resolved.projectRoot };
|
|
171
|
+
const db = resolved.canonicalAgentDbPath;
|
|
172
|
+
assertTurnStoreFiles(db);
|
|
173
|
+
if (setting === 'off') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'persisted turn capture opt-out' };
|
|
174
|
+
let exists = false;
|
|
175
|
+
try { exists = fs.statSync(db).isFile(); } catch { /* absent */ }
|
|
176
|
+
if (!exists && setting !== 'on') return { db, scope: 'project', projectRoot: resolved.projectRoot, skipped: 'no project memory db; persisted opt-in required' };
|
|
177
|
+
return { db, scope: 'project', projectRoot: resolved.projectRoot, optedIn: setting === 'on' };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Revalidation narrows the queue-to-launch window; it does not make SQLite's later open atomic.
|
|
181
|
+
function assertTurnStoreFiles(db) {
|
|
182
|
+
const directory = path.dirname(db);
|
|
183
|
+
let stat;
|
|
184
|
+
try { stat = fs.lstatSync(directory); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
185
|
+
if (stat && (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync.native(directory) !== directory)) {
|
|
186
|
+
throw new Error('store directory is not a canonical regular directory');
|
|
187
|
+
}
|
|
188
|
+
for (const file of [db, `${db}-wal`, `${db}-shm`, `${db}-journal`]) {
|
|
189
|
+
let entry;
|
|
190
|
+
try { entry = fs.lstatSync(file); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
191
|
+
if (entry && (!entry.isFile() || entry.isSymbolicLink() || entry.nlink > 1)) {
|
|
192
|
+
throw new Error('store or SQLite side file is a symlink, hard link or non-regular file');
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const projectName = (projectDir) => redactText(path.basename(path.resolve(projectDir))).replace(/[^A-Za-z0-9._-]/g, '_') || 'project';
|
|
157
198
|
|
|
158
199
|
/** Detached worker launch: the hook returns immediately; the worker runs the steps in order. */
|
|
159
200
|
export function launchDetached(steps, { receipts }) {
|
|
@@ -164,22 +205,36 @@ export function launchDetached(steps, { receipts }) {
|
|
|
164
205
|
return { launched: true, pid: child.pid };
|
|
165
206
|
}
|
|
166
207
|
|
|
167
|
-
function
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
208
|
+
function readTurnState(stateFile) {
|
|
209
|
+
try { return JSON.parse(fs.readFileSync(stateFile, 'utf8')) || {}; } catch { return {}; }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sameTurnSeen(stateFile, sessionKey, fingerprint, receipts) {
|
|
213
|
+
const previous = readTurnState(stateFile)[sessionKey];
|
|
214
|
+
if (previous?.fingerprint !== fingerprint) return false;
|
|
215
|
+
try {
|
|
216
|
+
const rows = readTail(receipts, 128 * 1024).flatMap((line) => { try { return [JSON.parse(line)]; } catch { return []; } });
|
|
217
|
+
const receipt = rows.findLast((r) => r.kind === 'store' && r.key === previous.key);
|
|
218
|
+
if (receipt) return receipt.status === 0 && receipt.verified === true;
|
|
219
|
+
} catch { /* queued worker has no receipt yet */ }
|
|
220
|
+
// An in-flight worker gets a bounded grace period; a killed/lost worker cannot consume the turn forever.
|
|
221
|
+
return Date.now() - previous.at < 2 * STEP_TIMEOUT_MS + 10_000;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function markTurnQueued(stateFile, sessionKey, fingerprint, key) {
|
|
225
|
+
const last = readTurnState(stateFile);
|
|
171
226
|
const keys = Object.keys(last);
|
|
172
227
|
for (const k of keys.slice(0, Math.max(0, keys.length - 200))) delete last[k];
|
|
173
228
|
try {
|
|
174
229
|
fs.mkdirSync(path.dirname(stateFile), { recursive: true, mode: 0o700 });
|
|
175
|
-
fs.writeFileSync(stateFile, JSON.stringify({ ...last, [sessionKey]: fingerprint }), { mode: 0o600 });
|
|
230
|
+
fs.writeFileSync(stateFile, JSON.stringify({ ...last, [sessionKey]: { fingerprint, key, at: Date.now() } }), { mode: 0o600 });
|
|
176
231
|
} catch { /* dedupe is best effort; a duplicate record beats a lost one */ }
|
|
177
|
-
return false;
|
|
178
232
|
}
|
|
179
233
|
|
|
180
234
|
/**
|
|
181
235
|
* Capture this turn's outcome (Stop) and/or queue distillation (SessionEnd, PreCompact).
|
|
182
|
-
* Returns a plain report; `
|
|
236
|
+
* Returns a plain report; `queued` means a worker request, never proof the store committed.
|
|
237
|
+
* `skipped` / `distill.skipped` carry the reason whenever nothing is requested.
|
|
183
238
|
*/
|
|
184
239
|
export function captureTurnOutcome({
|
|
185
240
|
projectDir, event, payload = {}, host = 'claude',
|
|
@@ -191,15 +246,20 @@ export function captureTurnOutcome({
|
|
|
191
246
|
settleMs = 2000,
|
|
192
247
|
now = () => new Date(),
|
|
193
248
|
} = {}) {
|
|
194
|
-
const report = { event, host, recorded: false, distill: { queued: false } };
|
|
249
|
+
const report = { event, host, queued: false, recorded: false, distill: { queued: false } };
|
|
195
250
|
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') {
|
|
196
251
|
return { ...report, skipped: 'RUVNET_TURN_CAPTURE=off', distill: { queued: false, skipped: 'RUVNET_TURN_CAPTURE=off' } };
|
|
197
252
|
}
|
|
198
253
|
if (!ruflo) return { ...report, skipped: 'ruflo not found', distill: { queued: false, skipped: 'ruflo not found' } };
|
|
199
|
-
|
|
254
|
+
let target;
|
|
255
|
+
try { target = resolveTurnDb({ projectDir, brainHome }); } catch (error) { return { ...report, skipped: `store resolution failed: ${redactText(error.message)}` }; }
|
|
256
|
+
const { db, scope, projectRoot } = target;
|
|
200
257
|
Object.assign(report, { db, scope });
|
|
258
|
+
if (target.skipped) return { ...report, skipped: target.skipped, distill: { queued: false, skipped: target.skipped } };
|
|
201
259
|
const steps = [];
|
|
202
|
-
const
|
|
260
|
+
const binding = { projectRoot, projectDir: fs.realpathSync.native(projectDir), brainHome };
|
|
261
|
+
let dedupe;
|
|
262
|
+
const project = projectName(projectRoot);
|
|
203
263
|
const receipts = path.join(brainHome, 'turn-capture', 'receipts.jsonl');
|
|
204
264
|
|
|
205
265
|
// ONE WRITER PER TURN (ADR-100 §3). Measured 2026-10-01 on this repo's real store: 624 `turns` rows
|
|
@@ -228,14 +288,17 @@ export function captureTurnOutcome({
|
|
|
228
288
|
} else {
|
|
229
289
|
const fingerprint = crypto.createHash('sha256').update(JSON.stringify([turn.finalText, turn.files])).digest('hex');
|
|
230
290
|
const stateFile = path.join(brainHome, 'turn-capture', 'last-turn.json');
|
|
231
|
-
|
|
291
|
+
const identity = crypto.createHash('sha256').update(`${host}:${db}:${sessionKey}`).digest('hex');
|
|
292
|
+
if (sameTurnSeen(stateFile, identity, fingerprint, receipts)) report.skipped = 'same turn outcome already queued or verified';
|
|
232
293
|
else {
|
|
233
294
|
const at = now();
|
|
234
|
-
const key = `turn-${project}-${at.getTime()}`;
|
|
295
|
+
const key = `turn-${project}-${at.getTime()}-${crypto.randomBytes(6).toString('hex')}`;
|
|
296
|
+
dedupe = { stateFile, identity, fingerprint, key };
|
|
235
297
|
const value = buildTurnRecord({ turn, project, host, session: payload.session_id, at });
|
|
236
|
-
steps.push({ kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
|
|
298
|
+
steps.push({ ...binding, kind: 'store', ruflo, args: ['memory', 'store', '-k', key, '--value', value, '-n', TURN_NAMESPACE,
|
|
237
299
|
'--path', db, '--tags', `project=${project},host=${host}`, '--provenance', 'agent_output'] });
|
|
238
|
-
|
|
300
|
+
// This synchronous boundary proves only queuing; the worker's exact receipt proves recording.
|
|
301
|
+
Object.assign(report, { queued: true, key, value });
|
|
239
302
|
}
|
|
240
303
|
}
|
|
241
304
|
} else report.skipped = `turn outcomes are recorded at Stop, not ${event}`;
|
|
@@ -243,49 +306,66 @@ export function captureTurnOutcome({
|
|
|
243
306
|
if (event === 'SessionEnd' || event === 'PreCompact') {
|
|
244
307
|
if (!fs.existsSync(db)) report.distill = { queued: false, skipped: 'no memory db to distill yet' };
|
|
245
308
|
else {
|
|
246
|
-
steps.push({ kind: 'distill', ruflo, args: ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'] });
|
|
309
|
+
steps.push({ ...binding, kind: 'distill', ruflo, args: ['memory', 'distill', 'run', '--db', db, '--namespace', TURN_NAMESPACE, '--max-entries', '500'] });
|
|
247
310
|
report.distill = { queued: true, db };
|
|
248
311
|
}
|
|
249
312
|
}
|
|
250
313
|
if (!steps.length) return report;
|
|
251
314
|
|
|
252
315
|
try {
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
if (report.recorded) {
|
|
316
|
+
// Only explicit persisted consent permits creating a project store directory.
|
|
317
|
+
if (target.optedIn) fs.mkdirSync(path.dirname(db), { recursive: true, mode: 0o700 });
|
|
318
|
+
if (report.queued) {
|
|
257
319
|
fs.appendFileSync(path.join(path.dirname(db), 'agentdb-turns.jsonl'),
|
|
258
|
-
`${JSON.stringify({ ts: Date.now(), key: report.key,
|
|
320
|
+
`${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
321
|
}
|
|
260
322
|
report.launch = launch(steps, { receipts });
|
|
323
|
+
if (dedupe) markTurnQueued(dedupe.stateFile, dedupe.identity, dedupe.fingerprint, dedupe.key);
|
|
261
324
|
} catch (error) {
|
|
262
|
-
return { ...report, recorded: false, distill: { queued: false, skipped: `launch failed: ${error.message}` }, skipped: `launch failed: ${error.message}` };
|
|
325
|
+
return { ...report, queued: false, recorded: false, distill: { queued: false, skipped: `launch failed: ${redactText(error.message)}` }, skipped: `launch failed: ${redactText(error.message)}` };
|
|
263
326
|
}
|
|
264
327
|
return report;
|
|
265
328
|
}
|
|
266
329
|
|
|
267
|
-
/**
|
|
268
|
-
|
|
330
|
+
/** Exact (namespace, key, content) readback. Exit zero alone never proves capture. */
|
|
331
|
+
function readBack({ ruflo, db, key, run, options }) {
|
|
332
|
+
const invocation = rufloInvocation(ruflo, ['memory', 'retrieve', '--key', key, '--namespace', TURN_NAMESPACE, '--value-only', '--path', db]);
|
|
333
|
+
const result = run(invocation.executable, invocation.args, options);
|
|
334
|
+
return result.status === 0 ? String(result.stdout || '').trim() : null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/** The detached worker: bounded steps, safe error evidence, exact content readback. */
|
|
338
|
+
export function runSteps({ steps = [], receipts } = {}, { run = spawnSync, read = readBack, env = process.env } = {}) {
|
|
269
339
|
const results = [];
|
|
270
340
|
for (const step of steps) {
|
|
271
|
-
let status = null;
|
|
272
|
-
|
|
273
|
-
const db =
|
|
341
|
+
let status = null; let error = null; let verified = false;
|
|
342
|
+
const args = [...step.args];
|
|
343
|
+
const db = args[args.indexOf(step.kind === 'store' ? '--path' : '--db') + 1];
|
|
344
|
+
const key = step.kind === 'store' ? args[args.indexOf('-k') + 1] : undefined;
|
|
345
|
+
const valueIndex = args.indexOf('--value') + 1;
|
|
346
|
+
if (step.kind === 'store') args[valueIndex] = redactText(args[valueIndex]);
|
|
274
347
|
try {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
348
|
+
if (!step.projectRoot || !step.projectDir || !step.brainHome) throw new Error('queued step has no canonical project binding');
|
|
349
|
+
const target = resolveTurnDb({ projectDir: step.projectDir, brainHome: step.brainHome, requestedStorePath: db, gitTimeoutMs: 1000 });
|
|
350
|
+
if (target.skipped) throw new Error(target.skipped);
|
|
351
|
+
if (target.projectRoot !== step.projectRoot || target.db !== db) throw new Error('queued canonical project/store identity changed');
|
|
352
|
+
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off') throw new Error('RUVNET_TURN_CAPTURE=off');
|
|
353
|
+
const invocation = rufloInvocation(step.ruflo, args);
|
|
354
|
+
// Contain Ruflo's auxiliary writes in the canonical store's own directory.
|
|
281
355
|
const home = path.dirname(db);
|
|
282
|
-
const
|
|
283
|
-
env: { ...
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
356
|
+
const options = { encoding: 'utf8', timeout: STEP_TIMEOUT_MS, cwd: home, windowsHide: true,
|
|
357
|
+
maxBuffer: 1024 * 1024, env: { ...env, ...RUFLO_ENV, CLAUDE_FLOW_MEMORY_PATH: home } };
|
|
358
|
+
const r = run(invocation.executable, invocation.args, options);
|
|
359
|
+
status = Number.isInteger(r.status) ? r.status : 1;
|
|
360
|
+
if (r.error || status !== 0) error = redactText(r.error?.message || String(r.stderr || '').trim().split(/\r?\n/)[0] || `ruflo exited ${status}`).slice(0, 300);
|
|
361
|
+
else if (step.kind === 'store') {
|
|
362
|
+
verified = read({ ruflo: step.ruflo, db, key, run, options }) === args[valueIndex];
|
|
363
|
+
if (!verified) { status = 1; error = 'exact turn key/content readback failed'; }
|
|
364
|
+
}
|
|
365
|
+
} catch (e) { status = 1; error = redactText(e.message).slice(0, 300); }
|
|
366
|
+
const row = { at: new Date().toISOString(), kind: step.kind, db: redactText(db),
|
|
367
|
+
storeIdentity: crypto.createHash('sha256').update(db).digest('hex'), key, status, error,
|
|
368
|
+
...(step.kind === 'store' ? { verified } : {}) };
|
|
289
369
|
results.push(row);
|
|
290
370
|
if (receipts) {
|
|
291
371
|
try {
|
|
@@ -297,6 +377,24 @@ export function runSteps({ steps = [], receipts } = {}, { run = spawnSync } = {}
|
|
|
297
377
|
return results;
|
|
298
378
|
}
|
|
299
379
|
|
|
380
|
+
/** Bounded recent evidence, isolated by canonical db. A continuity success cannot mask turn failures. */
|
|
381
|
+
export function turnRecordingStatus({ projectDir = process.cwd(), env = process.env, home = os.homedir(), now = Date.now() } = {}) {
|
|
382
|
+
const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
|
|
383
|
+
let target;
|
|
384
|
+
try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
|
|
385
|
+
if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
|
|
386
|
+
let rows = [];
|
|
387
|
+
try {
|
|
388
|
+
rows = readTail(path.join(brainHome, 'turn-capture', 'receipts.jsonl'), 128 * 1024).flatMap((line) => {
|
|
389
|
+
try { return [JSON.parse(line)]; } catch { return []; }
|
|
390
|
+
}).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);
|
|
391
|
+
} catch { /* first run */ }
|
|
392
|
+
const failed = rows.filter((r) => r.status !== 0 || r.verified !== true);
|
|
393
|
+
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)}` };
|
|
394
|
+
return rows.length ? { state: 'ok', line: `turn recording ✓ (${rows.length}/${rows.length} exact readbacks)` }
|
|
395
|
+
: { state: 'unknown', line: 'turn recording not yet proven — no exact readback receipt' };
|
|
396
|
+
}
|
|
397
|
+
|
|
300
398
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url) && process.argv[2] === '--run-steps') {
|
|
301
399
|
try { runSteps(JSON.parse(process.argv[3] || '{}')); } catch { /* a detached worker has no one to report to */ }
|
|
302
400
|
process.exit(0);
|
|
@@ -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" ;;
|