ruvnet-brain 4.5.6 → 4.5.7

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 CHANGED
@@ -7,7 +7,7 @@ Created: 2026-06-29 22:36:38 EDT
7
7
 
8
8
  # 🧠 RuvNet Brain
9
9
 
10
- ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.6 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.6-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
10
+ ### 🧠 RuvNet Brain — [![RuvNet Brain version 4.5.7 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.5.7-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json)
11
11
 
12
12
  **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.**
13
13
 
@@ -1405,6 +1405,35 @@ export function bundleIdentity(src) {
1405
1405
  return parts.some(Boolean) ? parts.join('|') : null;
1406
1406
  }
1407
1407
 
1408
+ const downloadedArchiveVerifiers = new WeakSet();
1409
+
1410
+ function archiveVerifierFromDigests(actualDigests, bytes) {
1411
+ const verifier = Object.freeze({
1412
+ bytes,
1413
+ digest(algorithm = 'sha256') {
1414
+ if (!Object.hasOwn(actualDigests, algorithm)) throw new Error(`archive algorithm ${algorithm} was not captured`);
1415
+ return actualDigests[algorithm];
1416
+ },
1417
+ });
1418
+ downloadedArchiveVerifiers.add(verifier);
1419
+ return verifier;
1420
+ }
1421
+
1422
+ // Capture the closed transaction algorithm inventory synchronously. Never retain mutable bytes:
1423
+ // the capability attests only to this creation-time snapshot, not a later caller-owned Buffer.
1424
+ export function createDownloadedArchiveVerifier(downloadedBuffer, expectedDigests = []) {
1425
+ if (!Buffer.isBuffer(downloadedBuffer) || !Array.isArray(expectedDigests)) throw new TypeError('archive bytes and digest inventory required');
1426
+ const algorithms = new Set(['sha256']);
1427
+ for (const expected of expectedDigests) {
1428
+ if (expected == null) continue;
1429
+ if (typeof expected !== 'string' || !expected) throw new TypeError('invalid archive digest');
1430
+ algorithms.add(expected.includes(':') ? expected.split(':')[0] : 'sha256');
1431
+ }
1432
+ const actualDigests = Object.freeze(Object.fromEntries([...algorithms].map(algorithm =>
1433
+ [algorithm, createHash(algorithm).update(downloadedBuffer).digest('hex')])));
1434
+ return archiveVerifierFromDigests(actualDigests, downloadedBuffer.length);
1435
+ }
1436
+
1408
1437
  /**
1409
1438
  * Confirm the download+extraction actually changed what is on disk (issue #35 item 2, Dr. Mark
1410
1439
  * Allen / @mamd69).
@@ -1448,8 +1477,11 @@ export function bundleIdentity(src) {
1448
1477
  * @returns {{ok: boolean, reason: string|null, landed: object|null, kind: 'noop'|'damaged'|null,
1449
1478
  * storeUnchanged: boolean, bundleChanged: boolean|null}}
1450
1479
  */
