ruvnet-brain 4.5.6 → 4.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +50 -1
  3. package/kb/forge-update.mjs +50 -9
  4. package/package.json +5 -3
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/commands/configure.md +13 -1
  8. package/plugin/hooks/codex-hooks.json +31 -1
  9. package/plugin/hooks/hook-contracts.json +82 -2
  10. package/plugin/hooks/hooks.json +31 -1
  11. package/plugin/mcp/managed-cli-generation.mjs +91 -0
  12. package/plugin/mcp/managed-cli-interface.mjs +36 -12
  13. package/plugin/mcp/server.mjs +3 -2
  14. package/plugin/scripts/agentdb-recall.mjs +38 -10
  15. package/plugin/scripts/continuation-gate.mjs +3 -1
  16. package/plugin/scripts/continuity-hook-policy.mjs +3 -0
  17. package/plugin/scripts/continuity-journal.mjs +11 -11
  18. package/plugin/scripts/hook-shim.mjs +3 -2
  19. package/plugin/scripts/kb-copy-proof.mjs +84 -22
  20. package/plugin/scripts/learn-capture.mjs +44 -0
  21. package/plugin/scripts/learn-capture.sh +2 -241
  22. package/plugin/scripts/learn-flush.mjs +86 -191
  23. package/plugin/scripts/learning-observation.mjs +51 -0
  24. package/plugin/scripts/learning-queue.mjs +135 -0
  25. package/plugin/scripts/learning-store.mjs +105 -0
  26. package/plugin/scripts/learning-worker-supervisor.mjs +79 -0
  27. package/plugin/scripts/nightly-scheduler.mjs +6 -2
  28. package/plugin/scripts/project-capture-queue.mjs +16 -7
  29. package/plugin/scripts/project-progression-contract.mjs +58 -4
  30. package/plugin/scripts/project-progression-outbox.mjs +22 -6
  31. package/plugin/scripts/project-progression-session-start.mjs +10 -0
  32. package/plugin/scripts/project-progression-suspension.mjs +60 -0
  33. package/plugin/scripts/project-transition-hook.mjs +25 -8
  34. package/plugin/scripts/runtime-preferences.mjs +45 -4
  35. package/plugin/scripts/session-snapshot-hook.mjs +14 -7
  36. package/plugin/scripts/session-start-core.mjs +8 -0
  37. package/plugin/scripts/session-start-proof.mjs +49 -0
  38. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  39. package/scripts/codex-fresh-host-proof.mjs +179 -0
  40. package/scripts/codex-host-execution-proof.mjs +246 -0
  41. package/scripts/codex-host-proof-runtime.mjs +104 -0
  42. package/scripts/console-engine.mjs +29 -16
  43. package/scripts/health-repair.mjs +47 -68
  44. package/scripts/onboarding-console.mjs +25 -54
  45. package/scripts/qa/progression-validation-benchmark.mjs +66 -0
  46. package/scripts/release-qualification-contract.mjs +76 -7
  47. package/scripts/remedy-registry.mjs +9 -2
