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.
Files changed (86) hide show
  1. package/README.md +15 -19
  2. package/bin/developer-update.mjs +3 -0
  3. package/bin/install.mjs +15 -3
  4. package/bin/nightly-refresh.mjs +17 -1
  5. package/config/practical-rule-catalog.json +111 -0
  6. package/console/CONTRACT.md +47 -3
  7. package/console/app.js +112 -20
  8. package/package.json +5 -2
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/commands/brain-console.md +13 -1
  12. package/plugin/commands/configure.md +2 -2
  13. package/plugin/commands/rnb.md +13 -1
  14. package/plugin/commands/rnbc.md +13 -1
  15. package/plugin/commands/rvbc.md +13 -1
  16. package/plugin/commands/rvcb.md +13 -1
  17. package/plugin/hooks/codex-hooks.json +13 -1
  18. package/plugin/hooks/hook-contracts.json +20 -4
  19. package/plugin/scripts/adr-currency-gate.mjs +75 -31
  20. package/plugin/scripts/advocacy-outcomes.mjs +18 -4
  21. package/plugin/scripts/agentdb-recall.mjs +161 -26
  22. package/plugin/scripts/automatic-update.mjs +11 -2
  23. package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
  24. package/plugin/scripts/capability-registry.mjs +55 -114
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
  26. package/plugin/scripts/codex-hook-adapter.mjs +33 -6
  27. package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
  28. package/plugin/scripts/continuation-gate.mjs +2 -1
  29. package/plugin/scripts/continuity-brief.mjs +55 -22
  30. package/plugin/scripts/continuity-events.mjs +93 -17
  31. package/plugin/scripts/continuity-hook-policy.mjs +1 -1
  32. package/plugin/scripts/continuity-journal.mjs +77 -29
  33. package/plugin/scripts/decision-gate.mjs +62 -37
  34. package/plugin/scripts/decision-outcomes.mjs +192 -199
  35. package/plugin/scripts/detach.mjs +53 -13
  36. package/plugin/scripts/developer-update-cleanup.mjs +38 -0
  37. package/plugin/scripts/developer-update-lock.mjs +30 -0
  38. package/plugin/scripts/developer-update-maintenance.mjs +178 -0
  39. package/plugin/scripts/developer-update-owner.mjs +24 -0
  40. package/plugin/scripts/developer-update-policy.mjs +48 -0
  41. package/plugin/scripts/developer-update.mjs +435 -0
  42. package/plugin/scripts/doc-currency.mjs +993 -0
  43. package/plugin/scripts/ground-ruvnet.sh +16 -3
  44. package/plugin/scripts/grounding-answer.mjs +18 -16
  45. package/plugin/scripts/grounding-stamp.sh +3 -0
  46. package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
  47. package/plugin/scripts/grounding-turn-gate.mjs +34 -29
  48. package/plugin/scripts/grounding-turn-mark.mjs +101 -17
  49. package/plugin/scripts/hook-context-budget.mjs +90 -0
  50. package/plugin/scripts/hook-registry.mjs +129 -1
  51. package/plugin/scripts/hook-shim.mjs +20 -2
  52. package/plugin/scripts/host-update.mjs +6 -3
  53. package/plugin/scripts/lesson-gate.mjs +1 -1
  54. package/plugin/scripts/nightly-scheduler.mjs +55 -16
  55. package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
  56. package/plugin/scripts/project-capture-queue.mjs +22 -15
  57. package/plugin/scripts/project-progression-producer.mjs +15 -8
  58. package/plugin/scripts/project-progression-reader.mjs +14 -4
  59. package/plugin/scripts/project-progression-session-start.mjs +29 -17
  60. package/plugin/scripts/project-progression-sources.mjs +24 -10
  61. package/plugin/scripts/project-progression-store.mjs +7 -2
  62. package/plugin/scripts/project-store-resolver.mjs +10 -7
  63. package/plugin/scripts/project-transition-hook.mjs +20 -12
  64. package/plugin/scripts/session-snapshot-hook.mjs +20 -9
  65. package/plugin/scripts/session-start-budget.mjs +18 -0
  66. package/plugin/scripts/session-start-core.mjs +32 -6
  67. package/plugin/scripts/session-start-trace.mjs +2 -1
  68. package/plugin/scripts/turn-outcome-capture.mjs +7 -5
  69. package/plugin/scripts/unprompted-runtime.mjs +36 -10
  70. package/plugin/scripts/user-settings.mjs +7 -1
  71. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  72. package/scripts/codex-managed-terminal.mjs +1 -1
  73. package/scripts/console-runtime-identity.mjs +2 -0
  74. package/scripts/console-suite-update.mjs +117 -0
  75. package/scripts/doc-currency.mjs +6 -990
  76. package/scripts/hook-qualify-core.mjs +30 -1
  77. package/scripts/model-managed-prompt.mjs +1 -1
  78. package/scripts/model-managed-workflow-service.mjs +113 -14
  79. package/scripts/model-routing-controller.mjs +112 -21
  80. package/scripts/onboarding-console.mjs +41 -12
  81. package/scripts/practical-rule-selector.mjs +51 -0
  82. package/scripts/product-integrity-contract.mjs +11 -4
  83. package/scripts/release-qualification-contract.mjs +14 -0
  84. package/scripts/remedy-registry.mjs +1 -0
  85. package/scripts/source-scope-receipt.mjs +9 -4
  86. 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 = 47;
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
- function updateModules(directory, sourceDir = path.dirname(fileURLToPath(import.meta.url))) {
144
- const files = ['automatic-update.mjs', 'user-settings.mjs', 'ruvnet-gate1-pattern.mjs'];
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 (Object.keys(modules).sort().join(',') !== 'automatic-update.mjs,ruvnet-gate1-pattern.mjs,user-settings.mjs') throw new Error('invalid update module closure');
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 registration = readNightlyRegistration({ brainHome, identity });
274
- if (!registration.ok) return { state: 'failed', ageHours,
275
- evidence: `Nightly registration is invalid: ${registration.why}`, receipt };
276
- const expectedIdentity = { schedulerIdentity: registration.record.identity,
277
- registrationPath: registration.record.recordPath, nodePath: registration.record.nodePath,
278
- runnerPath: registration.record.runnerPath, runnerSha256: registration.record.runnerSha256, argv: [] };
279
- if (receipt.schedulerIdentity !== registration.record.identity
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
- /** agentic-kit owns this machine's Brain updates (`ak sync`), so the Brain's own scheduler is off on purpose. */
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 { return JSON.parse(fs.readFileSync(path.join(home, '.config', 'agentic-kit', 'kit.json'), 'utf8')).ruvnetBrain === true; }
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
- const deadlineAt = now() + budgetMs;
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()), makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
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 resolution = resolveProjectStore({ projectDir, gitTimeoutMs: Math.max(1, Math.min(300, budgetMs)) });
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, budgetMs - (Date.now() - startedAt)) });
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 source = readSourceIdentity({ checkoutRoot: resolution.checkoutRoot, kind: resolution.kind });
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
- ...(priorMeaning === meaning
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
- const rows = query(listStatement, [namespace]);
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: Date.now() + 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('restore-failed'); }
217
+ } catch (error) { return unknown(classify(error)); }
204
218
  let resolution;
205
219
  try {
206
- resolution = resolveProjectStore({ projectDir });
207
- } catch {
208
- return unknown('canonical-path');
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
- const deadlineAt = Date.now() + deadlineMs;
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');