1451
- export function verifyLanded({ kbDir, kbName, before, beforeBundle = null, expectedDigest = null, downloadedBuffer = null }) {
1480
+ export function verifyLanded({ kbDir, kbName, before, beforeBundle = null, expectedDigest = null, downloadedBuffer = null, archiveVerifier = null }) {
1452
1481
  const damaged = (reason, landed = null) => ({ ok: false, kind: 'damaged', reason, landed, storeUnchanged: false, bundleChanged: null });
1482
+ if (archiveVerifier !== null && (!downloadedArchiveVerifiers.has(archiveVerifier) || downloadedBuffer !== null)) {
1483
+ return damaged('archive verification capability is invalid or mixed with mutable archive bytes');
1484
+ }
1453
1485
  const p = path.join(kbDir, 'SOURCE.json');
1454
1486
  if (!fs.existsSync(p)) {
1455
1487
  return damaged(`no SOURCE.json found at ${p} after extraction — cannot confirm what actually landed`);
@@ -1487,9 +1519,11 @@ export function verifyLanded({ kbDir, kbName, before, beforeBundle = null, expec
1487
1519
 
1488
1520
  // Bytes that do not match what the release declared are a HARD failure whatever the identities
1489
1521
  // say, and the copy now in place is suspect — so this is checked before anything else.
1490
- if (expectedDigest && downloadedBuffer) {
1522
+ if (expectedDigest && (downloadedBuffer || archiveVerifier)) {
1491
1523
  const algo = expectedDigest.includes(':') ? expectedDigest.split(':')[0] : 'sha256';
1492
- const actual = `${algo}:${createHash(algo).update(downloadedBuffer).digest('hex')}`;
1524
+ let actual;
1525
+ try { actual = `${algo}:${archiveVerifier ? archiveVerifier.digest(algo) : createHash(algo).update(downloadedBuffer).digest('hex')}`; }
1526
+ catch (error) { return damaged(`downloaded bundle digest verification failed: ${error.message}`, landed); }
1493
1527
  if (actual !== expectedDigest) {
1494
1528
  return damaged(`downloaded bundle digest ${actual} does not match the release-declared digest ${expectedDigest}`, landed);
1495
1529
  }
@@ -1705,7 +1739,7 @@ async function main() {
1705
1739
  const originLabel = resolved.origin === 'latest-release-asset' ? `live release asset "${resolved.assetName}"`
1706
1740
  : resolved.origin === 'live-manifest' ? 'live manifest' : 'PINNED FALLBACK (see warning above)';
1707
1741
  console.log(`\n[${behindStores.length} store(s)] downloading ${resolved.url}\n (source: ${originLabel}) ...`);
1708
- const buf = await fetchBuffer(resolved.url);
1742
+ let buf = await fetchBuffer(resolved.url);
1709
1743
  const sigBuf = await fetchBuffer(`${resolved.url}.sig`, { failureCode: 3, kind: 'signature' });
1710
1744
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'forge-update-release-'));
1711
1745
  const zipPath = path.join(tmp, 'bundle.zip');
@@ -1718,6 +1752,14 @@ async function main() {
1718
1752
  const signature = verifyDownloadedBundle(zipPath, sigPath);
1719
1753
  if (!signature.ok) { fs.rmSync(tmp, { recursive: true, force: true }); die(`✗ SIGNATURE VERIFICATION FAILED: ${signature.reason}`, 4); }
1720
1754
  console.log(` ✓ signature verified — ${signature.reason}`);
1755
+ let archiveVerifier;
1756
+ try { archiveVerifier = createDownloadedArchiveVerifier(buf, resolvedTargets.map(({ resolved: target }) => target.digest)); }
1757
+ catch (error) {
1758
+ fs.rmSync(tmp, { recursive: true, force: true });
1759
+ die(`archive digest snapshot failed: ${error.message} — local files untouched.`, 4);
1760
+ }
1761
+ const bundleSha256 = archiveVerifier.digest();
1762
+ buf = null; // Snapshot owns only immutable digest strings; release the downloaded bytes before validation.
1721
1763
  // DISK-SPACE PREFLIGHT, before a single byte is unpacked: the bundle unpacks in temp, then a whole
1722
1764
  // candidate generation is built beside the live one: the bundle, plus what prepareCandidate carries into
1723
1765
  // it from live — node_modules, and every private/local-ingest store file (restorePrivateFilesIntoCandidate
@@ -1801,7 +1843,7 @@ async function main() {
1801
1843
  for (const { local, resolved: storeResolution } of landingTargets) {
1802
1844
  execFileSync(process.execPath, [guard, '--dir', dir, '--name', local.kbName], { cwd: dir, stdio: 'pipe' });
1803
1845
  const verified = verifyLanded({ kbDir: dir, kbName: local.kbName, before: local, beforeBundle: source,
1804
- expectedDigest: storeResolution.digest, downloadedBuffer: buf });
1846
+ expectedDigest: storeResolution.digest, archiveVerifier });
1805
1847
  if (!verified.ok && verified.kind !== 'noop') return { valid: false, failures: [verified.reason] };
1806
1848
  if (phase === 'live') finalVerificationByStore.set(local.kbName, verified);
1807
1849
  }
@@ -1872,7 +1914,6 @@ async function main() {
1872
1914
  fs.rmSync(tmp, { recursive: true, force: true });
1873
1915
  console.log(` storage transaction: ${transaction.terminalVerdict}`);
1874
1916
  if (transaction.terminalVerdict === 'cleanup-pending') {
1875
- const bundleSha256 = createHash('sha256').update(buf).digest('hex');
1876
1917
  const cleanupOutcome = writeUpdateOutcome({ terminalVerdict: 'cleanup-pending', storageDelta: transaction.storageDelta,
1877
1918
  transactionReceipts: transaction.paths.receipts, bundleSha256,
1878
1919
  phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: 'cleanup-pending', bundleSha256,
@@ -1886,7 +1927,7 @@ async function main() {
1886
1927
  for (const { local, resolved: storeResolution } of landingTargets) {
1887
1928
  const verified = finalVerificationByStore.get(local.kbName)
1888
1929
  || verifyLanded({ kbDir: KB_DIR, kbName: local.kbName, before: local, beforeBundle: source,
1889
- expectedDigest: storeResolution.digest, downloadedBuffer: buf });
1930
+ expectedDigest: storeResolution.digest, archiveVerifier });
1890
1931
  if (verified.storeUnchanged || verified.kind === 'noop') unchangedStores.push(local.kbName);
1891
1932
  landedByStore.set(local.kbName, { landed: verified.landed, origin: storeResolution.origin, assetName: storeResolution.assetName });
1892
1933
  }
@@ -1947,10 +1988,10 @@ async function main() {
1947
1988
  currencyVerdict: verdict.verdict, currencyReason: verdict.reason, candidateKind: candidateIdentity.kind,
1948
1989
  storageDelta: transaction.storageDelta,
1949
1990
  transactionReceipts: transaction.paths.receipts,
1950
- bundleSha256: createHash('sha256').update(buf).digest('hex'),
1991
+ bundleSha256,
1951
1992
  coverageSha256: sha256File(path.join(KB_DIR, 'COVERAGE.json')),
1952
1993
  phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: transaction.terminalVerdict,
1953
- bundleSha256: createHash('sha256').update(buf).digest('hex'),
1994
+ bundleSha256,
1954
1995
  transactionReceipts: transaction.paths.receipts, overlay: privateOverlay,
1955
1996
  storageDelta: transaction.storageDelta }) });
1956
1997
  if (finalOutcome?.terminalVerdict === 'recovery-required') die(finalOutcome.reason);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.6",
3
+ "version": "4.5.7",
4
4
  "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
3
  "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 77 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships a UserPromptSubmit retrieve-and-inject grounding hook and a PreToolUse write gate that refuses ungrounded rUv-product code until search_ruvnet has been consulted (ADR-0012 / ADR-067).",
4
- "version": "4.5.6",
4
+ "version": "4.5.7",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
7
7
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ruvnet-brain",
3
- "version": "4.5.6",
3
+ "version": "4.5.7",
4
4
  "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.",
5
5
  "author": {
6
6
  "name": "Stuart Kerr"
@@ -1,6 +1,7 @@
1
1
  ---
2
2
  description: Open the RuvNet Brain Console (/rnbc) — a local web page that mirrors YOUR machine's RuvNet setup, explains it in plain English, and lets you safely configure and fix it. Read-only until you click; every machine change is explained first and is reversible.
3
- updated: 2026-10-01
3
+ updated: 2026-10-04
4
+ version: 1.0.1
4
5
  ---
5
6
 
6
7
  Launch the **RuvNet Brain Console** (RNBC) for the user. `/rnbc` is its name; `/rnb`, `/rvbc`,
@@ -64,6 +65,17 @@ header chip says how fresh the reading is and re-measures if they click it.
64
65
  Report it plainly — the real error, not a guess — and offer to fix it. Never leave them staring at
65
66
  a dead tab wondering whether it is still loading.
66
67
 
68
+ ## Promise capture preference
69
+
70
+ If the user asks to stop automatic promise capture, explain the supported setting:
71
+ `RUVNET_PROMISE_CAPTURE=off` in the hook process environment. It can be supplied for the machine or
72
+ one project through the host's environment configuration. The exact value `off` stops capturing new
73
+ "I'll …" promises; unset it or set `on` to resume capture. This preference is separate from the Brain
74
+ on/off switch and is not currently a Console toggle. Existing promises remain visible and forceable,
75
+ and close only on verified evidence. Capability and completion integrity checks keep running. Do not
76
+ disable the Stop hook or edit the work ledger to apply this preference. Reopen the host if its current
77
+ session has not picked up the changed environment.
78
+
67
79
  ---
68
80
 
69
81
  ## Things NOT to do before the page is open
@@ -297,7 +297,7 @@
297
297
  "codex"
298
298
  ],
299
299
  "mode": "advisory",
300
- "offBehavior": "run",
300
+ "offBehavior": "silence",
301
301
  "matcher": "*",
302
302
  "timeout": 6
303
303
  },
@@ -0,0 +1,91 @@
1
+ // DISTINCT-FROM hook-shim.mjs: managed execution fails closed; advisory hook fallback cannot
2
+ // authorize a command. The protocol shell owns declarations, each request owns one generation.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ const LOCAL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
11
+ const contained = (root, file) => file.startsWith(`${root}${path.sep}`);
12
+
13
+ export function managedGenerationIdentity(root, { version, generation = 'source' } = {}) {
14
+ root = fs.realpathSync(root);
15
+ const manifestPath = path.join(root, '.claude-plugin', 'plugin.json');
16
+ const handlerPath = path.join(root, 'mcp', 'managed-cli-interface.mjs');
17
+ for (const file of [manifestPath, handlerPath]) {
18
+ if (!fs.lstatSync(file).isFile() || !contained(root, fs.realpathSync(file))) {
19
+ throw new Error('managed generation resource is not a contained regular file');
20
+ }
21
+ }
22
+ const manifestBytes = fs.readFileSync(manifestPath);
23
+ const manifest = JSON.parse(manifestBytes);
24
+ if (manifest.name !== 'ruvnet-brain' || typeof manifest.version !== 'string'
25
+ || !/^\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.+-]+)?$/.test(manifest.version)
26
+ || (version !== undefined && manifest.version !== version)) {
27
+ throw new Error('managed generation manifest name/version mismatch');
28
+ }
29
+ const sourceDigest = digest(Buffer.concat([manifestBytes, fs.readFileSync(handlerPath)]));
30
+ return Object.freeze({ root, handlerPath, version: manifest.version, generation, sourceDigest,
31
+ binding: digest(JSON.stringify([root, manifest.version, generation, sourceDigest])) });
32
+ }
33
+
34
+ function selectGeneration(env, fallbackRoot) {
35
+ const home = env.HOME || os.homedir();
36
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
37
+ let activeBytes;
38
+ try { activeBytes = fs.readFileSync(path.join(brainHome, 'active.json'), 'utf8'); }
39
+ catch (error) {
40
+ if (error.code !== 'ENOENT') throw error;
41
+ // A copied persistent shell must never manufacture an active generation from its resources.
42
+ if (path.resolve(fallbackRoot) === path.join(home, '.claude', 'ruvnet-brain')) {
43
+ throw new Error('managed active generation unavailable');
44
+ }
45
+ return { ...managedGenerationIdentity(fallbackRoot), brainHome, leased: false };
46
+ }
47
+ const active = JSON.parse(activeBytes);
48
+ if (typeof active.codeRoot !== 'string' || typeof active.version !== 'string'
49
+ || !Number.isSafeInteger(active.generation) || active.generation < 0) {
50
+ throw new Error('managed active generation descriptor invalid');
51
+ }
52
+ const versions = fs.realpathSync(path.join(brainHome, 'versions'));
53
+ const root = fs.realpathSync(path.resolve(brainHome, active.codeRoot));
54
+ if (!contained(versions, root) || path.basename(root) !== active.version) {
55
+ throw new Error('managed active generation escapes its version tree');
56
+ }
57
+ return { ...managedGenerationIdentity(root, active), brainHome, leased: true };
58
+ }
59
+
60
+ export function createManagedCliDispatcher({ fallbackRoot = LOCAL_ROOT,
61
+ importModule = (url) => import(url) } = {}) {
62
+ const pins = new Map();
63
+ return async function dispatchManagedCli(toolName, args, env = process.env,
64
+ fetchImpl = globalThis.fetch, lifecycle = {}) {
65
+ let lease;
66
+ try {
67
+ const selected = selectGeneration(env, fallbackRoot);
68
+ // ESM caches by URL. A changed file at the same immutable path must never be blessed as
69
+ // new code while execution still uses the old cached module (nor can query strings fix dependencies).
70
+ const pinned = pins.get(selected.root);
71
+ if (pinned && pinned !== selected.sourceDigest) throw new Error('managed immutable generation changed; restart required');
72
+ pins.set(selected.root, selected.sourceDigest);
73
+ if (selected.leased) {
74
+ const dir = path.join(selected.brainHome, 'leases');
75
+ fs.mkdirSync(dir, { recursive: true });
76
+ lease = path.join(dir, `mcp-managed-${process.pid}-${randomUUID()}.json`);
77
+ fs.writeFileSync(lease, JSON.stringify({ pid: process.pid, version: selected.version, at: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
78
+ }
79
+ const handler = await importModule(pathToFileURL(selected.handlerPath).href);
80
+ if (typeof handler.callManagedCli !== 'function') throw new Error('managed generation handler unavailable');
81
+ return await handler.callManagedCli(toolName, args, env, fetchImpl,
82
+ { ...lifecycle, generationBinding: selected.binding });
83
+ } catch (error) {
84
+ return { content: [{ type: 'text', text: `managed generation unavailable: ${error.message}` }], isError: true };
85
+ } finally {
86
+ if (lease) { try { fs.unlinkSync(lease); } catch { /* lease expiry preserves interrupted calls */ } }
87
+ }
88
+ };
89
+ }
90
+
91
+ export const dispatchManagedCli = createManagedCliDispatcher();
@@ -2,11 +2,14 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { managedGenerationIdentity } from './managed-cli-generation.mjs';
5
7
  import { loadRuntimePreferences, runtimeChildEnv } from '../scripts/runtime-preferences.mjs';
6
8
  import { projectDirectory } from '../scripts/project-identity.mjs';
7
9
  import { recordManagedCliObservation, recordRegistryLatestObservation } from '../scripts/capability-claim-evidence.mjs';
8
10
  import { runSessionSnapshotHook } from '../scripts/session-snapshot-hook.mjs';
9
11
  import { resolveProjectStore } from '../scripts/project-store-resolver.mjs';
12
+ import { isAutomaticProgressionSuspension } from '../scripts/project-progression-suspension.mjs';
10
13
 
11
14
  export const MANAGED_EXECUTABLES = Object.freeze([
12
15
  'ruflo',
@@ -34,6 +37,12 @@ const MAX_ARG_BYTES = 8192;
34
37
  const DEFAULT_FRESH_MS = 24 * 60 * 60 * 1000;
35
38
  const DEFAULT_TIMEOUT_MS = 30_000;
36
39
  const MAX_OUTPUT_BYTES = 1024 * 1024;
40
+ // Bind direct calls to the module loaded at process start, too. Missing shell resources do not
41
+ // prevent declarations from loading; the active dispatcher supplies the execution binding.
42
+ const LOCAL_BINDING = (() => {
43
+ try { return managedGenerationIdentity(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')).binding; }
44
+ catch { return null; }
45
+ })();
37
46
 
38
47
  const executableSchema = {
39
48
  type: 'string',
@@ -139,27 +148,32 @@ function stateDir(env) {
139
148
  return env.RUVNET_BRAIN_HELP_READ_DIR || path.join(brainHome, 'help-read');
140
149
  }
141
150
 
142
- function freshStamp(executable, argv, env, now = Date.now()) {
151
+ function freshStamp(executable, argv, env, binding, now = Date.now()) {
143
152
  const stamp = path.join(stateDir(env), helpKey(executable, argv));
144
- let stat;
153
+ let stat; let readAt;
145
154
  try {
146
- stat = fs.statSync(stamp);
155
+ stat = fs.lstatSync(stamp);
156
+ if (!stat.isFile()) return false;
157
+ const receipt = JSON.parse(fs.readFileSync(stamp, 'utf8'));
158
+ if (receipt.schemaVersion !== 1 || receipt.generation !== binding
159
+ || !Number.isFinite(receipt.readAt) || receipt.readAt > now) return false;
160
+ readAt = receipt.readAt;
147
161
  } catch {
148
162
  return false;
149
163
  }
150
164
  const configured = Number(env.RUVNET_BRAIN_HELP_MAX_AGE_MS);
151
165
  const maxAge = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_FRESH_MS;
152
- return now - stat.mtimeMs < maxAge;
166
+ return now - stat.mtimeMs < maxAge && now - readAt < maxAge;
153
167
  }
154
168
 
155
- function writeStamps(executable, argv, env) {
169
+ function writeStamps(executable, argv, env, binding) {
156
170
  const dir = stateDir(env);
157
171
  fs.mkdirSync(dir, { recursive: true });
158
172
  for (const key of stampKeysForHelp(executable, argv)) {
159
173
  const stamp = path.join(dir, key);
160
174
  const temporary = path.join(dir, `.${key}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
161
175
  try {
162
- fs.writeFileSync(temporary, '', { flag: 'wx', mode: 0o600 });
176
+ fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, generation: binding, readAt: Date.now() }), { flag: 'wx', mode: 0o600 });
163
177
  fs.renameSync(temporary, stamp);
164
178
  } finally {
165
179
  try { fs.unlinkSync(temporary); } catch { /* renamed or never created */ }
@@ -291,7 +305,8 @@ function managedProgressionCapture({ executable, argv, projectRoot, env, event,
291
305
  },
292
306
  } : {}),
293
307
  };
294
- return { adopted: true, ...runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host }) };
308
+ const result = runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host, env });
309
+ return isAutomaticProgressionSuspension(result) ? result : { adopted: true, ...result };
295
310
  }
296
311
 
297
312
  function resultOf(executable, argv, result) {
@@ -316,6 +331,8 @@ function resultOf(executable, argv, result) {
316
331
 
317
332
  export async function callManagedCli(toolName, args, env = process.env, fetchImpl = globalThis.fetch, lifecycle = {}) {
318
333
  try {
334
+ const binding = lifecycle.generationBinding || LOCAL_BINDING;
335
+ if (!binding) throw new Error('managed generation identity unavailable');
319
336
  const executable = assertExecutable(args?.executable);
320
337
  const argv = literalArgv(args?.argv ?? []);
321
338
  const projectRoot = env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory({ env });
@@ -344,13 +361,13 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
344
361
  stampKeysForHelp(executable, argv);
345
362
  const commandArgv = [...argv, '--help'];
346
363
  const execution = await execute(executable, commandArgv, env);
347
- if (execution.code === 0 && !execution.error) writeStamps(executable, argv, env);
364
+ if (normalizeManagedExecution(execution).outcome === 'success') writeStamps(executable, argv, env, binding);
348
365
  recordManagedCliObservation({ toolName, executable, argv: commandArgv, execution, env });
349
366
  return resultOf(executable, commandArgv, execution);
350
367
  }
351
368
 
352
369
  if (toolName === 'ruvnet_cli_run') {
353
- if (!freshStamp(executable, argv, env)) {
370
+ if (!freshStamp(executable, argv, env, binding)) {
354
371
  const key = helpKey(executable, argv).replaceAll('.', ' ');
355
372
  return {
356
373
  content: [{ type: 'text', text: `Read the interface first with ruvnet_cli_help for: ${key}` }],
@@ -391,7 +408,8 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
391
408
  : env;
392
409
  const capture = typeof lifecycle.capture === 'function' ? lifecycle.capture : managedProgressionCapture;
393
410
  const beforeCapture = await capture({ executable, argv, projectRoot, env: childEnv, event: 'PreToolUse' });
394
- if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true)) {
411
+ if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true
412
+ && !isAutomaticProgressionSuspension(beforeCapture))) {
395
413
  const reason = beforeCapture.error || beforeCapture.skipped || 'pre-execution progression was not durably captured';
396
414
  return { content: [{ type: 'text', text: `managed execution refused: ${reason}` }], isError: true };
397
415
  }
@@ -405,13 +423,19 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
405
423
  await lifecycle.afterExecute({ executable, argv, projectRoot, env: childEnv, execution, normalized });
406
424
  }
407
425
  recordManagedCliObservation({ toolName, executable, argv, execution, env });
408
- if (afterCapture?.adopted && afterCapture.progressionCaptured !== true) {
426
+ if (afterCapture?.adopted && afterCapture.progressionCaptured !== true
427
+ && !isAutomaticProgressionSuspension(afterCapture)) {
409
428
  return {
410
429
  content: [{ type: 'text', text: `managed command ${normalized.outcome}; continuity result was not durably captured: ${afterCapture.skipped || 'unknown reason'}` }],
411
430
  isError: true,
412
431
  };
413
432
  }
414
- return resultOf(executable, argv, execution);
433
+ const result = resultOf(executable, argv, execution);
434
+ if (isAutomaticProgressionSuspension(beforeCapture) || isAutomaticProgressionSuspension(afterCapture)) {
435
+ result.content.push({ type: 'text', text: 'Automatic project progression is operator-suspended; suspended captures produced no progression receipt. Ordinary AgentDB memory and explicit checkpoints remain available.' });
436
+ result.structuredContent.continuity = 'operator-suspended';
437
+ }
438
+ return result;
415
439
  }
416
440
 
417
441
  return {
@@ -35,7 +35,8 @@ import os from 'node:os';
35
35
  import readline from 'node:readline';
36
36
  import { fileURLToPath } from 'node:url';
37
37
  import { writeOwn as writeOwnReadiness } from '../scripts/mcp-readiness.mjs';
38
- import { callManagedCli, MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
38
+ import { MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
39
+ import { dispatchManagedCli } from './managed-cli-generation.mjs';
39
40
  import { unmountedNotice } from '../scripts/brain-location.mjs';
40
41
 
41
42
  const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
@@ -362,7 +363,7 @@ async function handleClient(msg) {
362
363
  case 'tools/call': {
363
364
  if (params?.name === 'ruvnet_cli_help' || params?.name === 'ruvnet_cli_run'
364
365
  || params?.name === 'ruvnet_registry_latest') {
365
- return clientOk(id, await callManagedCli(params.name, params.arguments || {}));
366
+ return clientOk(id, await dispatchManagedCli(params.name, params.arguments || {}));
366
367
  }
367
368
  if (params?.name !== 'search_ruvnet') return clientErr(id, -32602, `unknown tool: ${params?.name}`);
368
369
  refreshLease();
@@ -385,7 +385,9 @@ function promiseBookkeeping() {
385
385
  }
386
386
  }
387
387
  const openKeys = new Set(led.items.filter((i) => i?.kind === PROMISE_KIND && !i.done && i.projectId === pid).map((i) => i.key));
388
- for (const promise of extractCommitments(hookInput.last_assistant_message)) {
388
+ // Owner preference suppresses only new capture; verified closure above and integrity audits stay active.
389
+ const newPromises = process.env.RUVNET_PROMISE_CAPTURE === 'off' ? [] : extractCommitments(hookInput.last_assistant_message);
390
+ for (const promise of newPromises) {
389
391
  if (openKeys.has(promise.key) || openKeys.size >= PROMISE_CAP_OPEN) continue;
390
392
  openKeys.add(promise.key);
391
393
  led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, at,
@@ -303,24 +303,24 @@ export function recordingLine(status, now = Date.now()) {
303
303
  return `AgentDB: recording ✓ (last write ${ago(now - status.lastCommitAt)} ago, ${status.eventsToday} event(s) today, outbox ${status.pending} pending)`;
304
304
  }
305
305
 
306
- /**
307
- * The Claude Stop line, at most ONCE per session per condition (review S3: it repeated at every turn).
308
- * State is a tiny file in the project's own .swarm, bounded to the last 20 sessions.
309
- */
310
- export function stopNotice({ journal, status, session }) {
311
- if (!status?.stuck || !status.problem) return '';
312
- const file = path.join(journal.swarm, NOTICE_STATE);
306
+ /** Shared notices: once per session/condition, bounded to the last twenty sessions in .swarm. */
307
+ export function conditionNotice({ swarm, session, condition, message, now = Date.now }) {
308
+ const file = path.join(swarm, NOTICE_STATE);
313
309
  let state = {};
314
310
  try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first notice */ }
315
311
  const id = String(session || 'unknown');
316
312
  const shown = Array.isArray(state[id]?.conditions) ? state[id].conditions : [];
317
- if (shown.includes(status.problem)) return '';
318
- state[id] = { at: new Date(journal.now()).toISOString(), conditions: [...shown, status.problem] };
313
+ if (shown.includes(condition)) return '';
314
+ state[id] = { at: new Date(now()).toISOString(), conditions: [...shown, condition] };
319
315
  const recent = Object.entries(state).sort((a, b) => String(b[1]?.at).localeCompare(String(a[1]?.at))).slice(0, 20);
320
316
  try { fs.writeFileSync(file, JSON.stringify(Object.fromEntries(recent)), { mode: 0o600 }); } catch { /* still show it once */ }
321
- return `[RuvNet Brain] ${recordingLine(status, journal.now())}`;
317
+ return message;
318
+ }
319
+ export function stopNotice({ journal, status, session }) {
320
+ if (!status?.stuck || !status.problem) return '';
321
+ return conditionNotice({ swarm: journal.swarm, session, condition: status.problem, now: () => journal.now(),
322
+ message: `[RuvNet Brain] ${recordingLine(status, journal.now())}` });
322
323
  }
323
-
324
324
  function defaultStore({ ruflo, db, key, value }) {
325
325
  const cwd = rufloRunDir(db);
326
326
  try {
@@ -86,7 +86,8 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
86
86
  const TABLE = {
87
87
  'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
88
88
  'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
89
- 'capacity-aware-parallel-work': { file: 'capacity-aware-parallel-work.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 32768 },
89
+ // Capacity advice is a Brain prompt hint; OFF must not execute its resource probes or steer work.
90
+ 'capacity-aware-parallel-work': { file: 'capacity-aware-parallel-work.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
90
91
  // ADR-063 / issue #103: `blocking` so an opt-in refusal can actually reach the host. The hook
91
92
  // still exits 0 for every user at the shipped default (managedMemoryBoundary=advise), so this
92
93
  // changes the CEILING of what it may do, not what it does.
@@ -46,10 +46,11 @@ const storeList = (source) => {
46
46
  return stores && typeof stores === 'object' ? Object.entries(stores).map(([kbName, value]) => ({ kbName, ...value })) : [];
47
47
  };
48
48
  const stemOf = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i, '').toLowerCase();
49
- // The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store>.meta.json, <store>.passages.jsonl,
50
- // <store>.symbols.json, <store>-primer.md.
49
+ // The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store>[.big].meta.json,
50
+ // <store>[.big].passages.jsonl, <store>[.big].symbols.json, <store>-primer.md.
51
51
  const storeStem = (file) => path.basename(String(file)).toLowerCase()
52
- .replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '').replace(/\.(?:meta|symbols)\.json$|\.passages\.jsonl$/, '');
52
+ .replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '')
53
+ .replace(/(?:\.big)?(?:\.(?:meta|symbols)\.json|\.passages\.jsonl)$/, '');
53
54
  // Files the installer/updater writes into a KB that no bundle ships (bin/install.mjs placeUpdater,
54
55
  // placeTrustedCoverageValidator, ensureVerifier; the updater's snapshot receipt). Re-created on every install.
55
56
  const INSTALLER_WRITTEN = new Set(['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json', '.refresh-snapshot.json',
@@ -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
-
@@ -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 });
@@ -0,0 +1,60 @@
1
+ // Operator containment only (#390 B). No store, checkpoint, event, turn or search is disabled here.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { randomUUID } from 'node:crypto';
7
+
8
+ const suspendedResults = new WeakSet();
9
+ export function progressionSuspensionFile(env = process.env) {
10
+ return path.join(env.RUVNET_BRAIN_STATE_DIR || path.join(env.HOME || os.homedir(), '.config', 'ruvnet-brain'), 'brain-progression-suspension');
11
+ }
12
+ export function operatorProgressionSuspension(env = process.env) {
13
+ if (env.RUVNET_BRAIN_PROGRESSION_SUSPENDED === '1') return { source: 'environment' };
14
+ if (env.RUVNET_BRAIN_PROGRESSION_SUSPENDED && env.RUVNET_BRAIN_PROGRESSION_SUSPENDED !== '0') {
15
+ throw new Error('invalid automatic progression suspension environment setting');
16
+ }
17
+ let value;
18
+ try { value = JSON.parse(fs.readFileSync(progressionSuspensionFile(env), 'utf8')); }
19
+ catch (error) { if (error.code === 'ENOENT') return null; throw new Error('automatic progression suspension policy unreadable or invalid'); }
20
+ if (value?.schemaVersion !== 1 || typeof value.suspended !== 'boolean') {
21
+ throw new Error('automatic progression suspension policy unreadable or invalid');
22
+ }
23
+ return value.suspended ? { source: 'operator-state' } : null;
24
+ }
25
+ export function automaticProgressionSuspensionResult(env = process.env, fields = {}) {
26
+ const control = operatorProgressionSuspension(env);
27
+ if (!control) return null;
28
+ const result = Object.freeze({ ...fields, progressionSuspended: true, progressionCaptured: false,
29
+ receipt: null, skipped: 'operator suspended automatic project progression', suspensionSource: control.source });
30
+ suspendedResults.add(result);
31
+ return result;
32
+ }
33
+ // A look-alike public flag is never authority to bypass an unexpected capture failure.
34
+ export const isAutomaticProgressionSuspension = (result) => Boolean(result && suspendedResults.has(result));
35
+
36
+ /** Explicit operator configuration: replacing this control file never touches evidence or queues. */
37
+ export function setOperatorProgressionSuspension(suspended, { env = process.env } = {}) {
38
+ if (typeof suspended !== 'boolean') throw new TypeError('suspended must be a boolean');
39
+ const file = progressionSuspensionFile(env);
40
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
41
+ const temporary = `${file}.${process.pid}.${randomUUID()}.tmp`;
42
+ try {
43
+ fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, suspended }), { flag: 'wx', mode: 0o600 });
44
+ fs.renameSync(temporary, file);
45
+ } finally { try { fs.unlinkSync(temporary); } catch { /* renamed or not created */ } }
46
+ return operatorProgressionSuspension(env);
47
+ }
48
+
49
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
50
+ try {
51
+ const argv = process.argv.slice(2);
52
+ if (argv.length !== 1 || !['--suspend', '--resume', '--status'].includes(argv[0])) {
53
+ throw new Error('usage: project-progression-suspension.mjs --suspend | --resume | --status');
54
+ }
55
+ if (argv[0] !== '--status') setOperatorProgressionSuspension(argv[0] === '--suspend');
56
+ const control = operatorProgressionSuspension();
57
+ console.log(JSON.stringify({ automaticProgression: control ? 'operator-suspended' : 'enabled', source: control?.source || null,
58
+ evidencePreserved: true, explicitCheckpointsAvailable: true }));
59
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
60
+ }
@@ -5,9 +5,11 @@ import crypto from 'node:crypto';
5
5
  import os from 'node:os';
6
6
  import { developmentHooksSuspended } from './development-maintenance.mjs';
7
7
  import { resolveTurnDb } from './turn-outcome-capture.mjs';
8
+ import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
8
9
  import { execFileSync } from 'node:child_process';
9
10
  import { fileURLToPath } from 'node:url';
10
11
  import { redactText } from './continuity-events.mjs';
12
+ import { conditionNotice } from './continuity-journal.mjs';
11
13
  import { normalizeHostEvent } from './hook-input.mjs';
12
14
  import { resolveProjectStore } from './project-store-resolver.mjs';
13
15
  import { withProgressionReader } from './project-progression-reader.mjs';
@@ -148,7 +150,9 @@ export function observeTransitionSource(resolution, projectDir = resolution.chec
148
150
 
149
151
  /** Called only by the fenced queue drainer; current heads merge, original observations stay fixed. */
150
152
  export function captureNormalizedTransition(job, { readHistory = readTransitionHistory, capture = runSessionSnapshotHook,
151
- budgetMs = 6500, makeStoreFactory, now = Date.now } = {}) {
153
+ budgetMs = 6500, makeStoreFactory, now = Date.now, env = process.env } = {}) {
154
+ const suspended = automaticProgressionSuspensionResult(env);
155
+ if (suspended) return suspended;
152
156
  const deadlineAt = now() + budgetMs;
153
157
  const resolution = resolveProjectStore({ projectDir: job.originProjectDir, gitTimeoutMs: Math.max(1, Math.min(500, budgetMs)) });
154
158
  const normalized = job.payload.normalizedTransition;
@@ -173,7 +177,7 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
173
177
  const progression = buildRestoredTransitionProgression({ resolution, observation: normalized.observation,
174
178
  snapshots, sessionIdentity: job.payload.session_id, host: job.host,
175
179
  sourceIdentity: normalized.sourceIdentity }, restored);
176
- const result = capture(job.originProjectDir, job.event, { host: job.host, budgetMs: Math.max(0, deadlineAt - now()),
180
+ const result = capture(job.originProjectDir, job.event, { host: job.host, env, budgetMs: Math.max(0, deadlineAt - now()),
177
181
  ...(makeStoreFactory ? { makeStoreFactory } : {}), writeMetadata: false,
178
182
  rawInput: JSON.stringify({ session_id: job.payload.session_id, hook_event_name: job.event, projectProgression: progression }),
179
183
  captureTurn: () => ({ recorded: false, skipped: 'transition boundary' }),
@@ -184,6 +188,8 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
184
188
  export function runProjectTransitionHook(projectDir, event, { payload = {}, host = process.env.RUVNET_HOOK_HOST || 'claude',
185
189
  readHistory = readTransitionHistory, capture = runSessionSnapshotHook, env = process.env } = {}) {
186
190
  if (developmentHooksSuspended(projectDir)) return { state: 'skipped', reason: 'development hooks suspended' };
191
+ const suspended = automaticProgressionSuspensionResult(env, { state: 'suspended', reason: 'automatic project progression is operator-suspended' });
192
+ if (suspended) return suspended;
187
193
  const observation = normalizeTransition(payload, event, { host });
188
194
  if (observation.skipped) return { state: 'skipped', reason: observation.skipped };
189
195
  const brainHome = env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
@@ -194,22 +200,33 @@ export function runProjectTransitionHook(projectDir, event, { payload = {}, host
194
200
  const transportEvent = event === 'PostToolUseFailure' ? 'PostToolUse' : event;
195
201
  // Fsync the selected observation BEFORE any history enumeration/merge. A deadline, corruption,
196
202
  // or long-lived project must leave it pending rather than erase it or fabricate root ancestry.
197
- const queued = queueCapture({ projectDir: resolution.projectRoot, originProjectDir: projectDir, event: transportEvent, host,
203
+ const queued = queueCapture({ projectDir: resolution.projectRoot, originProjectDir: projectDir, env, event: transportEvent, host,
198
204
  payload: { session_id: payload.session_id, hook_event_name: transportEvent,
199
205
  normalizedTransition: { observation, sourceIdentity: observeTransitionSource(resolution, projectDir) } } });
200
206
  if (!queued) return { state: 'degraded', reason: 'normalized observation queue unwritable', eventId: observation.id };
201
207
  let result = null;
202
- runOutboxReplay({ projectDir: resolution.projectRoot, budgetMs: Math.min(6500, effectiveBudgetMs(env)),
203
- captureNormalized: (job, options) => captureNormalizedTransition(job, { ...options, readHistory, capture }),
208
+ runOutboxReplay({ projectDir: resolution.projectRoot, env, budgetMs: Math.min(6500, effectiveBudgetMs(env)),
209
+ captureNormalized: (job, options) => captureNormalizedTransition(job, { ...options, env, readHistory, capture }),
204
210
  onCaptured: (captured) => { if (captured?.eventId === observation.id) result = captured; } });
205
- if (!result?.receipt) replayOutboxDetached({ projectDir: resolution.projectRoot });
211
+ if (!result?.receipt) replayOutboxDetached({ projectDir: resolution.projectRoot, env });
206
212
  return { state: result?.progressionCaptured && result.receipt ? 'committed' : 'pending', eventId: observation.id, result };
207
213
  }
208
214
 
215
+ /** One pending-readback condition across prompt/tool boundaries and both CLI entrypoints. */
216
+ export function transitionPendingNotice(projectDir, payload, message) {
217
+ const { projectRoot } = resolveProjectStore({ projectDir, gitTimeoutMs: 500 });
218
+ return conditionNotice({ swarm: path.join(projectRoot, '.swarm'),
219
+ session: normalizeHostEvent(payload)?.session_id, condition: 'project-transition-pending-readback', message });
220
+ }
221
+
209
222
  if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
210
223
  try {
211
224
  const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
212
- const result = runProjectTransitionHook(payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd(), process.argv[2], { payload });
213
- if (result.state === 'pending') process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: process.argv[2], additionalContext: 'Project memory transition is pending; exact AgentDB readback was not verified at this boundary.' } }));
225
+ const projectDir = payload.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd();
226
+ const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
227
+ if (result.state === 'pending') {
228
+ const message = transitionPendingNotice(projectDir, payload, 'Project memory transition is pending; exact AgentDB readback was not verified at this boundary.');
229
+ if (message) process.stdout.write(JSON.stringify({ hookSpecificOutput: { hookEventName: process.argv[2], additionalContext: message } }));
230
+ }
214
231
  } catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
215
232
  }
@@ -17,6 +17,7 @@ import { ProjectProgressionStore } from './project-progression-store.mjs';
17
17
  import { resolveProjectStore } from './project-store-resolver.mjs';
18
18
  import { resolveTurnDb, captureTurnOutcome } from './turn-outcome-capture.mjs';
19
19
  import { captureContinuityEvents, stopNotice } from './continuity-journal.mjs';
20
+ import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
20
21
 
21
22
  /**
22
23
  * The capture boundary's whole budget. hooks.json declares 10s; this keeps the internal work well
@@ -158,6 +159,8 @@ export function runSessionSnapshotHook(projectDir, event, {
158
159
  continuity = { recorded: 0, skipped: `continuity capture failed: ${error.message}` };
159
160
  }
160
161
  const idle = { metadataWritten, progressionCaptured: false, receipt: null, turn, continuity };
162
+ const operatorSuspended = automaticProgressionSuspensionResult(env, idle);
163
+ if (operatorSuspended) return operatorSuspended;
161
164
 
162
165
  if (hasProjectProgression(payload)) {
163
166
  if (payload.hook_event_name !== event) {
@@ -206,7 +209,7 @@ export function runSessionSnapshotHook(projectDir, event, {
206
209
  try { frozen = produce({ resolution, projectDir, payload, host, trigger: event }); } catch { frozen = null; }
207
210
  const queued = frozen?.projectProgression ? queueCapture({ projectDir: root, originProjectDir: projectDir, env, event, host,
208
211
  payload: { session_id: payload.session_id, hook_event_name: event, projectProgression: frozen.projectProgression } }) : null;
209
- const handed = queued ? spawnReplay({ projectDir: root, token }) : false;
212
+ const handed = queued ? spawnReplay({ projectDir: root, token, env }) : false;
210
213
  if (!handed && token && token !== ordered) releaseReplayLock(root, token);
211
214
  return { ...idle, replayed: 0, progressionCaptured: false, deferredToReplayer: Boolean(queued),
212
215
  replaySkipped: `${why}; this capture ${queued ? 'queued behind it' : 'NOT queued (queue unwritable)'}`
@@ -262,7 +265,7 @@ export function runSessionSnapshotHook(projectDir, event, {
262
265
  // NOT LOST — DEFERRED. capture() fsyncs the snapshot to the durable outbox BEFORE it writes to
263
266
  // the store, so a budget overrun leaves the evidence on disk. On a short budget nothing later in
264
267
  // this process can settle it, so the lock goes straight to a detached worker.
265
- handedLock = !ordered && budgetMs < REPLAY_MIN_BUDGET_MS && pendingCount() > 0 && spawnReplay({ projectDir: root, token });
268
+ handedLock = !ordered && budgetMs < REPLAY_MIN_BUDGET_MS && pendingCount() > 0 && spawnReplay({ projectDir: root, token, env });
266
269
  return { ...idle, replayed, skipped: `capture deferred: ${error.message}`,
267
270
  ...(handedLock ? { replaySkipped: 'deferred capture handed to a detached worker' } : {}) };
268
271
  }
@@ -281,9 +284,9 @@ export function runSessionSnapshotHook(projectDir, event, {
281
284
  // simultaneous SessionEnds lost the second one's final state (4.4.1). So: queued work → hand THIS
282
285
  // lock to a worker; and re-check after releasing, for a boundary that queued in between.
283
286
  if (!ordered && !handedLock) {
284
- if (!(queuedWork(root) && spawnReplay({ projectDir: root, token }))) {
287
+ if (!(queuedWork(root) && spawnReplay({ projectDir: root, token, env }))) {
285
288
  releaseReplayLock(root, token);
286
- if (queuedWork(root)) spawnReplay({ projectDir: root });
289
+ if (queuedWork(root)) spawnReplay({ projectDir: root, env });
287
290
  }
288
291
  }
289
292
  }
@@ -297,10 +300,14 @@ if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-
297
300
  // Finish evaluating this module before importing its transition consumer.
298
301
  void (async () => {
299
302
  try {
300
- const { runProjectTransitionHook } = await import('./project-transition-hook.mjs');
303
+ const { runProjectTransitionHook, transitionPendingNotice } = await import('./project-transition-hook.mjs');
301
304
  const payload = JSON.parse(fs.readFileSync(0, 'utf8') || '{}');
302
- const result = runProjectTransitionHook(payload.cwd || projectDirectory(), process.argv[2], { payload });
303
- if (result.state === 'pending') process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition remains pending; exact readback was not verified.' }));
305
+ const projectDir = payload.cwd || projectDirectory();
306
+ const result = runProjectTransitionHook(projectDir, process.argv[2], { payload });
307
+ if (result.state === 'pending') {
308
+ const message = transitionPendingNotice(projectDir, payload, 'Project memory transition remains pending; exact readback was not verified.');
309
+ if (message) process.stdout.write(JSON.stringify({ systemMessage: message }));
310
+ }
304
311
  } catch { process.stdout.write(JSON.stringify({ systemMessage: 'Project memory transition capture degraded; exact readback was not verified.' })); }
305
312
  })();
306
313
  } else if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) {
@@ -110,10 +110,12 @@ export function claudeTurn(lines) {
110
110
  }
111
111
 
112
112
  function readTail(file, bytes = TRANSCRIPT_TAIL_BYTES) {
113
- const size = fs.statSync(file).size;
114
- const offset = Math.max(0, size - bytes);
115
113
  const handle = fs.openSync(file, 'r');
116
114
  try {
115
+ const stat = fs.fstatSync(handle);
116
+ if (!stat.isFile()) throw new Error('Transcript must be a regular file');
117
+ const size = stat.size;
118
+ const offset = Math.max(0, size - bytes);
117
119
  const buf = Buffer.alloc(size - offset);
118
120
  fs.readSync(handle, buf, 0, buf.length, offset);
119
121
  const lines = buf.toString('utf8').split(/\r?\n/);
@@ -128,7 +130,9 @@ export function readSettledTranscript(file, { stableMs = 400, maxMs = 2000, slee
128
130
  const deadline = Date.now() + Math.max(0, maxMs);
129
131
  let size = -1;
130
132
  for (;;) {
131
- const now = fs.statSync(file).size;
133
+ const stat = fs.statSync(file);
134
+ if (!stat.isFile()) throw new Error('Transcript must be a regular file');
135
+ const now = stat.size;
132
136
  if (now === size || Date.now() >= deadline) break;
133
137
  size = now;
134
138
  pause(Math.min(stableMs, Math.max(0, deadline - Date.now())));
@@ -44,7 +44,9 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
44
44
  "files": [
45
45
  "tests/unit/automatic-hook-retirement.test.mjs",
46
46
  "tests/unit/install-activation-rollback.test.mjs",
47
- "tests/unit/forge-update-apply-rollback.test.mjs"
47
+ "tests/unit/forge-update-apply-rollback.test.mjs",
48
+ "tests/unit/forge-update-archive-digest.test.mjs",
49
+ "tests/unit/kb-copy-proof-legacy-sidecars.test.mjs"
48
50
  ]
49
51
  },
50
52
  {
@@ -93,7 +95,8 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
93
95
  "files": [
94
96
  "tests/unit/nightly-scheduler.test.mjs",
95
97
  "tests/unit/nightly-refresh-launcher.test.mjs",
96
- "tests/unit/nightly-two-run-proof.test.mjs"
98
+ "tests/unit/nightly-two-run-proof.test.mjs",
99
+ "tests/unit/nightly-refresh-run-health.test.mjs"
97
100
  ]
98
101
  },
99
102
  {
@@ -151,13 +154,31 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
151
154
  "tests/mutation/citation-binding-mutation.test.mjs"
152
155
  ]
153
156
  },
157
+ {
158
+ "id": "owner-capture-and-off-controls",
159
+ "reason": "Brain OFF suppresses capacity execution; promise opt-out suppresses only new capture and preserves existing closure and capability truth",
160
+ "files": [
161
+ "tests/unit/capacity-aware-parallel-work.test.mjs",
162
+ "tests/unit/continuation-gate-capability-truth.test.mjs",
163
+ "tests/unit/continuation-gate-completion-claims.test.mjs"
164
+ ]
165
+ },
166
+ {
167
+ "id": "active-managed-generation",
168
+ "reason": "Active code selection binds help authorization and execution to one immutable leased generation across promotion",
169
+ "files": [
170
+ "tests/unit/managed-cli-generation.test.mjs"
171
+ ]
172
+ },
154
173
  {
155
174
  "id": "pending-memory-durability",
156
175
  "reason": "Complete outbox tails replay and accepted pending journal events survive capacity pressure without silent deletion",
157
176
  "files": [
158
177
  "tests/unit/project-progression-outbox.test.mjs",
159
178
  "tests/unit/project-progression-durability.test.mjs",
160
- "tests/unit/continuity-customer-regressions.test.mjs"
179
+ "tests/unit/continuity-customer-regressions.test.mjs",
180
+ "tests/unit/progression-outbox-containment.test.mjs",
181
+ "tests/unit/transition-pending-notices.test.mjs"
161
182
  ]
162
183
  }
163
184
  ],
@@ -166,7 +187,8 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
166
187
  "id": "native-explicit-interface",
167
188
  "reason": "Real MCP subprocess readiness, command policy and literal argv safety",
168
189
  "files": [
169
- "tests/integration/managed-cli-mcp.test.mjs"
190
+ "tests/integration/managed-cli-mcp.test.mjs",
191
+ "tests/integration/managed-cli-server-boundary.test.mjs"
170
192
  ]
171
193
  },
172
194
  {
@@ -219,7 +241,8 @@ export const RELEASE_REQUIREMENTS = Object.freeze({
219
241
  "tests/unit/turn-durable-transport.test.mjs",
220
242
  "tests/integration/automatic-progression-continuation.test.mjs",
221
243
  "tests/integration/capture-consent-boundary.test.mjs",
222
- "tests/integration/session-start-turn-replay.test.mjs"
244
+ "tests/integration/session-start-turn-replay.test.mjs",
245
+ "tests/integration/progression-suspension.test.mjs"
223
246
  ]
224
247
  },
225
248
  {