@@ -0,0 +1,135 @@
1
+ // Workflow queue transport only. Retained source bytes plus acknowledgments permit crash replay.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+
6
+ export const MAX_QUEUE_BYTES = 1024 * 1024;
7
+ export const WORKER_BUDGET_MS = 18_000;
8
+ const hash = (value) => createHash('sha256').update(value).digest('hex');
9
+ const nofollow = fs.constants.O_NOFOLLOW || 0;
10
+ export const queueName = /^session-[A-Za-z0-9_-]+\.jsonl$/;
11
+
12
+ /** Reject symlinks/junctions at every ancestor beneath the canonical selected scope. */
13
+ export function safeQueue(context, create = false) {
14
+ const root = fs.realpathSync.native(context.scope === 'user' ? context.home : context.projectDir);
15
+ const relative = path.relative(root, context.queueDir);
16
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) throw new Error('queue outside scope');
17
+ let current = root;
18
+ for (const part of relative.split(path.sep)) {
19
+ current = path.join(current, part);
20
+ if (create && !fs.existsSync(current)) fs.mkdirSync(current, { mode: 0o700 });
21
+ const stat = fs.lstatSync(current);
22
+ if (!stat.isDirectory() || stat.isSymbolicLink() || fs.realpathSync.native(current) !== current) throw new Error('unsafe queue ancestor');
23
+ }
24
+ return current;
25
+ }
26
+
27
+ export function readSafe(file, maxBytes = MAX_QUEUE_BYTES) {
28
+ if (!fs.lstatSync(file).isFile()) throw new Error('unsafe queue file');
29
+ const fd = fs.openSync(file, fs.constants.O_RDONLY | nofollow);
30
+ try {
31
+ const stat = fs.fstatSync(fd);
32
+ if (!stat.isFile() || stat.size > maxBytes) throw new Error('queue file exceeds bounded read');
33
+ return fs.readFileSync(fd);
34
+ } finally { fs.closeSync(fd); }
35
+ }
36
+
37
+ export function queueFiles(context, options = {}) {
38
+ try {
39
+ const dir = safeQueue(context);
40
+ if (Number.isInteger(options.limit)) {
41
+ const before = []; const after = []; let passed = !options.cursor;
42
+ const iterator = fs.opendirSync(dir);
43
+ try {
44
+ let entry;
45
+ while (Date.now() < options.deadline && (entry = iterator.readSync())) {
46
+ if (!queueName.test(entry.name)) continue;
47
+ const file = path.join(dir, entry.name);
48
+ if (!passed && entry.name === options.cursor) { passed = true; if (before.length < options.limit) before.push(file); continue; }
49
+ if (passed) { after.push(file); if (after.length >= options.limit) break; }
50
+ else if (before.length < options.limit) before.push(file);
51
+ }
52
+ } finally { iterator.closeSync(); }
53
+ return [...after, ...before].slice(0, options.limit);
54
+ }
55
+ return fs.readdirSync(dir).filter(name => queueName.test(name)).map(name => path.join(dir, name))
56
+ .sort((a, b) => fs.lstatSync(a).mtimeMs - fs.lstatSync(b).mtimeMs || a.localeCompare(b));
57
+ } catch (error) { if (error.code === 'ENOENT') return []; throw error; }
58
+ }
59
+
60
+ /** Hash-bound offsets cannot acknowledge replaced source bytes or an appended sibling line. */
61
+ export function pendingRecords(file) {
62
+ const bytes = readSafe(file); let ack = {};
63
+ try { ack = JSON.parse(readSafe(`${file}.ack.json`).toString()); }
64
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
65
+ const records = []; let start = 0;
66
+ for (let end = 0; end < bytes.length; end++) {
67
+ if (bytes[end] !== 10) continue;
68
+ const line = bytes.subarray(start, end + 1); const key = `${start}:${hash(line)}`;
69
+ if (line.toString().trim() && ack[key] !== true) records.push({ key, raw: line.toString() });
70
+ start = end + 1;
71
+ }
72
+ // A torn final line is pending evidence; it can become complete on a legacy writer's next append.
73
+ if (start < bytes.length && bytes.subarray(start).toString().trim()) records.push({ key: null, raw: bytes.subarray(start).toString() });
74
+ return { records, ack };
75
+ }
76
+
77
+ export function writeExclusive(file, bytes) {
78
+ const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | nofollow, 0o600);
79
+ try { fs.writeFileSync(fd, bytes); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
80
+ }
81
+
82
+ export function writeAtomic(target, bytes) {
83
+ try { if (!fs.lstatSync(target).isFile()) throw new Error('unsafe acknowledgment'); }
84
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
85
+ const temporary = `${target}.${randomUUID()}`;
86
+ writeExclusive(temporary, bytes);
87
+ fs.renameSync(temporary, target);
88
+ }
89
+ export function acknowledge(file, ack) { writeAtomic(`${file}.ack.json`, JSON.stringify(ack)); }
90
+
91
+ /** Lease lasts longer than the finite worker. PID is diagnostic, never writer authority. */
92
+ export function takeQueueLock(context, now = Date.now()) {
93
+ const dir = safeQueue(context); const file = path.join(dir, '.worker-lock');
94
+ const token = randomUUID(); const body = JSON.stringify({ token, expires: now + 60_000 });
95
+ try { writeExclusive(file, body); return token; } catch (error) { if (error.code !== 'EEXIST') throw error; }
96
+ const seen = readSafe(file).toString();
97
+ let expires;
98
+ try { const owner = JSON.parse(seen); if (owner.retirementUnconfirmed || owner.retirementRequired) return null; expires = owner.expires; } catch { expires = fs.lstatSync(file).mtimeMs + 60_000; }
99
+ if (!Number.isFinite(expires)) expires = fs.lstatSync(file).mtimeMs + 60_000;
100
+ if (!(expires < now)) return null;
101
+ const aside = `${file}.${token}`;
102
+ fs.renameSync(file, aside);
103
+ if (readSafe(aside).toString() !== seen) {
104
+ try { fs.linkSync(aside, file); } catch { /* another fenced owner already exists */ }
105
+ fs.rmSync(aside); return null;
106
+ }
107
+ fs.rmSync(aside);
108
+ try { writeExclusive(file, body); return token; } catch (error) { if (error.code === 'EEXIST') return null; throw error; }
109
+ }
110
+
111
+ export function ownsQueueLock(context, token) {
112
+ try {
113
+ safeQueue(context);
114
+ const lock = JSON.parse(readSafe(path.join(context.queueDir, '.worker-lock')).toString());
115
+ return !lock.retirementUnconfirmed && lock.token === token && Date.now() < lock.expires;
116
+ } catch { return false; }
117
+ }
118
+ export function releaseQueueLock(context, token) {
119
+ if (ownsQueueLock(context, token)) fs.rmSync(path.join(context.queueDir, '.worker-lock'));
120
+ }
121
+
122
+ // Fixed vocabulary: arbitrary executable names, arguments, basenames and paths never persist.
123
+ const verbs = new Set('git npm node python python3 cargo rustc go gh cd ls rg cat export mkdir rm cp mv bash sh curl wget npx ruflo'.split(' '));
124
+ const subs = new Set('status diff log show add commit push pull fetch checkout switch branch test run install ci build check fmt clippy auth workflow release memory hooks'.split(' '));
125
+ export function safeAction(tool, value) {
126
+ if (typeof value !== 'string') return null;
127
+ if (tool === 'Bash') {
128
+ const [verb, sub] = value.trim().split(/\s+/, 3);
129
+ if (!verbs.has(verb)) return 'command';
130
+ return subs.has(sub) ? `${verb} ${sub}` : verb;
131
+ }
132
+ if (!['Write', 'Edit', 'MultiEdit'].includes(tool)) return null;
133
+ // Legacy action is minimized again before invocation; no private basename reaches Ruflo argv.
134
+ return 'edit file';
135
+ }
@@ -0,0 +1,105 @@
1
+ // Canonical workflow observations; existing Ruflo is the sole writer, never the legacy JSON hook store.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import { resolveProjectStore } from './project-store-resolver.mjs';
7
+ import { withProgressionReader } from './project-progression-reader.mjs';
8
+ import { rufloRunDir } from './project-progression-store.mjs';
9
+ import { rufloInvocation } from './ruflo-bin.mjs';
10
+ import { loadRuntimePreferences } from './runtime-preferences.mjs';
11
+ import { loadNodeSqlite } from './node-sqlite.mjs';
12
+ import { safeQueue, writeAtomic } from './learning-queue.mjs';
13
+
14
+ export const LEARNING_NAMESPACE = 'learning-observations';
15
+ export function learningTarget(context, { env = process.env, explicitLegacyApply = false } = {}) {
16
+ if (!context.enabled) throw new Error('learning consent disabled');
17
+ if (context.scope === 'project') return resolveProjectStore({ projectDir: context.projectDir, gitTimeoutMs: 1000 }).canonicalAgentDbPath;
18
+ const root = path.join(context.home, '.claude', 'global-memory');
19
+ const chosen = loadRuntimePreferences({ env, cwd: context.projectDir }).values.learningScope === 'user';
20
+ if (!chosen && !explicitLegacyApply) throw new Error('user learning requires persisted user consent or explicit legacy Apply');
21
+ safeQueue({ scope: 'user', home: context.home, queueDir: root });
22
+ const db = path.join(root, '.swarm', 'memory.db');
23
+ if (!fs.lstatSync(db).isFile()) throw new Error('user store not adopted');
24
+ const resolved = resolveProjectStore({ projectDir: root, requestedStorePath: db, gitTimeoutMs: 1000 });
25
+ if (resolved.projectRoot !== root) throw new Error('foreign global learner root');
26
+ return resolved.canonicalAgentDbPath;
27
+ }
28
+
29
+ export function runLearningRuflo(binary, args, db, { env = process.env, deadline = Date.now() + 18_000 } = {}) {
30
+ const remaining = deadline - Date.now();
31
+ if (remaining <= 0) throw new Error('learning deadline exhausted');
32
+ const cwd = rufloRunDir(db, { root: env.RUVNET_RUFLO_CWD_ROOT || path.join(env.HOME, '.cache', 'ruvnet-brain', 'learning-ruflo') });
33
+ try {
34
+ const invocation = rufloInvocation(binary, args);
35
+ return spawnSync(invocation.executable, invocation.args, { cwd, env: { ...env, RUFLO_DAEMON_AUTOSTART: '0' },
36
+ encoding: 'utf8', shell: false, timeout: Math.min(6000, remaining), killSignal: 'SIGKILL', maxBuffer: 65536, windowsHide: true });
37
+ } finally { fs.rmSync(cwd, { force: true, recursive: true }); }
38
+ }
39
+
40
+ export function recordLearningObservation(binary, context, file, record, row, options = {}) {
41
+ const db = learningTarget(context, options);
42
+ const key = 'workflow-' + createHash('sha256').update(path.basename(file) + ':' + record.key).digest('hex');
43
+ const value = JSON.stringify({ schemaVersion: 1, tool: row.tool, action: row.action, scope: context.scope,
44
+ authoritative: false, provenance: 'system-observation', outcome: 'host-reported-success' });
45
+ const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
46
+ const existing = withProgressionReader(db, reader => reader.readContent(LEARNING_NAMESPACE, key));
47
+ if (!(existing.ok && existing.value === value)) {
48
+ const stored = run(['memory', 'store', '--key', key, '--value', value, '--namespace', LEARNING_NAMESPACE,
49
+ '--no-upsert', '--provenance', 'system_observation', '--path', db]);
50
+ if (stored.error || stored.status !== 0) throw new Error('canonical observation write failed');
51
+ }
52
+ const retrieved = run(['memory', 'retrieve', '--key', key, '--namespace', LEARNING_NAMESPACE, '--value-only', '--path', db]);
53
+ if (retrieved.error || retrieved.status !== 0 || retrieved.stdout.trim() !== value) throw new Error('canonical exact CLI readback failed');
54
+ const independent = withProgressionReader(db, reader => reader.readContent(LEARNING_NAMESPACE, key));
55
+ if (!independent.ok || independent.value !== value) throw new Error('canonical independent row readback failed');
56
+ return { db, key, digest: createHash('sha256').update(value).digest('hex'), exactCli: true, independentRow: true };
57
+ }
58
+
59
+ export function learningStoreStatus(db) {
60
+ const rows = withProgressionReader(db, reader => reader.listKeys(LEARNING_NAMESPACE, { maxEntries: 100_000 }).length);
61
+ if (!rows.ok) return { known: false, observations: 0, patterns: null, lastDistillAt: null };
62
+ const sqlite = loadNodeSqlite(); let connection;
63
+ try {
64
+ connection = new sqlite.DatabaseSync(db, { readOnly: true });
65
+ const present = name => !!connection.prepare('SELECT 1 FROM sqlite_master WHERE type=? AND name=?').get('table', name);
66
+ const patterns = present('reasoning_patterns') ? connection.prepare('SELECT count(*) AS n FROM reasoning_patterns WHERE json_valid(metadata) AND json_extract(metadata,?)=?').get('$.namespace', LEARNING_NAMESPACE).n : 0;
67
+ const at = present('distill_state') ? connection.prepare('SELECT last_run_at AS at FROM distill_state WHERE namespace=?').get(LEARNING_NAMESPACE)?.at : null;
68
+ return { known: true, observations: rows.value, patterns, lastDistillAt: at ?? null };
69
+ } catch { return { known: false, observations: rows.value, patterns: null, lastDistillAt: null }; }
70
+ finally { connection?.close(); }
71
+ }
72
+
73
+ /** Existing $0 structural distiller, with a verified native WAL-safe snapshot before mutation. */
74
+ export function distillLearning(binary, context, options = {}) {
75
+ const db = learningTarget(context, options); const before = learningStoreStatus(db);
76
+ if (!before.known) throw new Error('distillation baseline unavailable');
77
+ if (options.automatic && before.lastDistillAt !== null && Date.now() - before.lastDistillAt < 3600_000) {
78
+ return { db, completed: false, deferred: 'automatic cadence: at most hourly', patternDelta: 0, ratifiedLessons: 0 };
79
+ }
80
+ const backupDir = path.join(path.dirname(db), 'learning-backups');
81
+ const run = args => { if (options.allowed && !options.allowed()) throw new Error('learning consent changed'); return runLearningRuflo(binary, args, db, options); };
82
+ if (options.allowed && !options.allowed()) throw new Error('learning consent changed');
83
+ safeQueue({ scope: 'project', projectDir: path.dirname(path.dirname(db)), queueDir: backupDir }, true);
84
+ const prior = new Set(fs.readdirSync(backupDir));
85
+ const backup = run(['memory', 'backup', '--db', db, '--dir', backupDir, '--keep', '3', '--verbose']);
86
+ if (backup.error || backup.status !== 0) throw new Error('distillation backup failed');
87
+ if (!backup.stdout.includes('memory DB backed up → ') || backup.stdout.includes('byte-copy')) throw new Error('native online backup mode unverified');
88
+ const images = fs.readdirSync(backupDir).filter(name => name.endsWith('.db') && !prior.has(name));
89
+ if (images.length !== 1) throw new Error('fresh distillation snapshot absent');
90
+ const image = path.join(backupDir, images[0]);
91
+ fs.chmodSync(image, 0o600);
92
+ // The source-bound verbose marker above distinguishes native online backup from
93
+ // Ruflo's byte-copy fallback. Row verification is additional observation evidence,
94
+ // not a claim that this subset alone proves whole-database rollback.
95
+ const source = withProgressionReader(db, reader => reader.listKeys(LEARNING_NAMESPACE, { maxEntries: 100_000 }).map(key => [key, reader.readContent(LEARNING_NAMESPACE, key)]));
96
+ const copy = withProgressionReader(image, reader => reader.listKeys(LEARNING_NAMESPACE, { maxEntries: 100_000 }).map(key => [key, reader.readContent(LEARNING_NAMESPACE, key)]));
97
+ if (!source.ok || !copy.ok || JSON.stringify(source.value) !== JSON.stringify(copy.value)) throw new Error('distillation snapshot row verification failed');
98
+ writeAtomic(path.join(backupDir, 'latest-receipt.json'), JSON.stringify({ db, before, snapshot: image, providerMode: 'native-online-backup', observationsVerified: true, retainedSnapshots: 3, restore: 'automatic exact restore unavailable' }));
99
+ const distilled = run(['memory', 'distill', 'run', '--db', db, '--namespace', LEARNING_NAMESPACE,
100
+ '--max-entries', '8', '--judge', 'structural', '--budget-usd', '0']);
101
+ const after = learningStoreStatus(db);
102
+ return { db, before, after, completed: !distilled.error && distilled.status === 0,
103
+ patternDelta: before.known && after.known ? after.patterns - before.patterns : null,
104
+ snapshot: image, ratifiedLessons: 0 };
105
+ }
@@ -0,0 +1,79 @@
1
+ // One finite owner retires its separate worker tree before surrendering the queue fence.
2
+ import { spawn, spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ import { ownsQueueLock, releaseQueueLock, writeExclusive, writeAtomic, readSafe } from './learning-queue.mjs';
6
+ import { learningContext } from './runtime-preferences.mjs';
7
+
8
+ const aliveGroup = pid => {
9
+ try { process.kill(-pid, 0); return true; } catch (error) { return error.code !== 'ESRCH'; }
10
+ };
11
+ const killTree = pid => {
12
+ if (process.platform === 'win32') {
13
+ const r = spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { timeout: 1000, windowsHide: true, stdio: 'ignore' });
14
+ return !r.error && r.status === 0;
15
+ }
16
+ try { process.kill(-pid, 'SIGKILL'); return true; } catch (error) { return error.code === 'ESRCH'; }
17
+ };
18
+
19
+ export async function superviseLearning(context, token, deadline, { report = false, spawnEngine = spawn,
20
+ killOwned = killTree, groupAlive = aliveGroup, platform = process.platform, env = process.env,
21
+ persistFence = writeAtomic, writeDiagnostic = writeExclusive } = {}) {
22
+ // A supervisor crash or later read-only filesystem must never permit expired-lease takeover.
23
+ // Persist and verify the retirement requirement before any worker can exist.
24
+ const lockFile = path.join(context.queueDir, '.worker-lock');
25
+ try {
26
+ if (!ownsQueueLock(context, token)) throw new Error('queue ownership lost');
27
+ const lock = JSON.parse(readSafe(lockFile));
28
+ persistFence(lockFile, JSON.stringify({ ...lock, retirementRequired: true }));
29
+ const verified = JSON.parse(readSafe(lockFile));
30
+ if (verified.token !== token || verified.retirementRequired !== true) throw new Error('retirement requirement unverified');
31
+ } catch {
32
+ return { retirementConfirmed: true, fencePersisted: false, launched: false };
33
+ }
34
+ const child = spawnEngine(process.execPath, [fileURLToPath(new URL('./learn-flush.mjs', import.meta.url)), '--worker', ...(report ? ['--report'] : [])], {
35
+ cwd: context.projectDir, detached: true, stdio: ['ignore', report ? 'inherit' : 'ignore', report ? 'inherit' : 'ignore', 'ipc'], windowsHide: true,
36
+ env: { ...env, RUVNET_LEARN_WORKER_TOKEN: token, RUVNET_LEARN_WORKER_EXPIRES: String(deadline), RUFLO_DAEMON_AUTOSTART: '0' },
37
+ });
38
+ return await new Promise(resolve => {
39
+ let retiring = false; let exited = false;
40
+ const timer = setTimeout(() => void retire(true), Math.max(1, deadline - Date.now()));
41
+ const retire = async failed => {
42
+ if (retiring) return; retiring = true; clearTimeout(timer);
43
+ let killed = false;
44
+ try { killed = !child.pid || killOwned(child.pid); } catch { /* bounded refusal below */ }
45
+ const until = Date.now() + 600; let confirmed = !child.pid;
46
+ while (!confirmed && Date.now() < until) {
47
+ try { confirmed = platform === 'win32' ? killed && exited : !groupAlive(child.pid); } catch { confirmed = false; }
48
+ if (!confirmed) await new Promise(r => setTimeout(r, 25));
49
+ }
50
+ // Mandatory fencing is independent of optional diagnostics (including ENOSPC).
51
+ let fencePersisted = confirmed;
52
+ if (!confirmed) {
53
+ try {
54
+ if (ownsQueueLock(context, token)) {
55
+ const lock = JSON.parse(readSafe(lockFile));
56
+ persistFence(lockFile, JSON.stringify({ ...lock, retirementUnconfirmed: true }));
57
+ fencePersisted = JSON.parse(readSafe(lockFile)).retirementUnconfirmed === true;
58
+ }
59
+ } catch { /* Report failed durability without claiming the queue is fenced. */ }
60
+ }
61
+ try {
62
+ const current = learningContext({ env, cwd: context.projectDir });
63
+ if (current.enabled && current.queueDir === context.queueDir) {
64
+ if (failed || !confirmed) writeDiagnostic(path.join(context.queueDir, `.run-${Date.now()}-${process.pid}.json`), JSON.stringify({
65
+ schemaVersion: 1, failed: 1, fed: 0, acknowledged: 0, workerPid: child.pid, retirementConfirmed: confirmed, fencePersisted,
66
+ reason: confirmed ? 'owned worker exceeded deadline or crashed; retired; originals retained' : fencePersisted
67
+ ? 'owned tree retirement unconfirmed; queue fenced; originals retained'
68
+ : 'owned tree retirement unconfirmed; fence persistence failed; manual recovery required; originals retained',
69
+ }));
70
+ }
71
+ } catch { /* Diagnostic failure never consumes original evidence. */ }
72
+ if (confirmed) releaseQueueLock(context, token);
73
+ if (child.connected) child.disconnect(); child.unref?.(); resolve({ retirementConfirmed: confirmed, fencePersisted });
74
+ };
75
+ child.once('message', message => { if (message?.type === 'learning-worker-complete') void retire(false); });
76
+ child.once('error', () => void retire(true));
77
+ child.once('exit', () => { exited = true; if (!retiring) void retire(true); });
78
+ });
79
+ }
@@ -101,8 +101,12 @@ export function describeFailedRefreshRun(receipt, { reasonLimit = 200 } = {}) {
101
101
  const ledger = receipt.phases.map((entry) => entry?.phase);
102
102
  if (ledger.length > declared.length || ledger.some((phase, index) => phase !== declared[index])) return null;
103
103
  if (ledger.length === 0) return `failed before its first phase (${receipt.terminalVerdict || 'unknown'})`;
104
- const failing = [...receipt.phases].reverse().find((entry) => entry?.status !== 'PASS') || receipt.phases[receipt.phases.length - 1];
105
- const reason = String(failing.evidence?.updateResult?.reason ?? failing.evidence?.reason ?? '').split('\n')[0].trim();
104
+ const failing = receipt.phases.find((entry) => entry?.required !== false && entry?.status === 'FAIL')
105
+ || receipt.phases.find((entry) => entry?.status === 'FAIL')
106
+ || [...receipt.phases].reverse().find((entry) => entry?.status !== 'PASS')
107
+ || receipt.phases[receipt.phases.length - 1];
108
+ const reason = String(failing.evidence?.updateResult?.reason ?? failing.evidence?.error
109
+ ?? failing.evidence?.reason ?? '').split('\n')[0].trim();
106
110
  return reason ? `failed at ${failing.phase}: ${reason.slice(0, reasonLimit)}` : `failed at ${failing.phase}`;
107
111
  }
