ruvnet-brain 4.5.16 → 4.6.0
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 +15 -19
- package/bin/developer-update.mjs +3 -0
- package/bin/install.mjs +15 -3
- package/bin/nightly-refresh.mjs +17 -1
- package/config/practical-rule-catalog.json +111 -0
- package/console/CONTRACT.md +47 -3
- package/console/app.js +112 -20
- package/package.json +5 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +13 -1
- package/plugin/commands/configure.md +2 -2
- package/plugin/commands/rnb.md +13 -1
- package/plugin/commands/rnbc.md +13 -1
- package/plugin/commands/rvbc.md +13 -1
- package/plugin/commands/rvcb.md +13 -1
- package/plugin/hooks/codex-hooks.json +13 -1
- package/plugin/hooks/hook-contracts.json +20 -4
- package/plugin/scripts/adr-currency-gate.mjs +75 -31
- package/plugin/scripts/advocacy-outcomes.mjs +18 -4
- package/plugin/scripts/agentdb-recall.mjs +161 -26
- package/plugin/scripts/automatic-update.mjs +11 -2
- package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
- package/plugin/scripts/capability-registry.mjs +55 -114
- package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
- package/plugin/scripts/codex-hook-adapter.mjs +33 -6
- package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
- package/plugin/scripts/continuation-gate.mjs +2 -1
- package/plugin/scripts/continuity-brief.mjs +55 -22
- package/plugin/scripts/continuity-events.mjs +93 -17
- package/plugin/scripts/continuity-hook-policy.mjs +1 -1
- package/plugin/scripts/continuity-journal.mjs +77 -29
- package/plugin/scripts/decision-gate.mjs +62 -37
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/developer-update-cleanup.mjs +38 -0
- package/plugin/scripts/developer-update-lock.mjs +30 -0
- package/plugin/scripts/developer-update-maintenance.mjs +178 -0
- package/plugin/scripts/developer-update-owner.mjs +24 -0
- package/plugin/scripts/developer-update-policy.mjs +48 -0
- package/plugin/scripts/developer-update.mjs +435 -0
- package/plugin/scripts/doc-currency.mjs +993 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -3
- package/plugin/scripts/grounding-answer.mjs +18 -16
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
- package/plugin/scripts/grounding-turn-gate.mjs +34 -29
- package/plugin/scripts/grounding-turn-mark.mjs +101 -17
- package/plugin/scripts/hook-context-budget.mjs +90 -0
- package/plugin/scripts/hook-registry.mjs +129 -1
- package/plugin/scripts/hook-shim.mjs +20 -2
- package/plugin/scripts/host-update.mjs +6 -3
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/nightly-scheduler.mjs +55 -16
- package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
- package/plugin/scripts/project-capture-queue.mjs +22 -15
- package/plugin/scripts/project-progression-producer.mjs +15 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -4
- package/plugin/scripts/project-progression-session-start.mjs +29 -17
- package/plugin/scripts/project-progression-sources.mjs +24 -10
- package/plugin/scripts/project-progression-store.mjs +7 -2
- package/plugin/scripts/project-store-resolver.mjs +10 -7
- package/plugin/scripts/project-transition-hook.mjs +20 -12
- package/plugin/scripts/session-snapshot-hook.mjs +20 -9
- package/plugin/scripts/session-start-budget.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +32 -6
- package/plugin/scripts/session-start-trace.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -5
- package/plugin/scripts/unprompted-runtime.mjs +36 -10
- package/plugin/scripts/user-settings.mjs +7 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/codex-managed-terminal.mjs +1 -1
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/console-suite-update.mjs +117 -0
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +30 -1
- package/scripts/model-managed-prompt.mjs +1 -1
- package/scripts/model-managed-workflow-service.mjs +113 -14
- package/scripts/model-routing-controller.mjs +112 -21
- package/scripts/onboarding-console.mjs +41 -12
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/release-qualification-contract.mjs +14 -0
- package/scripts/remedy-registry.mjs +1 -0
- package/scripts/source-scope-receipt.mjs +9 -4
- package/scripts/stack-sync.mjs +19 -132
|
@@ -5,10 +5,12 @@ import path from 'node:path';
|
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
7
7
|
import { automaticPath } from './automatic-update.mjs';
|
|
8
|
+
import { sharedLockStatus } from './developer-update-lock.mjs';
|
|
9
|
+
import { EXECUTION_MODULES } from './developer-update-policy.mjs';
|
|
8
10
|
|
|
9
11
|
export const NIGHTLY_LABEL = 'com.ruvnet.brain-update';
|
|
10
12
|
export const NIGHTLY_HOUR = 3;
|
|
11
|
-
export const NIGHTLY_MINUTE =
|
|
13
|
+
export const NIGHTLY_MINUTE = 30;
|
|
12
14
|
export const NIGHTLY_PROOF_LABEL = /^com\.ruvnet\.brain-update\.proof-[A-Za-z0-9._-]+$/;
|
|
13
15
|
export const NIGHTLY_ENV_ALLOWLIST = Object.freeze([
|
|
14
16
|
'PATH', 'HOME', 'USERPROFILE', 'RUVNET_BRAIN_HOME', 'RUVNET_BRAIN_KB', 'npm_config_cache', 'NO_COLOR', 'SystemRoot', 'SYSTEMROOT', 'ComSpec', 'COMSPEC', 'PATHEXT', 'TEMP', 'TMP',
|
|
@@ -140,8 +142,13 @@ function inspectRefreshOwner(owner) {
|
|
|
140
142
|
} catch { return 'unknown'; }
|
|
141
143
|
}
|
|
142
144
|
|
|
143
|
-
|
|
144
|
-
|
|
145
|
+
const HISTORICAL_UPDATE_MODULES = ['automatic-update.mjs', 'user-settings.mjs', 'ruvnet-gate1-pattern.mjs'];
|
|
146
|
+
const BRIDGED_UPDATE_MODULES = [...HISTORICAL_UPDATE_MODULES, 'developer-update-owner.mjs'];
|
|
147
|
+
const LEGACY_UPDATE_MODULES = [...BRIDGED_UPDATE_MODULES, 'developer-update-policy.mjs'];
|
|
148
|
+
const PREVIOUS_DEVELOPER_MODULES = [...BRIDGED_UPDATE_MODULES, ...EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs')];
|
|
149
|
+
const DEVELOPER_UPDATE_MODULES = [...new Set([...LEGACY_UPDATE_MODULES, ...EXECUTION_MODULES])];
|
|
150
|
+
function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url)), developerSuite = true) {
|
|
151
|
+
const files = developerSuite ? DEVELOPER_UPDATE_MODULES : LEGACY_UPDATE_MODULES;
|
|
145
152
|
const bytes = Object.fromEntries(files.map(file => [file, fs.readFileSync(path.join(sourceDir, file))]));
|
|
146
153
|
const digest = sha256(Buffer.concat(files.flatMap(file => [Buffer.from(file), bytes[file]])));
|
|
147
154
|
const target = path.join(directory, `update-modules-${digest}`);
|
|
@@ -157,7 +164,8 @@ function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.
|
|
|
157
164
|
|
|
158
165
|
function verifyUpdateModules(modules) {
|
|
159
166
|
if (!modules) return; // existing schema-2 registrations remain inspectable
|
|
160
|
-
if (
|
|
167
|
+
if (![HISTORICAL_UPDATE_MODULES, BRIDGED_UPDATE_MODULES, LEGACY_UPDATE_MODULES, PREVIOUS_DEVELOPER_MODULES, DEVELOPER_UPDATE_MODULES].some(names => [...names].sort().join(',') === Object.keys(modules).sort().join(','))) throw new Error('invalid update module closure');
|
|
168
|
+
if (modules['developer-update.mjs'] && !modules['plugin-artifact-proof.mjs'] && fs.readFileSync(modules['developer-update.mjs'].path, 'utf8').includes('./plugin-artifact-proof.mjs')) throw new Error('new coordinator artifact closure is incomplete');
|
|
161
169
|
for (const [name, item] of Object.entries(modules)) {
|
|
162
170
|
if (!path.isAbsolute(item.path) || path.basename(item.path) !== name
|
|
163
171
|
|| path.dirname(item.path) !== path.dirname(modules['automatic-update.mjs'].path)
|
|
@@ -167,7 +175,7 @@ function verifyUpdateModules(modules) {
|
|
|
167
175
|
}
|
|
168
176
|
|
|
169
177
|
export function installNightlyRunner({ brainHome, source, nodePath = process.execPath,
|
|
170
|
-
identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, moduleSource } = {}) {
|
|
178
|
+
identity = NIGHTLY_LABEL, packageTarget, bundleTarget, env = {}, moduleSource, developerSuite = !NIGHTLY_PROOF_LABEL.test(identity) } = {}) {
|
|
171
179
|
validateIdentity(identity);
|
|
172
180
|
if (!source || !fs.existsSync(source)) throw new Error(`nightly runner source is missing: ${source || '(unset)'}`);
|
|
173
181
|
const bytes = fs.readFileSync(source);
|
|
@@ -179,8 +187,8 @@ export function installNightlyRunner({ brainHome, source, nodePath = process.exe
|
|
|
179
187
|
throw new Error(`nightly runner at ${runnerPath} does not match its content-addressed identity`);
|
|
180
188
|
}
|
|
181
189
|
if (!fs.existsSync(runnerPath)) fs.writeFileSync(runnerPath, bytes, { mode: 0o755 });
|
|
182
|
-
const modules = updateModules(dir, moduleSource);
|
|
183
|
-
const record = { updateModules: modules, schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
|
|
190
|
+
const modules = updateModules(dir, moduleSource, developerSuite);
|
|
191
|
+
const record = { updateModules: modules, ...(developerSuite ? { mode: 'developer-suite' } : {}), schemaVersion: 2, kind: 'ruvnet-brain-nightly-scheduler', identity,
|
|
184
192
|
nodePath: path.resolve(nodePath), runnerPath, runnerSha256: digest, argv: [],
|
|
185
193
|
environment: Object.fromEntries(NIGHTLY_ENV_ALLOWLIST.filter(key => env[key] !== undefined).map(key => [key, key === 'PATH' ? automaticPath({ nodePath, home: homeOf(env), env }) : String(env[key])])),
|
|
186
194
|
packageTarget: normalizePackageTarget(packageTarget), bundleTarget: normalizeBundleTarget(bundleTarget, identity) };
|
|
@@ -200,6 +208,7 @@ export function readNightlyRegistration({ brainHome, identity = NIGHTLY_LABEL,
|
|
|
200
208
|
|| !path.isAbsolute(record.nodePath) || !path.isAbsolute(record.runnerPath)
|
|
201
209
|
|| !Array.isArray(record.argv) || record.argv.length !== 0) throw new Error('invalid registration schema');
|
|
202
210
|
if (record.environment !== undefined && (!record.environment || Array.isArray(record.environment) || Object.entries(record.environment).some(([key, value]) => !NIGHTLY_ENV_ALLOWLIST.includes(key) || typeof value !== 'string'))) throw new Error('invalid registered environment');
|
|
211
|
+
if (![undefined, 'developer-suite'].includes(record.mode) || (record.mode === 'developer-suite') !== Boolean(record.updateModules?.['developer-update.mjs'])) throw new Error('nightly coordinator mode/module closure mismatch');
|
|
203
212
|
verifyUpdateModules(record.updateModules);
|
|
204
213
|
const actual = sha256(fs.readFileSync(record.runnerPath));
|
|
205
214
|
if (actual !== record.runnerSha256) throw new Error('runner digest mismatch');
|
|
@@ -242,8 +251,35 @@ export function resolveNightlyProofBundle({ brainHome, env = process.env } = {})
|
|
|
242
251
|
return execution.record.bundleTarget;
|
|
243
252
|
}
|
|
244
253
|
|
|
254
|
+
export function developerRunHealth({ brainHome, registration, now = Date.now(), maxAgeHours = 30 }) {
|
|
255
|
+
const file = path.join(brainHome, 'nightly-suite-update.json');
|
|
256
|
+
if (!fs.existsSync(file)) return { state: 'never-ran', evidence: 'No coordinated update receipt exists yet.', receipt: null };
|
|
257
|
+
let receipt;
|
|
258
|
+
try { receipt = JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
259
|
+
catch (error) { return { state: 'failed', evidence: `Coordinated update receipt unreadable: ${error.message}`, receipt: null }; }
|
|
260
|
+
const validDigest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
|
|
261
|
+
const bound = validDigest(receipt.sourceSha256) && receipt.kind === 'nightly-suite-update' && receipt.schemaVersion === 1
|
|
262
|
+
&& receipt.sourceSha256 === registration.updateModules?.['developer-update.mjs']?.sha256
|
|
263
|
+
&& EXECUTION_MODULES.filter(name => name !== 'plugin-artifact-proof.mjs' || registration.updateModules?.[name]).every(name => validDigest(receipt.sourceSnapshot?.[name]) && receipt.sourceSnapshot[name] === registration.updateModules?.[name]?.sha256);
|
|
264
|
+
const ageHours = (now - Date.parse(receipt.finishedAt || receipt.startedAt || '')) / 3_600_000;
|
|
265
|
+
if (!bound || !Number.isFinite(ageHours)) return { state: 'failed', evidence: 'Coordinated update receipt is not bound to registered source.', receipt };
|
|
266
|
+
if (receipt.state === 'running') {
|
|
267
|
+
const lock = sharedLockStatus({ brainHome });
|
|
268
|
+
const live = lock.state === 'running' && lock.owner.pid === (receipt.ownerPid || receipt.pid) && lock.owner.token === receipt.ownerToken;
|
|
269
|
+
return { state: live ? 'running' : 'failed', ageHours, receipt, evidence: live ? 'Coordinated updates have a live exact owner.' : 'Coordinated update owner is absent or unverified.' };
|
|
270
|
+
}
|
|
271
|
+
if (receipt.state === 'failed' || receipt.ok !== true) return { state: 'failed', ageHours, receipt, evidence: receipt.error || 'Coordinated updates failed.' };
|
|
272
|
+
if (receipt.mode !== 'apply' || receipt.schedulerIdentity !== registration.identity) return { state: 'never-ran', ageHours, receipt, evidence: 'Latest receipt is manual or a currency check; nightly execution remains unproven.' };
|
|
273
|
+
if (receipt.state !== 'completed') return { state: 'failed', ageHours, receipt, evidence: 'Coordinated update terminal state is unverified.' };
|
|
274
|
+
if (ageHours > maxAgeHours) return { state: 'stale', ageHours, receipt, evidence: `Last coordinated update is ${ageHours.toFixed(1)}h old.` };
|
|
275
|
+
const verification = { ok: bound && receipt.mode === 'apply' && receipt.schedulerIdentity === registration.identity && receipt.state === 'completed' && receipt.ok === true && ageHours >= 0 && ageHours <= maxAgeHours };
|
|
276
|
+
return { state: verification.ok ? 'ok' : 'failed', verification, ageHours, receipt, evidence: verification.ok ? `Coordinated updates completed ${ageHours.toFixed(1)}h ago; ${receipt.config?.scope || 'unknown'} scope.` : 'Coordinated update timestamp or terminal evidence is invalid.' };
|
|
277
|
+
}
|
|
278
|
+
|
|
245
279
|
export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Date.now(), maxAgeHours = 30,
|
|
246
280
|
inspectOwner = inspectRefreshOwner } = {}) {
|
|
281
|
+
const registration = readNightlyRegistration({ brainHome, identity });
|
|
282
|
+
if (registration.ok && registration.record.mode === 'developer-suite') return developerRunHealth({ brainHome, registration: registration.record, now, maxAgeHours });
|
|
247
283
|
const dir = path.join(brainHome, 'refresh-runs');
|
|
248
284
|
let files = [];
|
|
249
285
|
try {
|
|
@@ -270,13 +306,13 @@ export function refreshRunHealth({ brainHome, identity = NIGHTLY_LABEL, now = Da
|
|
|
270
306
|
const stamp = Date.parse(receipt.finishedAt || receipt.startedAt || '');
|
|
271
307
|
if (!Number.isFinite(stamp)) return { state: 'failed', evidence: 'Latest nightly receipt has no valid timestamp.', receipt };
|
|
272
308
|
const ageHours = (now - stamp) / 3_600_000;
|
|
273
|
-
const
|
|
274
|
-
if (!
|
|
275
|
-
evidence: `Nightly registration is invalid: ${
|
|
276
|
-
const expectedIdentity = { schedulerIdentity:
|
|
277
|
-
registrationPath:
|
|
278
|
-
runnerPath:
|
|
279
|
-
if (receipt.schedulerIdentity !==
|
|
309
|
+
const legacyRegistration = readNightlyRegistration({ brainHome, identity });
|
|
310
|
+
if (!legacyRegistration.ok) return { state: 'failed', ageHours,
|
|
311
|
+
evidence: `Nightly registration is invalid: ${legacyRegistration.why}`, receipt };
|
|
312
|
+
const expectedIdentity = { schedulerIdentity: legacyRegistration.record.identity,
|
|
313
|
+
registrationPath: legacyRegistration.record.recordPath, nodePath: legacyRegistration.record.nodePath,
|
|
314
|
+
runnerPath: legacyRegistration.record.runnerPath, runnerSha256: legacyRegistration.record.runnerSha256, argv: [] };
|
|
315
|
+
if (receipt.schedulerIdentity !== legacyRegistration.record.identity
|
|
280
316
|
|| JSON.stringify(receipt.executableIdentity) !== JSON.stringify(expectedIdentity)) {
|
|
281
317
|
return { state: 'failed', ageHours, evidence: 'Nightly receipt is not bound to the registered runner bytes.', receipt };
|
|
282
318
|
}
|
|
@@ -334,9 +370,12 @@ export function refreshHistory({ brainHome }) {
|
|
|
334
370
|
failuresSinceSuccess: sinceSuccess.filter(({ receipt }) => receipt.status === 'FAILED').length };
|
|
335
371
|
}
|
|
336
372
|
|
|
337
|
-
/**
|
|
373
|
+
/** Kit retains the legacy update owner unless this machine explicitly assigns it to Brain. */
|
|
338
374
|
export function updateOwnedByAgenticKit(home = os.homedir()) {
|
|
339
|
-
try {
|
|
375
|
+
try {
|
|
376
|
+
const config = JSON.parse(fs.readFileSync(path.join(home, '.config', 'agentic-kit', 'kit.json'), 'utf8'));
|
|
377
|
+
return config.ruvnetBrain === true && config.ruvnetBrainUpdateOwner !== 'brain';
|
|
378
|
+
}
|
|
340
379
|
catch { return false; }
|
|
341
380
|
}
|
|
342
381
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Immutable Git blobs prove the active Claude surface, independently of stale provider metadata.
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
export const gitBlobHash = bytes => crypto.createHash('sha1').update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
|
|
6
|
+
const safePath = name => typeof name === 'string' && name && !name.includes('\\') && !path.posix.isAbsolute(name)
|
|
7
|
+
&& !name.split('/').some(part => part === '..' || part === '' || part === '.') && !name.includes('\0');
|
|
8
|
+
const within = (root, file) => file === root || file.startsWith(root + path.sep);
|
|
9
|
+
export function pinnedGithubRepo(source) {
|
|
10
|
+
if (source?.source === 'github' && /^[A-Za-z0-9][A-Za-z0-9_-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(source.repo || '')) return source.repo;
|
|
11
|
+
if (source?.source === 'url') return /^https:\/\/github\.com\/([A-Za-z0-9][A-Za-z0-9_-]*\/[A-Za-z0-9][A-Za-z0-9_.-]*?)(?:\.git)?$/.exec(source.url || '')?.[1] || null;
|
|
12
|
+
return null;
|
|
13
|
+
}
|
|
14
|
+
export async function pinnedClaudeTarget(source, commit, { request = fetch, cache = new Map() } = {}) {
|
|
15
|
+
const repo = pinnedGithubRepo(source);
|
|
16
|
+
if (!repo || !/^[a-f0-9]{40}$/i.test(commit || '')) throw Error('pinned artifact owner is not a known GitHub repository');
|
|
17
|
+
const subdir = source.path ? String(source.path).replace(/^\.\//, '').replace(/\/$/, '') : '';
|
|
18
|
+
if (subdir && !safePath(subdir)) throw Error('pinned plugin path escapes repository');
|
|
19
|
+
const key = `${repo}:${commit}:${subdir}`;
|
|
20
|
+
if (cache.has(key)) return cache.get(key);
|
|
21
|
+
const get = async url => {
|
|
22
|
+
const response = await request(url, { signal: AbortSignal.timeout(20_000), headers: { 'User-Agent': 'ruvnet-brain/claude-artifact-proof' } });
|
|
23
|
+
if (!response.ok) throw Error(`immutable GitHub artifact HTTP ${response.status}`);
|
|
24
|
+
return response;
|
|
25
|
+
};
|
|
26
|
+
const tree = await (await get(`https://api.github.com/repos/${repo}/git/trees/${commit}?recursive=1`)).json();
|
|
27
|
+
if (!Array.isArray(tree.tree) || tree.truncated !== false || !tree.tree.length || tree.tree.length > 5000) throw Error('immutable repository tree is incomplete or unbounded');
|
|
28
|
+
const prefix = subdir ? subdir + '/' : '';
|
|
29
|
+
const entries = tree.tree.filter(entry => entry.path?.startsWith(prefix)).map(entry => ({ ...entry, path: entry.path.slice(prefix.length) })).filter(entry => entry.path);
|
|
30
|
+
if (entries.some(entry => !safePath(entry.path) || !/^[a-f0-9]{40}$/i.test(entry.sha || ''))) throw Error('immutable repository path or hash malformed');
|
|
31
|
+
const manifestPath = '.claude-plugin/plugin.json', manifestEntry = entries.find(entry => entry.path === manifestPath && entry.type === 'blob');
|
|
32
|
+
if (!manifestEntry) throw Error('immutable Claude manifest absent');
|
|
33
|
+
const resource = prefix + manifestPath;
|
|
34
|
+
const response = await get(`https://raw.githubusercontent.com/${repo}/${commit}/${resource.split('/').map(encodeURIComponent).join('/')}`);
|
|
35
|
+
const manifestBytes = Buffer.from(await response.arrayBuffer());
|
|
36
|
+
if (gitBlobHash(manifestBytes) !== manifestEntry.sha) throw Error('immutable Claude manifest does not match pinned Git blob');
|
|
37
|
+
const result = { repo, commit, manifest: JSON.parse(manifestBytes.toString('utf8')), entries, manifestBlob: manifestEntry.sha };
|
|
38
|
+
cache.set(key, result);
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function verifyClaudeArtifact(target, installPath) {
|
|
43
|
+
const proof = { ok: false, scope: 'active-claude-artifact', repo: target.repo, pinnedCommit: target.commit,
|
|
44
|
+
immutableManifestBlob: target.manifestBlob, declaredVersion: target.manifest.version || null,
|
|
45
|
+
compared: [], outsideClaudeProof: [], mismatches: [], actualClaudeSourceMatched: false, entireRepositoryMatched: false,
|
|
46
|
+
limits: ['Active resource directories and default Claude files reject extras; other cached files and dynamically computed runtime references are unverified.'] };
|
|
47
|
+
try {
|
|
48
|
+
const root = fs.realpathSync(installPath);
|
|
49
|
+
const actualManifest = path.join(root, '.claude-plugin/plugin.json');
|
|
50
|
+
if (!within(root, fs.realpathSync(actualManifest)) || fs.lstatSync(actualManifest).isSymbolicLink()) throw Error('unsafe cached Claude manifest');
|
|
51
|
+
proof.actualVersion = JSON.parse(fs.readFileSync(actualManifest, 'utf8')).version || null;
|
|
52
|
+
const entries = new Map(target.entries.map(entry => [entry.path, entry]));
|
|
53
|
+
const selected = new Set(), activeDirectories = new Set(['.claude-plugin', 'commands', 'agents', 'skills', 'hooks']);
|
|
54
|
+
const add = (name, required = true) => {
|
|
55
|
+
name = name.replace(/^\.\//, '').replace(/\/$/, '');
|
|
56
|
+
if (!safePath(name)) throw Error('declared Claude resource path is unsafe');
|
|
57
|
+
const found = target.entries.filter(entry => entry.path === name || entry.path.startsWith(name + '/'));
|
|
58
|
+
if (required && !found.length) throw Error(`declared Claude resource absent from pinned tree: ${name}`);
|
|
59
|
+
if (found.some(entry => entry.type === 'tree' && entry.path === name || entry.path.startsWith(name + '/'))) activeDirectories.add(name);
|
|
60
|
+
for (const entry of found) if (entry.type !== 'tree') selected.add(entry.path);
|
|
61
|
+
};
|
|
62
|
+
add('.claude-plugin');
|
|
63
|
+
for (const directory of ['commands', 'agents', 'skills', 'hooks']) add(directory, false);
|
|
64
|
+
// Root instructions/configuration can affect Claude even without a resource-directory entry.
|
|
65
|
+
for (const entry of target.entries) if (!entry.path.includes('/') && entry.type === 'blob') selected.add(entry.path);
|
|
66
|
+
for (const key of ['commands', 'agents', 'skills', 'hooks', 'mcpServers', 'lspServers', 'settings']) {
|
|
67
|
+
const value = target.manifest[key];
|
|
68
|
+
if (value !== undefined && !['hooks', 'mcpServers', 'lspServers', 'settings'].includes(key) && !(typeof value === 'string' || Array.isArray(value) && value.every(item => typeof item === 'string'))) throw Error('unsupported Claude resource declaration');
|
|
69
|
+
for (const name of (Array.isArray(value) ? value : [value])) if (typeof name === 'string') add(name);
|
|
70
|
+
if (value && typeof value === 'object') {
|
|
71
|
+
const refs = JSON.stringify(value).matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([A-Za-z0-9_./-]+)/g);
|
|
72
|
+
for (const ref of refs) add(ref[1]);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const pending = [...selected], seen = new Set();
|
|
76
|
+
for (let index = 0; index < pending.length; index++) {
|
|
77
|
+
const name = pending[index]; if (seen.has(name)) continue; seen.add(name);
|
|
78
|
+
const entry = entries.get(name);
|
|
79
|
+
if (!entry || entry.type !== 'blob' || !['100644', '100755', '120000'].includes(entry.mode)) throw Error(`unverifiable active Claude entry: ${name}`);
|
|
80
|
+
const file = path.join(root, ...name.split('/'));
|
|
81
|
+
const stat = fs.lstatSync(file); let bytes;
|
|
82
|
+
if (entry.mode === '120000') {
|
|
83
|
+
if (!stat.isSymbolicLink()) throw Error(`declared symlink changed: ${name}`);
|
|
84
|
+
const link = fs.readlinkSync(file), resolved = path.resolve(path.dirname(file), link);
|
|
85
|
+
if (!within(root, resolved) || !within(root, fs.realpathSync(file))) throw Error(`Claude symlink escapes artifact: ${name}`);
|
|
86
|
+
bytes = Buffer.from(link); const dependency = path.relative(root, resolved).split(path.sep).join('/'); add(dependency);
|
|
87
|
+
} else {
|
|
88
|
+
if (!stat.isFile() || stat.isSymbolicLink() || !within(root, fs.realpathSync(file)) || stat.size > 8 * 1024 * 1024) throw Error(`unsafe Claude artifact file: ${name}`);
|
|
89
|
+
bytes = fs.readFileSync(file);
|
|
90
|
+
}
|
|
91
|
+
const actual = gitBlobHash(bytes), matched = actual === entry.sha;
|
|
92
|
+
proof.compared.push({ path: name, expectedBlob: entry.sha, actualBlob: actual, matched });
|
|
93
|
+
if (!matched) proof.mismatches.push(name);
|
|
94
|
+
// Expand only references from bytes already independently matched to their immutable Git blob.
|
|
95
|
+
if (matched && bytes.length < 1024 * 1024 && !bytes.includes(0)) {
|
|
96
|
+
const refs = bytes.toString('utf8').matchAll(/\$\{CLAUDE_PLUGIN_ROOT\}\/([A-Za-z0-9_./-]+)/g);
|
|
97
|
+
for (const ref of refs) add(ref[1]);
|
|
98
|
+
// Literal local imports/config arguments form a bounded, file-relative runtime closure.
|
|
99
|
+
if (/\.(?:[cm]?js|py|sh|json)$/.test(name)) {
|
|
100
|
+
const localRefs = bytes.toString('utf8').matchAll(/["'](\.{1,2}\/[^"'\s]+\.(?:[cm]?js|py|sh|json))["']/g);
|
|
101
|
+
for (const ref of localRefs) {
|
|
102
|
+
const dependency = path.posix.normalize(path.posix.join(path.posix.dirname(name), ref[1]));
|
|
103
|
+
if (!safePath(dependency)) throw Error('local Claude runtime reference escapes artifact');
|
|
104
|
+
// Claude manifest resources resolve from plugin root, already validated above.
|
|
105
|
+
if (name !== '.claude-plugin/plugin.json') add(dependency);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
for (const next of selected) if (!seen.has(next) && !pending.includes(next)) pending.push(next);
|
|
110
|
+
}
|
|
111
|
+
// Cached default/declared resource directories must not contain undeclared active additions.
|
|
112
|
+
let inventoried = 0;
|
|
113
|
+
const reverse = (file, relative) => {
|
|
114
|
+
if (++inventoried > 5000) throw Error('cached Claude resource inventory is unbounded');
|
|
115
|
+
const stat = fs.lstatSync(file);
|
|
116
|
+
if (stat.isDirectory()) {
|
|
117
|
+
if (!within(root, fs.realpathSync(file))) throw Error('cached Claude resource directory escapes artifact');
|
|
118
|
+
for (const child of fs.readdirSync(file)) reverse(path.join(file,child), `${relative}/${child}`);
|
|
119
|
+
} else if (!selected.has(relative)) throw Error(`extra cached active Claude resource: ${relative}`);
|
|
120
|
+
};
|
|
121
|
+
for (const directory of activeDirectories) {
|
|
122
|
+
const file=path.join(root,...directory.split('/'));
|
|
123
|
+
if (fs.existsSync(file)) reverse(file,directory);
|
|
124
|
+
}
|
|
125
|
+
const defaultFiles = ['.mcp.json', '.lsp.json', 'CLAUDE.md', 'settings.json'];
|
|
126
|
+
for (const name of defaultFiles) if (fs.existsSync(path.join(root,name))) reverse(path.join(root,name),name);
|
|
127
|
+
proof.reverseInventory = { ok: true, inventoried, directories: [...activeDirectories].sort(), defaultFiles };
|
|
128
|
+
proof.outsideClaudeProof = target.entries.filter(entry => entry.type !== 'tree' && !seen.has(entry.path)).map(entry => entry.path);
|
|
129
|
+
proof.ok = proof.compared.length > 0 && proof.mismatches.length === 0;
|
|
130
|
+
proof.actualClaudeSourceMatched = proof.ok;
|
|
131
|
+
// Cache contents outside this bounded Claude inventory were not reverse-enumerated.
|
|
132
|
+
proof.entireRepositoryMatched = false;
|
|
133
|
+
} catch (error) { proof.error = error.message; }
|
|
134
|
+
return proof;
|
|
135
|
+
}
|
|
@@ -264,38 +264,42 @@ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn
|
|
|
264
264
|
*/
|
|
265
265
|
export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
|
|
266
266
|
makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null,
|
|
267
|
-
captureNormalized = captureNormalizedTransition, onCaptured = null, env = process.env
|
|
268
|
-
|
|
267
|
+
captureNormalized = captureNormalizedTransition, onCaptured = null, env = process.env,
|
|
268
|
+
deadlineAt: inheritedDeadlineAt = Infinity, signal } = {}) {
|
|
269
|
+
const deadlineAt = Math.min(inheritedDeadlineAt, now() + budgetMs);
|
|
270
|
+
if (signal?.aborted || now() >= deadlineAt) return 0;
|
|
269
271
|
if (developmentHooksSuspended(projectDir)) return 0;
|
|
270
272
|
if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return 0; }
|
|
271
273
|
const brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
|
|
272
274
|
try {
|
|
273
|
-
const consent = resolveTurnDb({ projectDir, brainHome });
|
|
275
|
+
const consent = resolveTurnDb({ projectDir, brainHome, deadlineAt, signal });
|
|
274
276
|
if (consent.skipped && !consent.skipped.startsWith('no project memory db')) return 0;
|
|
275
277
|
} catch { return 0; }
|
|
278
|
+
if (signal?.aborted || now() >= deadlineAt) return 0;
|
|
276
279
|
let held = token || takeReplayLock(projectDir);
|
|
277
280
|
let replayed = 0;
|
|
278
|
-
for (let round = 0; held && round < 8 && now() < deadlineAt; round += 1) {
|
|
281
|
+
for (let round = 0; held && round < 8 && now() < deadlineAt && !signal?.aborted; round += 1) {
|
|
279
282
|
try {
|
|
280
283
|
if (!adoptReplayLock(projectDir, held)) return replayed;
|
|
281
284
|
reclaimOrphans(projectDir);
|
|
282
|
-
const resolution = resolveProjectStore({ projectDir });
|
|
283
|
-
const store = makeStoreFactory(deadlineAt)({ projectDir, env, requestedStorePath: resolution.canonicalAgentDbPath });
|
|
285
|
+
const resolution = resolveProjectStore({ projectDir, deadlineAt });
|
|
286
|
+
const store = makeStoreFactory(deadlineAt)({ projectDir, env, requestedStorePath: resolution.canonicalAgentDbPath, deadlineAt, signal });
|
|
284
287
|
for (const snapshot of store.outbox.pendingSnapshots()) {
|
|
285
288
|
if (operatorProgressionSuspension(env)) return replayed;
|
|
286
|
-
if (now() >= deadlineAt) return replayed;
|
|
289
|
+
if (signal?.aborted || now() >= deadlineAt) return replayed;
|
|
287
290
|
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
288
|
-
store.captureFrozen(snapshot, { canCommit: () => refreshReplayLock(projectDir, held) });
|
|
291
|
+
store.captureFrozen(snapshot, { canCommit: () => !signal?.aborted && now() < deadlineAt && refreshReplayLock(projectDir, held) });
|
|
289
292
|
// captureFrozen commits only the verified key, retaining conflicting original history.
|
|
290
293
|
replayed += 1;
|
|
291
294
|
}
|
|
292
295
|
for (const file of queuedCaptures(projectDir)) {
|
|
293
296
|
if (operatorProgressionSuspension(env)) return replayed;
|
|
294
|
-
if (now() >= deadlineAt) return replayed;
|
|
297
|
+
if (signal?.aborted || now() >= deadlineAt) return replayed;
|
|
295
298
|
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
296
299
|
const claimed = claimQueued(file);
|
|
297
300
|
if (!claimed) continue;
|
|
298
301
|
onClaim?.(claimed);
|
|
302
|
+
if (signal?.aborted || now() >= deadlineAt) { returnClaim(claimed); return replayed; }
|
|
299
303
|
if (!refreshReplayLock(projectDir, held)) {
|
|
300
304
|
returnClaim(claimed);
|
|
301
305
|
return replayed;
|
|
@@ -308,16 +312,17 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
308
312
|
if (job) {
|
|
309
313
|
// Pre-upgrade raw queues lack origin identity and cannot be truthfully reconstructed.
|
|
310
314
|
if (runCapture === runSessionSnapshotHook && (!job.originProjectDir || (!job.payload?.projectProgression && !job.payload?.normalizedTransition))) { returnClaim(claimed); return replayed; }
|
|
311
|
-
const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome });
|
|
315
|
+
const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome, deadlineAt, signal });
|
|
312
316
|
if (developmentHooksSuspended(job.originProjectDir || projectDir)
|
|
313
317
|
|| (consent.skipped && !consent.skipped.startsWith('no project memory db'))) { returnClaim(claimed); return replayed; }
|
|
314
318
|
const options = { rawInput: JSON.stringify(job.payload), host: job.host, env,
|
|
315
|
-
budgetMs: Math.max(0, deadlineAt - now()),
|
|
319
|
+
budgetMs: Math.max(0, deadlineAt - now()), deadlineAt, signal,
|
|
320
|
+
makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
|
|
316
321
|
captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
|
|
317
322
|
captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) };
|
|
318
323
|
const result = job.payload?.normalizedTransition ? captureNormalized(job, options)
|
|
319
324
|
: runCapture(job.originProjectDir || projectDir, job.event, options);
|
|
320
|
-
committed = result?.progressionCaptured === true && Boolean(result.receipt);
|
|
325
|
+
committed = now() < deadlineAt && !signal?.aborted && result?.progressionCaptured === true && Boolean(result.receipt);
|
|
321
326
|
if (committed) onCaptured?.(result);
|
|
322
327
|
}
|
|
323
328
|
} catch { /* retain the queue until an exact-readback receipt exists */ }
|
|
@@ -327,7 +332,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
327
332
|
} catch { /* the debt stays durable; the next boundary hands it on again */ } finally {
|
|
328
333
|
releaseReplayLock(projectDir, held);
|
|
329
334
|
}
|
|
330
|
-
held = now() < deadlineAt && queuedWork(projectDir) ? takeReplayLock(projectDir) : null;
|
|
335
|
+
held = !signal?.aborted && now() < deadlineAt && queuedWork(projectDir) ? takeReplayLock(projectDir) : null;
|
|
331
336
|
}
|
|
332
337
|
if (held) releaseReplayLock(projectDir, held);
|
|
333
338
|
return replayed;
|
|
@@ -338,9 +343,11 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
338
343
|
export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } = {}) {
|
|
339
344
|
if (operatorProgressionSuspension(options.env || process.env)) return { state: 'suspended', replayed: 0, pending: null };
|
|
340
345
|
const startedAt = Date.now();
|
|
341
|
-
const
|
|
346
|
+
const deadlineAt = Math.min(options.deadlineAt ?? Infinity, startedAt + budgetMs);
|
|
347
|
+
if (options.signal?.aborted || startedAt >= deadlineAt) return { state: 'pending', replayed: 0, pending: null, reason: 'restore deadline exceeded' };
|
|
348
|
+
const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: Math.max(1, Math.min(300, budgetMs)), deadlineAt });
|
|
342
349
|
const root = resolution.projectRoot;
|
|
343
|
-
const replayed = runOutboxReplay({ ...options, projectDir: root, budgetMs: Math.max(0,
|
|
350
|
+
const replayed = runOutboxReplay({ ...options, deadlineAt, projectDir: root, budgetMs: Math.max(0, deadlineAt - Date.now()) });
|
|
344
351
|
let outboxPending = 0;
|
|
345
352
|
try { outboxPending = new ProgressionOutbox({ projectRoot: root }).pendingSnapshots().length; } catch { return { state: 'degraded', replayed, pending: null }; }
|
|
346
353
|
const pending = queuedWork(root) + outboxPending;
|
|
@@ -72,7 +72,7 @@ function marker(source) {
|
|
|
72
72
|
* The owner's convention writes them into the project's own namespace and into `default`; both are
|
|
73
73
|
* checked, because which one a given session used depends on whether `-n` was passed.
|
|
74
74
|
*/
|
|
75
|
-
function ownerNoteRows(canonicalAgentDbPath, projectNamespace) {
|
|
75
|
+
function ownerNoteRows(canonicalAgentDbPath, projectNamespace, options = {}) {
|
|
76
76
|
const namespaces = [...new Set([projectNamespace, ...OWNER_NOTE_NAMESPACES].filter(Boolean))];
|
|
77
77
|
const result = withProgressionReader(canonicalAgentDbPath, (reader) => {
|
|
78
78
|
const rows = [];
|
|
@@ -84,12 +84,12 @@ function ownerNoteRows(canonicalAgentDbPath, projectNamespace) {
|
|
|
84
84
|
}
|
|
85
85
|
}
|
|
86
86
|
return rows;
|
|
87
|
-
});
|
|
87
|
+
}, options);
|
|
88
88
|
return result.ok ? result.value : [];
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** The committed heads, computed the same way a restore computes them. Empty store → no heads. */
|
|
92
|
-
function committedHeads(canonicalAgentDbPath, projectIdentity) {
|
|
92
|
+
function committedHeads(canonicalAgentDbPath, projectIdentity, options = {}) {
|
|
93
93
|
const result = withProgressionReader(canonicalAgentDbPath, (reader) => {
|
|
94
94
|
const snapshots = [];
|
|
95
95
|
for (const key of reader.listKeys(PROGRESSION_NAMESPACE)) {
|
|
@@ -98,7 +98,7 @@ function committedHeads(canonicalAgentDbPath, projectIdentity) {
|
|
|
98
98
|
try { snapshots.push(JSON.parse(content)); } catch { /* a malformed row is the restore's problem */ }
|
|
99
99
|
}
|
|
100
100
|
return snapshots;
|
|
101
|
-
});
|
|
101
|
+
}, options);
|
|
102
102
|
if (!result.ok) {
|
|
103
103
|
if (fs.existsSync(canonicalAgentDbPath)) throw new Error(`prior progression read unavailable: ${result.reason}`);
|
|
104
104
|
return { heads: [], state: null, readPath: `unavailable (${result.reason})` };
|
|
@@ -130,14 +130,20 @@ export function buildProjectProgression({
|
|
|
130
130
|
env = process.env,
|
|
131
131
|
now = () => new Date().toISOString(),
|
|
132
132
|
trigger = payload.hook_event_name,
|
|
133
|
+
deadlineAt = Infinity,
|
|
134
|
+
signal,
|
|
133
135
|
} = {}) {
|
|
134
136
|
if (!resolution || typeof resolution !== 'object') throw new TypeError('resolution must be a project store resolution');
|
|
135
|
-
const
|
|
137
|
+
const options = { deadlineAt, signal };
|
|
138
|
+
const checkDeadline = () => { if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
|
|
139
|
+
checkDeadline();
|
|
140
|
+
const source = readSourceIdentity({ checkoutRoot: resolution.checkoutRoot, kind: resolution.kind, ...options });
|
|
136
141
|
source.identity.capturePath = fs.realpathSync.native(projectDir);
|
|
137
142
|
const ledger = readWorkLedger({ projectId: resolution.projectIdentity.id, env });
|
|
138
|
-
const note = readOwnerNote(() => ownerNoteRows(resolution.canonicalAgentDbPath, path.basename(resolution.projectRoot)));
|
|
143
|
+
const note = readOwnerNote(() => ownerNoteRows(resolution.canonicalAgentDbPath, path.basename(resolution.projectRoot), options));
|
|
139
144
|
const transcript = readTranscriptReference(payload.transcript_path, { host });
|
|
140
|
-
const { heads, state: priorState } = committedHeads(resolution.canonicalAgentDbPath, resolution.projectIdentity);
|
|
145
|
+
const { heads, state: priorState } = committedHeads(resolution.canonicalAgentDbPath, resolution.projectIdentity, options);
|
|
146
|
+
checkDeadline();
|
|
141
147
|
|
|
142
148
|
const priorSequence = heads.reduce((highest, head) => Math.max(highest, head.sequence ?? 0), 0);
|
|
143
149
|
|
|
@@ -290,7 +296,8 @@ export function buildProjectProgression({
|
|
|
290
296
|
projectProgression,
|
|
291
297
|
provenance,
|
|
292
298
|
meaningDigest: meaning,
|
|
293
|
-
|
|
299
|
+
// Compaction requires a fresh durable boundary even when application state is unchanged.
|
|
300
|
+
...(trigger !== 'PreCompact' && priorMeaning === meaning
|
|
294
301
|
? { skipped: { reason: 'no-op capture: project state and source identity are identical to the current head' } }
|
|
295
302
|
: {}),
|
|
296
303
|
};
|
|
@@ -176,7 +176,9 @@ function requireKey(value) {
|
|
|
176
176
|
* @returns {{ listKeys: Function, readContent: Function, close: Function }}
|
|
177
177
|
* @throws {ProgressionReaderUnavailable} when the CLI must be used instead.
|
|
178
178
|
*/
|
|
179
|
-
export function openProgressionReader(dbPath) {
|
|
179
|
+
export function openProgressionReader(dbPath, { deadlineAt = Infinity, signal } = {}) {
|
|
180
|
+
const checkDeadline = () => { if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
|
|
181
|
+
checkDeadline();
|
|
180
182
|
const DatabaseSync = databaseSync();
|
|
181
183
|
if (typeof DatabaseSync !== 'function') throw new ProgressionReaderUnavailable('node:sqlite is unavailable');
|
|
182
184
|
if (typeof dbPath !== 'string' || !dbPath) throw new ProgressionReaderUnavailable('no canonical store path');
|
|
@@ -230,8 +232,15 @@ export function openProgressionReader(dbPath) {
|
|
|
230
232
|
return {
|
|
231
233
|
/** Every active key in `namespace`, sorted, deduplicated-by-error, bounded. */
|
|
232
234
|
listKeys(namespace, { maxEntries = 10_000 } = {}) {
|
|
235
|
+
checkDeadline();
|
|
233
236
|
if (!Number.isSafeInteger(maxEntries) || maxEntries < 1) throw new TypeError('maxEntries must be a positive safe integer');
|
|
234
|
-
|
|
237
|
+
let rows = [];
|
|
238
|
+
if (Number.isFinite(deadlineAt) || signal) {
|
|
239
|
+
for (const row of listStatement.iterate(namespace)) {
|
|
240
|
+
checkDeadline(); rows.push(row);
|
|
241
|
+
if (rows.length > maxEntries) throw new Error('progression enumeration exceeds its bound');
|
|
242
|
+
}
|
|
243
|
+
} else rows = query(listStatement, [namespace]);
|
|
235
244
|
if (rows.length > maxEntries) throw new Error('progression enumeration exceeds its bound');
|
|
236
245
|
const keys = [];
|
|
237
246
|
const seen = new Set();
|
|
@@ -246,6 +255,7 @@ export function openProgressionReader(dbPath) {
|
|
|
246
255
|
|
|
247
256
|
/** The exact stored value for one (namespace, key), or null when that row does not exist. */
|
|
248
257
|
readContent(namespace, key) {
|
|
258
|
+
checkDeadline();
|
|
249
259
|
requireKey(key);
|
|
250
260
|
const rows = query(readStatement, [namespace, key]);
|
|
251
261
|
if (rows.length === 0) return null;
|
|
@@ -275,10 +285,10 @@ export function openProgressionReader(dbPath) {
|
|
|
275
285
|
* Returns `{ ok: true, value }`, or `{ ok: false, reason }` when the CLI must be used instead.
|
|
276
286
|
* Structural errors are NOT converted — they propagate, by design (see ProgressionReaderUnavailable).
|
|
277
287
|
*/
|
|
278
|
-
export function withProgressionReader(dbPath, work) {
|
|
288
|
+
export function withProgressionReader(dbPath, work, options = {}) {
|
|
279
289
|
let reader;
|
|
280
290
|
try {
|
|
281
|
-
reader = openProgressionReader(dbPath);
|
|
291
|
+
reader = openProgressionReader(dbPath, options);
|
|
282
292
|
} catch (error) {
|
|
283
293
|
if (error instanceof ProgressionReaderUnavailable) return { ok: false, reason: error.reason };
|
|
284
294
|
throw error;
|
|
@@ -32,6 +32,7 @@ const UNKNOWN_EXPLANATIONS = Object.freeze({
|
|
|
32
32
|
'output-bound': 'The full checkpoint and a goal/action-preserving bounded summary do not fit the host context; no checkpoint state was injected.',
|
|
33
33
|
'no-coherent-state': 'No coherent progression head survived validation.',
|
|
34
34
|
'restore-failed': 'The exact structural restore did not complete.',
|
|
35
|
+
'deadline-exceeded': 'The SessionStart restore deadline expired; no project restoration is claimed and durable work is retained.',
|
|
35
36
|
// MEASURED, and named rather than hidden. The in-process read path costs ~15ms for six snapshots;
|
|
36
37
|
// the `ruflo memory` CLI fallback costs 3634ms for the same six and does NOT fit the 2500ms
|
|
37
38
|
// restore deadline. Both produce an identical result, so this is a SPEED limit, not a correctness
|
|
@@ -74,16 +75,17 @@ function unknown(reason, { rowCount = null } = {}) {
|
|
|
74
75
|
* UNKNOWN should speak, so it never throws and never falls back to a CLI spawn: a count we cannot
|
|
75
76
|
* take cheaply is reported as null ("cannot tell"), which downgrades the banner rather than the run.
|
|
76
77
|
*/
|
|
77
|
-
function committedRowCount(canonicalAgentDbPath) {
|
|
78
|
+
function committedRowCount(canonicalAgentDbPath, options) {
|
|
78
79
|
try {
|
|
79
80
|
const result = withProgressionReader(canonicalAgentDbPath,
|
|
80
|
-
(reader) => reader.listKeys(PROGRESSION_NAMESPACE).length);
|
|
81
|
+
(reader) => reader.listKeys(PROGRESSION_NAMESPACE).length, options);
|
|
81
82
|
return result.ok ? result.value : null;
|
|
82
83
|
} catch { return null; }
|
|
83
84
|
}
|
|
84
85
|
|
|
85
86
|
function classify(error) {
|
|
86
87
|
const message = String(error?.message ?? error ?? '');
|
|
88
|
+
if (/deadline exceeded|deadline exhausted/i.test(message)) return 'deadline-exceeded';
|
|
87
89
|
if (/ruflo was not found|managed global ruflo/i.test(message)) return 'managed-ruflo-unavailable';
|
|
88
90
|
if (/canonical agentdb initialization failed/i.test(message)) return 'initialization-failed';
|
|
89
91
|
if (/structural pagination failed/i.test(message)
|
|
@@ -187,31 +189,44 @@ export function restoreProgressionForSession({
|
|
|
187
189
|
storeFactory,
|
|
188
190
|
maxOutputBytes = SESSION_CONTINUITY_LIMIT_BYTES,
|
|
189
191
|
deadlineMs = SESSION_CONTINUITY_DEADLINE_MS,
|
|
192
|
+
deadlineAt: inheritedDeadlineAt = Infinity,
|
|
193
|
+
signal,
|
|
190
194
|
writable = (projectRoot) => {
|
|
191
195
|
try { fs.accessSync(projectRoot, fs.constants.W_OK); return true; } catch { return false; }
|
|
192
196
|
},
|
|
193
197
|
} = {}) {
|
|
198
|
+
const deadlineAt = Math.min(inheritedDeadlineAt, Date.now() + deadlineMs);
|
|
199
|
+
const checkDeadline = () => { if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
|
|
200
|
+
const boundedRunner = (binary, args, options) => {
|
|
201
|
+
checkDeadline();
|
|
202
|
+
const result = spawnSync(binary, args, { ...options,
|
|
203
|
+
timeout: Math.max(1, Math.floor(Math.min(options.timeout || Infinity, deadlineAt - Date.now()))), killSignal: 'SIGKILL' });
|
|
204
|
+
if (result.error) throw new Error('restore deadline exceeded');
|
|
205
|
+
checkDeadline(); return result;
|
|
206
|
+
};
|
|
194
207
|
const projectDir = env.CLAUDE_PROJECT_DIR || cwd;
|
|
195
208
|
try {
|
|
209
|
+
checkDeadline();
|
|
196
210
|
if (operatorProgressionSuspension(env)) {
|
|
197
211
|
// Turn recording is independent: suspension must not strand ordinary AgentDB capture.
|
|
198
212
|
const turns = replayTurnQueue({ projectDir, env, home: env.HOME || os.homedir(), synchronous: true,
|
|
199
|
-
deadlineMs:
|
|
213
|
+
deadlineMs: deadlineAt, runner: boundedRunner });
|
|
200
214
|
return { status: 'unavailable', reason: 'operator-suspended', severity: 'info', pendingTurns: turns.pending,
|
|
201
215
|
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
216
|
}
|
|
203
|
-
} catch { return unknown(
|
|
217
|
+
} catch (error) { return unknown(classify(error)); }
|
|
204
218
|
let resolution;
|
|
205
219
|
try {
|
|
206
|
-
resolution = resolveProjectStore({ projectDir });
|
|
207
|
-
|
|
208
|
-
|
|
220
|
+
resolution = resolveProjectStore({ projectDir, deadlineAt });
|
|
221
|
+
checkDeadline();
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return unknown(/deadline|timed out/i.test(error.message) ? 'deadline-exceeded' : 'canonical-path');
|
|
209
224
|
}
|
|
210
225
|
|
|
211
226
|
if (!isProject(resolution)) return unavailable('non-project');
|
|
212
227
|
if (!writable(resolution.projectRoot)) return unavailable('read-only');
|
|
213
228
|
const initializing = !fs.existsSync(resolution.canonicalAgentDbPath);
|
|
214
|
-
const rowCount = initializing ? 0 : committedRowCount(resolution.canonicalAgentDbPath);
|
|
229
|
+
const rowCount = initializing ? 0 : committedRowCount(resolution.canonicalAgentDbPath, { deadlineAt, signal });
|
|
215
230
|
const miss = (reason) => unknown(reason, { rowCount });
|
|
216
231
|
|
|
217
232
|
const prefix = `${RESTORED_HEADER}\n`;
|
|
@@ -222,14 +237,7 @@ export function restoreProgressionForSession({
|
|
|
222
237
|
|
|
223
238
|
let store;
|
|
224
239
|
try {
|
|
225
|
-
|
|
226
|
-
const boundedRunner = (binary, args, options) => {
|
|
227
|
-
const remaining = deadlineAt - Date.now();
|
|
228
|
-
if (remaining < 1) throw new Error('restore deadline exceeded');
|
|
229
|
-
const result = spawnSync(binary, args, { ...options, timeout: Math.min(options.timeout, remaining) });
|
|
230
|
-
if (result.error) throw new Error('restore deadline exceeded');
|
|
231
|
-
return result;
|
|
232
|
-
};
|
|
240
|
+
checkDeadline();
|
|
233
241
|
const makeStore = storeFactory ?? ((options) => new ProjectProgressionStore({
|
|
234
242
|
...options,
|
|
235
243
|
env,
|
|
@@ -238,9 +246,11 @@ export function restoreProgressionForSession({
|
|
|
238
246
|
store = makeStore({
|
|
239
247
|
projectDir,
|
|
240
248
|
requestedStorePath: resolution.canonicalAgentDbPath,
|
|
249
|
+
deadlineAt, signal,
|
|
241
250
|
});
|
|
242
251
|
const home = env.HOME || env.USERPROFILE || os.homedir();
|
|
243
252
|
const turns = replayTurnQueue({ projectDir, env, home, synchronous: true, runner: boundedRunner, deadlineMs: deadlineAt });
|
|
253
|
+
checkDeadline();
|
|
244
254
|
if (turns.pending > 0 || turns.failed > 0) {
|
|
245
255
|
const failed = miss('outbox-replay');
|
|
246
256
|
return { ...failed, pendingTurns: turns.pending,
|
|
@@ -263,7 +273,8 @@ export function restoreProgressionForSession({
|
|
|
263
273
|
}
|
|
264
274
|
if (!suspended) {
|
|
265
275
|
const queue = drainCaptureQueue({ projectDir, budgetMs: Math.max(0, deadlineAt - Date.now()),
|
|
266
|
-
makeStoreFactory: () => () => store });
|
|
276
|
+
deadlineAt, signal, env, makeStoreFactory: () => () => store });
|
|
277
|
+
checkDeadline();
|
|
267
278
|
if (queue.state !== 'settled') {
|
|
268
279
|
const failed = miss('outbox-replay');
|
|
269
280
|
return { ...failed, pendingReplay: queue.pending,
|
|
@@ -275,6 +286,7 @@ export function restoreProgressionForSession({
|
|
|
275
286
|
let restored;
|
|
276
287
|
try {
|
|
277
288
|
restored = store.restoreLatest({ maxOutputBytes: payloadLimit, replayPending: !suspended, projectToBound: true });
|
|
289
|
+
checkDeadline();
|
|
278
290
|
} catch (error) {
|
|
279
291
|
if (store.pendingReplayCount?.() > 0) {
|
|
280
292
|
const failed = miss('outbox-replay');
|