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 +1 -1
- package/kb/forge-update.mjs +50 -9
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/configure.md +13 -1
- package/plugin/hooks/hook-contracts.json +1 -1
- package/plugin/mcp/managed-cli-generation.mjs +91 -0
- package/plugin/mcp/managed-cli-interface.mjs +36 -12
- package/plugin/mcp/server.mjs +3 -2
- package/plugin/scripts/continuation-gate.mjs +3 -1
- package/plugin/scripts/continuity-journal.mjs +11 -11
- package/plugin/scripts/hook-shim.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +4 -3
- package/plugin/scripts/nightly-scheduler.mjs +6 -2
- package/plugin/scripts/project-capture-queue.mjs +16 -7
- package/plugin/scripts/project-progression-outbox.mjs +22 -6
- package/plugin/scripts/project-progression-session-start.mjs +10 -0
- package/plugin/scripts/project-progression-suspension.mjs +60 -0
- package/plugin/scripts/project-transition-hook.mjs +25 -8
- package/plugin/scripts/session-snapshot-hook.mjs +14 -7
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/scripts/release-qualification-contract.mjs +28 -5
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 — [](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
|
|
package/kb/forge-update.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
1991
|
+
bundleSha256,
|
|
1951
1992
|
coverageSha256: sha256File(path.join(KB_DIR, 'COVERAGE.json')),
|
|
1952
1993
|
phaseEvidence: phaseEvidenceFor({ root: KB_DIR, terminalVerdict: transaction.terminalVerdict,
|
|
1953
|
-
bundleSha256
|
|
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.
|
|
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.
|
|
4
|
+
"version": "4.5.7",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Stuart Kerr"
|
|
7
7
|
},
|
|
@@ -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-
|
|
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
|
|
@@ -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.
|
|
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,
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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 {
|
package/plugin/mcp/server.mjs
CHANGED
|
@@ -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 {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
308
|
-
|
|
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(
|
|
318
|
-
state[id] = { at: new Date(
|
|
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
|
|
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
|
-
|
|
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
|
|
50
|
-
// <store
|
|
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)?$/, '')
|
|
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 =
|
|
105
|
-
|
|
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: { ...
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
197
|
+
const target = snapshots.get(record.recoveryEventKey);
|
|
190
198
|
return !quarantined.has(record.recoveryEventKey)
|
|
191
199
|
&& committed.get(record.recoveryEventKey) === record.recoveryPayloadDigest
|
|
192
|
-
&& target?.
|
|
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
|
-
|
|
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
|
-
.
|
|
201
|
-
|
|
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
|
|
213
|
-
|
|
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
|
|
303
|
-
|
|
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
|
|
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
|
{
|