108
112
 
@@ -13,6 +13,7 @@ import { redactProgression } from './project-progression-contract.mjs';
13
13
  import { ProgressionOutbox } from './project-progression-outbox.mjs';
14
14
  import { captureNormalizedTransition } from './project-transition-hook.mjs';
15
15
  import { boundedStoreFactory, runSessionSnapshotHook } from './session-snapshot-hook.mjs';
16
+ import { operatorProgressionSuspension } from './project-progression-suspension.mjs';
16
17
 
17
18
  /**
18
19
  * How long the detached worker may spend per step. The lock is refreshed between steps and goes stale
@@ -49,6 +50,7 @@ const mtimeOf = (projectDir, name) => { try { return fs.statSync(path.join(proje
49
50
  */
50
51
  export function queueCapture({ projectDir, originProjectDir = projectDir, event, host, payload, env = process.env }) {
51
52
  try {
53
+ if (operatorProgressionSuspension(env)) return null;
52
54
  const consent = resolveTurnDb({ projectDir: originProjectDir, requestedStorePath: path.join(projectDir, '.swarm', 'memory.db'),
53
55
  brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain') });
54
56
  if (consent.skipped) return null;
@@ -232,13 +234,16 @@ export function releaseReplayLock(projectDir, token) {
232
234
  }
233
235
 
234
236
  /** Hand the lock (or take it, if free) to a detached worker. Returns whether one was started. Never throws. */
235
- export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn } = {}) {
237
+ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn, env = process.env } = {}) {
238
+ try {
239
+ if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return false; }
240
+ } catch { if (token) releaseReplayLock(projectDir, token); return false; }
236
241
  const held = token || takeReplayLock(projectDir);
237
242
  if (!held) return false;
238
243
  try {
239
244
  const child = spawnFn(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'session-snapshot-hook.mjs'), '--replay-outbox'], {
240
245
  cwd: projectDir, detached: true, stdio: 'ignore', windowsHide: true,
241
- env: { ...process.env, RUVNET_REPLAY_LOCK_TOKEN: held },
246
+ env: { ...env, RUVNET_REPLAY_LOCK_TOKEN: held },
242
247
  });
243
248
  child.unref?.();
244
249
  return true;
@@ -259,10 +264,11 @@ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn
259
264
  */
260
265
  export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
261
266
  makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null,
262
- captureNormalized = captureNormalizedTransition, onCaptured = null } = {}) {
267
+ captureNormalized = captureNormalizedTransition, onCaptured = null, env = process.env } = {}) {
263
268
  const deadlineAt = now() + budgetMs;
264
269
  if (developmentHooksSuspended(projectDir)) return 0;
265
- const brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
270
+ if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return 0; }
271
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
266
272
  try {
267
273
  const consent = resolveTurnDb({ projectDir, brainHome });
268
274
  if (consent.skipped && !consent.skipped.startsWith('no project memory db')) return 0;
@@ -274,8 +280,9 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
274
280
  if (!adoptReplayLock(projectDir, held)) return replayed;
275
281
  reclaimOrphans(projectDir);
276
282
  const resolution = resolveProjectStore({ projectDir });
277
- const store = makeStoreFactory(deadlineAt)({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath });
283
+ const store = makeStoreFactory(deadlineAt)({ projectDir, env, requestedStorePath: resolution.canonicalAgentDbPath });
278
284
  for (const snapshot of store.outbox.pendingSnapshots()) {
285
+ if (operatorProgressionSuspension(env)) return replayed;
279
286
  if (now() >= deadlineAt) return replayed;
280
287
  if (!refreshReplayLock(projectDir, held)) return replayed;
281
288
  store.captureFrozen(snapshot, { canCommit: () => refreshReplayLock(projectDir, held) });
@@ -283,6 +290,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
283
290
  replayed += 1;
284
291
  }
285
292
  for (const file of queuedCaptures(projectDir)) {
293
+ if (operatorProgressionSuspension(env)) return replayed;
286
294
  if (now() >= deadlineAt) return replayed;
287
295
  if (!refreshReplayLock(projectDir, held)) return replayed;
288
296
  const claimed = claimQueued(file);
@@ -292,6 +300,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
292
300
  returnClaim(claimed);
293
301
  return replayed;
294
302
  }
303
+ if (operatorProgressionSuspension(env)) { returnClaim(claimed); return replayed; }
295
304
  let job = null;
296
305
  try { job = JSON.parse(fs.readFileSync(claimed, 'utf8')); } catch { /* torn: dropped below */ }
297
306
  let committed = false;
@@ -302,7 +311,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
302
311
  const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome });
303
312
  if (developmentHooksSuspended(job.originProjectDir || projectDir)
304
313
  || (consent.skipped && !consent.skipped.startsWith('no project memory db'))) { returnClaim(claimed); return replayed; }
305
- const options = { rawInput: JSON.stringify(job.payload), host: job.host,
314
+ const options = { rawInput: JSON.stringify(job.payload), host: job.host, env,
306
315
  budgetMs: Math.max(0, deadlineAt - now()), makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
307
316
  captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
308
317
  captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) };
@@ -327,6 +336,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
327
336
 
328
337
  /** Synchronous bounded startup drain. Pending debt must downgrade restore, never disappear. */
329
338
  export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } = {}) {
339
+ if (operatorProgressionSuspension(options.env || process.env)) return { state: 'suspended', replayed: 0, pending: null };
330
340
  const startedAt = Date.now();
331
341
  const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: Math.max(1, Math.min(300, budgetMs)) });
332
342
  const root = resolution.projectRoot;
@@ -336,4 +346,3 @@ export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } =
336
346
  const pending = queuedWork(root) + outboxPending;
337
347
  return { state: pending ? 'pending' : 'settled', replayed, pending };
338
348
  }
339
-
@@ -14,7 +14,32 @@ function canonicalize(value) {
14
14
  }
15
15
 
16
16
  export function digestCanonical(value) {
17
- return crypto.createHash('sha256').update(JSON.stringify(canonicalize(value))).digest('hex');
17
+ return crypto.createHash('sha256').update(JSON.stringify(isCanonicalJson(value) ? value : canonicalize(value))).digest('hex');
18
+ }
19
+
20
+ // Stored snapshots are already canonical. Verify that fact without constructing a second
21
+ // complete object graph; arbitrary callers still receive the original sorting/validation path.
22
+ function isCanonicalJson(value) {
23
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return true;
24
+ if (typeof value === 'number') return Number.isFinite(value);
25
+ if (Array.isArray(value)) {
26
+ if (Object.getPrototypeOf(value) !== Array.prototype || ['toJSON', 'map', 'constructor'].some(key => Object.hasOwn(value, key))) return false;
27
+ for (let index = 0; index < value.length; index++) {
28
+ const descriptor = Object.getOwnPropertyDescriptor(value, index);
29
+ if (!descriptor || !Object.hasOwn(descriptor, 'value') || !isCanonicalJson(descriptor.value)) return false;
30
+ }
31
+ return true;
32
+ }
33
+ if (!value || typeof value !== 'object' || Object.getPrototypeOf(value) !== Object.prototype) return false;
34
+ if (Object.hasOwn(value, 'toJSON')) return false;
35
+ let previous;
36
+ for (const key of Object.keys(value)) {
37
+ if (previous !== undefined && previous > key) return false;
38
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
39
+ if (!Object.hasOwn(descriptor, 'value') || !isCanonicalJson(descriptor.value)) return false;
40
+ previous = key;
41
+ }
42
+ return true;
18
43
  }
19
44
 
20
45
  function requireRecord(value, label) {
@@ -115,6 +140,35 @@ const INLINE_SECRETS = Object.freeze([
115
140
  },
116
141
  ]);
117
142
 
143
+ // Validation needs a verdict, not a rebuilt redacted payload. Keep exactly the writer's
144
+ // key and replacement semantics, including already-redacted markers and nested values.
145
+ function containsUnredactedSecret(value) {
146
+ const kinds = new Map();
147
+ const visit = (current) => {
148
+ if (typeof current === 'string') {
149
+ if (!/PRIVATE KEY|Bearer|(?:sk|api-key|ghp|github_pat)[-_]|password|passwd|token|secret|credential/i.test(current)) return false;
150
+ for (const rule of INLINE_SECRETS) {
151
+ rule.pattern.lastIndex = 0;
152
+ if (current.replace(rule.pattern, rule.replace) !== current) return true;
153
+ }
154
+ return false;
155
+ }
156
+ if (Array.isArray(current)) {
157
+ for (const item of current) if (visit(item)) return true;
158
+ } else if (current && typeof current === 'object' && Object.getPrototypeOf(current) === Object.prototype) {
159
+ for (const key of Object.keys(current)) {
160
+ if (!kinds.has(key)) kinds.set(key, secretKind(key));
161
+ const kind = kinds.get(key);
162
+ if (kind && current[key] !== null && current[key] !== undefined) {
163
+ if (current[key] !== `[REDACTED:${kind}]`) return true;
164
+ } else if (visit(current[key])) return true;
165
+ }
166
+ }
167
+ return false;
168
+ };
169
+ return visit(value);
170
+ }
171
+
118
172
  export function redactProgression(value) {
119
173
  const redactions = [];
120
174
  const mark = (path, kind) => {
@@ -239,15 +293,15 @@ export function validateProgressionSnapshot(snapshot, { expectedProjectIdentity
239
293
  } catch { errors.push('event key is unverifiable'); }
240
294
  }
241
295
  try {
242
- const body = canonicalize(snapshot);
243
- delete body.payloadDigest;
296
+ if (Object.getPrototypeOf(snapshot) !== Object.prototype) throw new TypeError('snapshot must be a plain JSON object');
297
+ const { payloadDigest: _digest, ...body } = snapshot;
244
298
  if (snapshot.payloadDigest !== digestCanonical(body)) errors.push('payload digest mismatch');
245
299
  } catch { errors.push('payload is not canonical JSON'); }
246
300
  if (Array.isArray(snapshot.redactions)
247
301
  && snapshot.redactions.some((row) => !row || typeof row.path !== 'string' || typeof row.kind !== 'string'
248
302
  || Object.keys(row).sort().join(',') !== 'kind,path')) errors.push('invalid redaction marker');
249
303
  try {
250
- if (redactProgression(snapshot).redactions.length > 0) errors.push('unredacted secret material');
304
+ if (containsUnredactedSecret(snapshot)) errors.push('unredacted secret material');
251
305
  } catch { /* canonical validation already reports the malformed payload */ }
252
306
 
253
307
  return { ok: errors.length === 0, errors: [...new Set(errors)] };
@@ -157,13 +157,21 @@ export class ProgressionOutbox {
157
157
  const committed = new Map();
158
158
  const quarantined = new Map();
159
159
  const recoveries = [];
160
+ let ordinal = 0;
160
161
  for (const record of readJsonl(this.path)) {
162
+ ordinal += 1;
161
163
  requireIdentity(record?.eventKey, 'outbox eventKey');
162
164
  requireIdentity(record?.payloadDigest, 'outbox payloadDigest');
163
165
  if (record.type === 'snapshot') {
164
166
  const prior = snapshots.get(record.eventKey);
165
167
  if (prior && prior.payloadDigest !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox event key collision');
166
- else snapshots.set(record.eventKey, record);
168
+ // Retain identity/recovery metadata, never every historical snapshot's full payload.
169
+ // A second incremental scan materializes only the unresolved payloads selected here.
170
+ else snapshots.set(record.eventKey, { eventKey: record.eventKey, payloadDigest: record.payloadDigest,
171
+ ordinal, recoveryDiagnostics: record.snapshot?.recoveryDiagnostics && {
172
+ originalEventKey: record.snapshot.recoveryDiagnostics.originalEventKey,
173
+ frozenPayloadDigest: record.snapshot.recoveryDiagnostics.frozenPayloadDigest,
174
+ }, snapshotDigest: record.snapshot?.payloadDigest });
167
175
  } else if (record.type === 'commit') {
168
176
  const prior = committed.get(record.eventKey);
169
177
  if (prior && prior !== record.payloadDigest) quarantined.set(record.eventKey, 'outbox commit collision');
@@ -186,19 +194,27 @@ export class ProgressionOutbox {
186
194
  }
187
195
  }
188
196
  const recovered = new Set(recoveries.filter((record) => {
189
- const target = snapshots.get(record.recoveryEventKey)?.snapshot;
197
+ const target = snapshots.get(record.recoveryEventKey);
190
198
  return !quarantined.has(record.recoveryEventKey)
191
199
  && committed.get(record.recoveryEventKey) === record.recoveryPayloadDigest
192
- && target?.payloadDigest === record.recoveryPayloadDigest
200
+ && target?.snapshotDigest === record.recoveryPayloadDigest
193
201
  && target.recoveryDiagnostics?.originalEventKey === record.eventKey
194
202
  && target.recoveryDiagnostics?.frozenPayloadDigest === record.payloadDigest;
195
203
  }).map((record) => `${record.eventKey}:${record.payloadDigest}`));
196
204
  this.quarantine = [...quarantined].map(([eventKey, reason]) => ({ eventKey, reason }));
197
- return [...snapshots.values()]
205
+ const pending = new Map([...snapshots.values()]
198
206
  .filter((record) => !quarantined.has(record.eventKey) && !committed.has(record.eventKey)
199
207
  && !recovered.has(`${record.eventKey}:${record.payloadDigest}`))
200
- .sort((left, right) => left.eventKey.localeCompare(right.eventKey))
201
- .map((record) => record.snapshot);
208
+ .map((record) => [record.ordinal, record.eventKey]));
209
+ const payloads = [];
210
+ ordinal = 0;
211
+ if (pending.size) {
212
+ for (const record of readJsonl(this.path)) {
213
+ ordinal += 1;
214
+ if (pending.has(ordinal)) payloads.push({ eventKey: pending.get(ordinal), snapshot: record.snapshot });
215
+ }
216
+ }
217
+ return payloads.sort((left, right) => left.eventKey.localeCompare(right.eventKey)).map((record) => record.snapshot);
202
218
  }
203
219
 
204
220
  /** Keys pendingSnapshots() refused to replay because their records disagree, with the reason. */
@@ -8,6 +8,7 @@ import { withProgressionReader } from './project-progression-reader.mjs';
8
8
  import { drainCaptureQueue, queuedWork } from './session-snapshot-hook.mjs';
9
9
  import { replayTurnQueue } from './turn-outcome-capture.mjs';
10
10
  import { STAGE_BUDGETS_MS } from './session-start-budget.mjs';
11
+ import { operatorProgressionSuspension } from './project-progression-suspension.mjs';
11
12
 
12
13
  const PROGRESSION_NAMESPACE = 'project-progression';
13
14
 
@@ -191,6 +192,15 @@ export function restoreProgressionForSession({
191
192
  },
192
193
  } = {}) {
193
194
  const projectDir = env.CLAUDE_PROJECT_DIR || cwd;
195
+ try {
196
+ if (operatorProgressionSuspension(env)) {
197
+ // Turn recording is independent: suspension must not strand ordinary AgentDB capture.
198
+ const turns = replayTurnQueue({ projectDir, env, home: env.HOME || os.homedir(), synchronous: true,
199
+ deadlineMs: Date.now() + deadlineMs });
200
+ return { status: 'unavailable', reason: 'operator-suspended', severity: 'info', pendingTurns: turns.pending,
201
+ context: '[RuvNet Brain — PROJECT CONTINUITY UNAVAILABLE]\nAutomatic progression capture, replay and restore are operator-suspended. Evidence and queues are preserved. Ordinary AgentDB memory and explicit checkpoints remain available; no project progression was restored.' };
202
+ }
203
+ } catch { return unknown('restore-failed'); }
194
204
  let resolution;
195
205
  try {
196
206
  resolution = resolveProjectStore({ projectDir });