ruvnet-brain 4.5.5 → 4.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +24 -6
  3. package/config/model-router/claude-terminal-mod/.claude-plugin/plugin.json +1 -0
  4. package/config/model-router/claude-terminal-mod/README.md +22 -0
  5. package/config/model-router/claude-terminal-mod/hooks/hooks.json +1 -0
  6. package/config/model-router/claude-terminal-mod/hooks/policy.default.mjs +2 -0
  7. package/config/model-router/claude-terminal-mod/hooks/register.js +66 -0
  8. package/config/model-router/claude-terminal-mod/hooks/routing.js +55 -0
  9. package/config/model-router/claude-terminal-mod/hooks/runtime.js +2 -0
  10. package/config/model-router/claude-terminal-mod/tests/native.test.ts +86 -0
  11. package/config/model-router/policy.default.mjs +4 -1
  12. package/kb/forge-update.mjs +50 -9
  13. package/package.json +4 -3
  14. package/plugin/.claude-plugin/plugin.json +1 -1
  15. package/plugin/.codex-plugin/plugin.json +1 -1
  16. package/plugin/commands/configure.md +13 -1
  17. package/plugin/hooks/hook-contracts.json +1 -1
  18. package/plugin/mcp/managed-cli-generation.mjs +91 -0
  19. package/plugin/mcp/managed-cli-interface.mjs +45 -16
  20. package/plugin/mcp/server.mjs +3 -2
  21. package/plugin/scripts/capability-claim-evidence.mjs +9 -2
  22. package/plugin/scripts/continuation-gate.mjs +3 -1
  23. package/plugin/scripts/continuity-journal.mjs +11 -11
  24. package/plugin/scripts/hook-shim.mjs +2 -1
  25. package/plugin/scripts/kb-copy-proof.mjs +4 -3
  26. package/plugin/scripts/nightly-scheduler.mjs +6 -2
  27. package/plugin/scripts/project-capture-queue.mjs +23 -8
  28. package/plugin/scripts/project-progression-hook.mjs +16 -7
  29. package/plugin/scripts/project-progression-outbox.mjs +116 -19
  30. package/plugin/scripts/project-progression-producer.mjs +12 -1
  31. package/plugin/scripts/project-progression-session-start.mjs +10 -0
  32. package/plugin/scripts/project-progression-store.mjs +53 -1
  33. package/plugin/scripts/project-progression-suspension.mjs +60 -0
  34. package/plugin/scripts/project-transition-hook.mjs +44 -16
  35. package/plugin/scripts/session-snapshot-hook.mjs +16 -8
  36. package/plugin/scripts/turn-outcome-capture.mjs +7 -3
  37. package/scripts/claude-terminal-mod.mjs +89 -0
  38. package/scripts/model-routing-gateway.mjs +47 -14
  39. package/scripts/model-routing-launchers.mjs +36 -1
  40. package/scripts/model-terminal-gateway.mjs +310 -0
  41. package/scripts/model-terminal-launchers.mjs +300 -0
  42. package/scripts/release-qualification-contract.mjs +37 -6
  43. package/scripts/release-qualification.mjs +9 -6
@@ -0,0 +1,91 @@
1
+ // DISTINCT-FROM hook-shim.mjs: managed execution fails closed; advisory hook fallback cannot
2
+ // authorize a command. The protocol shell owns declarations, each request owns one generation.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { createHash, randomUUID } from 'node:crypto';
7
+ import { fileURLToPath, pathToFileURL } from 'node:url';
8
+
9
+ const LOCAL_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
10
+ const digest = (value) => createHash('sha256').update(value).digest('hex');
11
+ const contained = (root, file) => file.startsWith(`${root}${path.sep}`);
12
+
13
+ export function managedGenerationIdentity(root, { version, generation = 'source' } = {}) {
14
+ root = fs.realpathSync(root);
15
+ const manifestPath = path.join(root, '.claude-plugin', 'plugin.json');
16
+ const handlerPath = path.join(root, 'mcp', 'managed-cli-interface.mjs');
17
+ for (const file of [manifestPath, handlerPath]) {
18
+ if (!fs.lstatSync(file).isFile() || !contained(root, fs.realpathSync(file))) {
19
+ throw new Error('managed generation resource is not a contained regular file');
20
+ }
21
+ }
22
+ const manifestBytes = fs.readFileSync(manifestPath);
23
+ const manifest = JSON.parse(manifestBytes);
24
+ if (manifest.name !== 'ruvnet-brain' || typeof manifest.version !== 'string'
25
+ || !/^\d+\.\d+\.\d+(?:[-+][a-zA-Z0-9.+-]+)?$/.test(manifest.version)
26
+ || (version !== undefined && manifest.version !== version)) {
27
+ throw new Error('managed generation manifest name/version mismatch');
28
+ }
29
+ const sourceDigest = digest(Buffer.concat([manifestBytes, fs.readFileSync(handlerPath)]));
30
+ return Object.freeze({ root, handlerPath, version: manifest.version, generation, sourceDigest,
31
+ binding: digest(JSON.stringify([root, manifest.version, generation, sourceDigest])) });
32
+ }
33
+
34
+ function selectGeneration(env, fallbackRoot) {
35
+ const home = env.HOME || os.homedir();
36
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(home, '.cache', 'ruvnet-brain');
37
+ let activeBytes;
38
+ try { activeBytes = fs.readFileSync(path.join(brainHome, 'active.json'), 'utf8'); }
39
+ catch (error) {
40
+ if (error.code !== 'ENOENT') throw error;
41
+ // A copied persistent shell must never manufacture an active generation from its resources.
42
+ if (path.resolve(fallbackRoot) === path.join(home, '.claude', 'ruvnet-brain')) {
43
+ throw new Error('managed active generation unavailable');
44
+ }
45
+ return { ...managedGenerationIdentity(fallbackRoot), brainHome, leased: false };
46
+ }
47
+ const active = JSON.parse(activeBytes);
48
+ if (typeof active.codeRoot !== 'string' || typeof active.version !== 'string'
49
+ || !Number.isSafeInteger(active.generation) || active.generation < 0) {
50
+ throw new Error('managed active generation descriptor invalid');
51
+ }
52
+ const versions = fs.realpathSync(path.join(brainHome, 'versions'));
53
+ const root = fs.realpathSync(path.resolve(brainHome, active.codeRoot));
54
+ if (!contained(versions, root) || path.basename(root) !== active.version) {
55
+ throw new Error('managed active generation escapes its version tree');
56
+ }
57
+ return { ...managedGenerationIdentity(root, active), brainHome, leased: true };
58
+ }
59
+
60
+ export function createManagedCliDispatcher({ fallbackRoot = LOCAL_ROOT,
61
+ importModule = (url) => import(url) } = {}) {
62
+ const pins = new Map();
63
+ return async function dispatchManagedCli(toolName, args, env = process.env,
64
+ fetchImpl = globalThis.fetch, lifecycle = {}) {
65
+ let lease;
66
+ try {
67
+ const selected = selectGeneration(env, fallbackRoot);
68
+ // ESM caches by URL. A changed file at the same immutable path must never be blessed as
69
+ // new code while execution still uses the old cached module (nor can query strings fix dependencies).
70
+ const pinned = pins.get(selected.root);
71
+ if (pinned && pinned !== selected.sourceDigest) throw new Error('managed immutable generation changed; restart required');
72
+ pins.set(selected.root, selected.sourceDigest);
73
+ if (selected.leased) {
74
+ const dir = path.join(selected.brainHome, 'leases');
75
+ fs.mkdirSync(dir, { recursive: true });
76
+ lease = path.join(dir, `mcp-managed-${process.pid}-${randomUUID()}.json`);
77
+ fs.writeFileSync(lease, JSON.stringify({ pid: process.pid, version: selected.version, at: new Date().toISOString() }), { flag: 'wx', mode: 0o600 });
78
+ }
79
+ const handler = await importModule(pathToFileURL(selected.handlerPath).href);
80
+ if (typeof handler.callManagedCli !== 'function') throw new Error('managed generation handler unavailable');
81
+ return await handler.callManagedCli(toolName, args, env, fetchImpl,
82
+ { ...lifecycle, generationBinding: selected.binding });
83
+ } catch (error) {
84
+ return { content: [{ type: 'text', text: `managed generation unavailable: ${error.message}` }], isError: true };
85
+ } finally {
86
+ if (lease) { try { fs.unlinkSync(lease); } catch { /* lease expiry preserves interrupted calls */ } }
87
+ }
88
+ };
89
+ }
90
+
91
+ export const dispatchManagedCli = createManagedCliDispatcher();
@@ -2,11 +2,14 @@ import { spawn } from 'node:child_process';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+ import { managedGenerationIdentity } from './managed-cli-generation.mjs';
5
7
  import { loadRuntimePreferences, runtimeChildEnv } from '../scripts/runtime-preferences.mjs';
6
8
  import { projectDirectory } from '../scripts/project-identity.mjs';
7
9
  import { recordManagedCliObservation, recordRegistryLatestObservation } from '../scripts/capability-claim-evidence.mjs';
8
10
  import { runSessionSnapshotHook } from '../scripts/session-snapshot-hook.mjs';
9
11
  import { resolveProjectStore } from '../scripts/project-store-resolver.mjs';
12
+ import { isAutomaticProgressionSuspension } from '../scripts/project-progression-suspension.mjs';
10
13
 
11
14
  export const MANAGED_EXECUTABLES = Object.freeze([
12
15
  'ruflo',
@@ -34,6 +37,12 @@ const MAX_ARG_BYTES = 8192;
34
37
  const DEFAULT_FRESH_MS = 24 * 60 * 60 * 1000;
35
38
  const DEFAULT_TIMEOUT_MS = 30_000;
36
39
  const MAX_OUTPUT_BYTES = 1024 * 1024;
40
+ // Bind direct calls to the module loaded at process start, too. Missing shell resources do not
41
+ // prevent declarations from loading; the active dispatcher supplies the execution binding.
42
+ const LOCAL_BINDING = (() => {
43
+ try { return managedGenerationIdentity(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')).binding; }
44
+ catch { return null; }
45
+ })();
37
46
 
38
47
  const executableSchema = {
39
48
  type: 'string',
@@ -139,27 +148,32 @@ function stateDir(env) {
139
148
  return env.RUVNET_BRAIN_HELP_READ_DIR || path.join(brainHome, 'help-read');
140
149
  }
141
150
 
142
- function freshStamp(executable, argv, env, now = Date.now()) {
151
+ function freshStamp(executable, argv, env, binding, now = Date.now()) {
143
152
  const stamp = path.join(stateDir(env), helpKey(executable, argv));
144
- let stat;
153
+ let stat; let readAt;
145
154
  try {
146
- stat = fs.statSync(stamp);
155
+ stat = fs.lstatSync(stamp);
156
+ if (!stat.isFile()) return false;
157
+ const receipt = JSON.parse(fs.readFileSync(stamp, 'utf8'));
158
+ if (receipt.schemaVersion !== 1 || receipt.generation !== binding
159
+ || !Number.isFinite(receipt.readAt) || receipt.readAt > now) return false;
160
+ readAt = receipt.readAt;
147
161
  } catch {
148
162
  return false;
149
163
  }
150
164
  const configured = Number(env.RUVNET_BRAIN_HELP_MAX_AGE_MS);
151
165
  const maxAge = Number.isFinite(configured) && configured > 0 ? configured : DEFAULT_FRESH_MS;
152
- return now - stat.mtimeMs < maxAge;
166
+ return now - stat.mtimeMs < maxAge && now - readAt < maxAge;
153
167
  }
154
168
 
155
- function writeStamps(executable, argv, env) {
169
+ function writeStamps(executable, argv, env, binding) {
156
170
  const dir = stateDir(env);
157
171
  fs.mkdirSync(dir, { recursive: true });
158
172
  for (const key of stampKeysForHelp(executable, argv)) {
159
173
  const stamp = path.join(dir, key);
160
174
  const temporary = path.join(dir, `.${key}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`);
161
175
  try {
162
- fs.writeFileSync(temporary, '', { flag: 'wx', mode: 0o600 });
176
+ fs.writeFileSync(temporary, JSON.stringify({ schemaVersion: 1, generation: binding, readAt: Date.now() }), { flag: 'wx', mode: 0o600 });
163
177
  fs.renameSync(temporary, stamp);
164
178
  } finally {
165
179
  try { fs.unlinkSync(temporary); } catch { /* renamed or never created */ }
@@ -253,9 +267,10 @@ export function normalizeManagedExecution(execution) {
253
267
  const output = [execution?.stdout, execution?.stderr].filter(Boolean)
254
268
  .join(execution?.stdout && execution?.stderr ? '\n' : '');
255
269
  const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
270
+ if (execution?.error) return { outcome: 'failure', output, contradictoryFailure };
256
271
  if (execution?.signal) return { outcome: 'interrupted', output, signal: execution.signal, contradictoryFailure };
257
- if (execution?.error || contradictoryFailure || execution?.code === null || execution?.code === undefined) {
258
- return { outcome: execution?.error || contradictoryFailure ? 'failure' : 'unknown', output, contradictoryFailure };
272
+ if (contradictoryFailure || execution?.code === null || execution?.code === undefined) {
273
+ return { outcome: contradictoryFailure ? 'failure' : 'unknown', output, contradictoryFailure };
259
274
  }
260
275
  return { outcome: execution.code === 0 ? 'success' : 'failure', output, contradictoryFailure };
261
276
  }
@@ -290,27 +305,34 @@ function managedProgressionCapture({ executable, argv, projectRoot, env, event,
290
305
  },
291
306
  } : {}),
292
307
  };
293
- return { adopted: true, ...runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host }) };
308
+ const result = runSessionSnapshotHook(projectRoot, event, { rawInput: JSON.stringify(payload), host, env });
309
+ return isAutomaticProgressionSuspension(result) ? result : { adopted: true, ...result };
294
310
  }
295
311
 
296
312
  function resultOf(executable, argv, result) {
297
313
  const output = [result.stdout, result.stderr].filter(Boolean).join(result.stdout && result.stderr ? '\n' : '');
298
314
  const normalized = normalizeManagedExecution(result);
315
+ const terminal = { outcome: normalized.outcome, code: result.code ?? null, signal: result.signal ?? null,
316
+ error: result.error ?? null, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
299
317
  if (normalized.outcome !== 'success') {
300
- const reason = result.error || (normalized.contradictoryFailure ? 'fatal output despite exit 0' : result.code == null ? 'no terminal exit status' : `exit ${result.code}`);
318
+ const reason = result.error || (result.signal ? `signal ${result.signal}` : result.code != null && result.code !== 0 ? `exit ${result.code}` : normalized.contradictoryFailure ? 'fatal output without successful completion' : 'no terminal exit status');
301
319
  return {
302
- content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} failed: ${reason}` }],
320
+ content: [{ type: 'text', text: [output, `${executable} ${argv.join(' ')} failed: ${reason}`].filter(Boolean).join('\n') }],
321
+ structuredContent: terminal,
303
322
  isError: true,
304
323
  };
305
324
  }
306
325
  return {
307
326
  content: [{ type: 'text', text: output || `${executable} ${argv.join(' ')} completed successfully` }],
327
+ structuredContent: terminal,
308
328
  isError: false,
309
329
  };
310
330
  }
311
331
 
312
332
  export async function callManagedCli(toolName, args, env = process.env, fetchImpl = globalThis.fetch, lifecycle = {}) {
313
333
  try {
334
+ const binding = lifecycle.generationBinding || LOCAL_BINDING;
335
+ if (!binding) throw new Error('managed generation identity unavailable');
314
336
  const executable = assertExecutable(args?.executable);
315
337
  const argv = literalArgv(args?.argv ?? []);
316
338
  const projectRoot = env.RUVNET_BRAIN_PROJECT_DIR || projectDirectory({ env });
@@ -339,13 +361,13 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
339
361
  stampKeysForHelp(executable, argv);
340
362
  const commandArgv = [...argv, '--help'];
341
363
  const execution = await execute(executable, commandArgv, env);
342
- if (execution.code === 0 && !execution.error) writeStamps(executable, argv, env);
364
+ if (normalizeManagedExecution(execution).outcome === 'success') writeStamps(executable, argv, env, binding);
343
365
  recordManagedCliObservation({ toolName, executable, argv: commandArgv, execution, env });
344
366
  return resultOf(executable, commandArgv, execution);
345
367
  }
346
368
 
347
369
  if (toolName === 'ruvnet_cli_run') {
348
- if (!freshStamp(executable, argv, env)) {
370
+ if (!freshStamp(executable, argv, env, binding)) {
349
371
  const key = helpKey(executable, argv).replaceAll('.', ' ');
350
372
  return {
351
373
  content: [{ type: 'text', text: `Read the interface first with ruvnet_cli_help for: ${key}` }],
@@ -386,7 +408,8 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
386
408
  : env;
387
409
  const capture = typeof lifecycle.capture === 'function' ? lifecycle.capture : managedProgressionCapture;
388
410
  const beforeCapture = await capture({ executable, argv, projectRoot, env: childEnv, event: 'PreToolUse' });
389
- if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true)) {
411
+ if (beforeCapture?.error || (beforeCapture?.adopted && beforeCapture.progressionCaptured !== true
412
+ && !isAutomaticProgressionSuspension(beforeCapture))) {
390
413
  const reason = beforeCapture.error || beforeCapture.skipped || 'pre-execution progression was not durably captured';
391
414
  return { content: [{ type: 'text', text: `managed execution refused: ${reason}` }], isError: true };
392
415
  }
@@ -400,13 +423,19 @@ export async function callManagedCli(toolName, args, env = process.env, fetchImp
400
423
  await lifecycle.afterExecute({ executable, argv, projectRoot, env: childEnv, execution, normalized });
401
424
  }
402
425
  recordManagedCliObservation({ toolName, executable, argv, execution, env });
403
- if (afterCapture?.adopted && afterCapture.progressionCaptured !== true) {
426
+ if (afterCapture?.adopted && afterCapture.progressionCaptured !== true
427
+ && !isAutomaticProgressionSuspension(afterCapture)) {
404
428
  return {
405
429
  content: [{ type: 'text', text: `managed command ${normalized.outcome}; continuity result was not durably captured: ${afterCapture.skipped || 'unknown reason'}` }],
406
430
  isError: true,
407
431
  };
408
432
  }
409
- return resultOf(executable, argv, execution);
433
+ const result = resultOf(executable, argv, execution);
434
+ if (isAutomaticProgressionSuspension(beforeCapture) || isAutomaticProgressionSuspension(afterCapture)) {
435
+ result.content.push({ type: 'text', text: 'Automatic project progression is operator-suspended; suspended captures produced no progression receipt. Ordinary AgentDB memory and explicit checkpoints remain available.' });
436
+ result.structuredContent.continuity = 'operator-suspended';
437
+ }
438
+ return result;
410
439
  }
411
440
 
412
441
  return {
@@ -35,7 +35,8 @@ import os from 'node:os';
35
35
  import readline from 'node:readline';
36
36
  import { fileURLToPath } from 'node:url';
37
37
  import { writeOwn as writeOwnReadiness } from '../scripts/mcp-readiness.mjs';
38
- import { callManagedCli, MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
38
+ import { MANAGED_CLI_TOOLS } from './managed-cli-interface.mjs';
39
+ import { dispatchManagedCli } from './managed-cli-generation.mjs';
39
40
  import { unmountedNotice } from '../scripts/brain-location.mjs';
40
41
 
41
42
  const BRAIN_HOME = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
@@ -362,7 +363,7 @@ async function handleClient(msg) {
362
363
  case 'tools/call': {
363
364
  if (params?.name === 'ruvnet_cli_help' || params?.name === 'ruvnet_cli_run'
364
365
  || params?.name === 'ruvnet_registry_latest') {
365
- return clientOk(id, await callManagedCli(params.name, params.arguments || {}));
366
+ return clientOk(id, await dispatchManagedCli(params.name, params.arguments || {}));
366
367
  }
367
368
  if (params?.name !== 'search_ruvnet') return clientErr(id, -32602, `unknown tool: ${params?.name}`);
368
369
  refreshLease();
@@ -3,6 +3,7 @@ import crypto from 'node:crypto';
3
3
  import fs from 'node:fs';
4
4
  import os from 'node:os';
5
5
  import path from 'node:path';
6
+ import { redactText } from './continuity-events.mjs';
6
7
 
7
8
  const HEX40 = /^[a-f0-9]{40}$/;
8
9
  const HEX64 = /^[a-f0-9]{64}$/;
@@ -157,7 +158,13 @@ export function recordManagedCliObservation({ toolName, executable, argv, execut
157
158
  observedAt = new Date().toISOString() } = {}) {
158
159
  try {
159
160
  const output = [execution?.stdout, execution?.stderr].filter(Boolean).join('\n');
160
- const reachable = execution?.code === 0 && !execution?.error;
161
+ const contradictoryFailure = /(?:^|\n)\s*(?:❌|\[ERROR\])|invalid pragma command|key not found/i.test(output);
162
+ const reachable = execution?.code === 0 && !execution?.error && !execution?.signal && !contradictoryFailure;
163
+ const outcome = execution?.error ? 'failure' : execution?.signal ? 'interrupted' : contradictoryFailure ? 'failure'
164
+ : Number.isSafeInteger(execution?.code) ? execution.code === 0 ? 'success' : 'failure' : 'unknown';
165
+ const terminal = { outcome, exitCode: Number.isSafeInteger(execution?.code) ? execution.code : null,
166
+ error: typeof execution?.error === 'string' ? redactText(execution.error).slice(0, 4096) : null,
167
+ signal: typeof execution?.signal === 'string' ? redactText(execution.signal).slice(0, 100) : null };
161
168
  const version = VERSION.exec(output)?.[1] || null;
162
169
  let observationClass = null;
163
170
  let fields = {};
@@ -169,7 +176,7 @@ export function recordManagedCliObservation({ toolName, executable, argv, execut
169
176
  observationClass = 'health';
170
177
  const negative = /\b(?:critical|degraded|error|failed|failure|unhealthy)\b/i.test(output);
171
178
  const positive = /\b(?:all checks passed|healthy|overall\s*[:=-]?\s*(?:ok|pass)|status\s*[:=-]\s*(?:ok|pass|healthy))\b/i.test(output);
172
- fields = { healthVerdict: negative ? 'FAIL' : reachable && positive ? 'PASS' : 'UNKNOWN', reachable };
179
+ fields = { healthVerdict: negative || ['failure', 'interrupted'].includes(outcome) ? 'FAIL' : reachable && positive ? 'PASS' : 'UNKNOWN', reachable, terminal };
173
180
  }
174
181
  if (!observationClass) return null;
175
182
  const payload = {
@@ -385,7 +385,9 @@ function promiseBookkeeping() {
385
385
  }
386
386
  }
387
387
  const openKeys = new Set(led.items.filter((i) => i?.kind === PROMISE_KIND && !i.done && i.projectId === pid).map((i) => i.key));
388
- for (const promise of extractCommitments(hookInput.last_assistant_message)) {
388
+ // Owner preference suppresses only new capture; verified closure above and integrity audits stay active.
389
+ const newPromises = process.env.RUVNET_PROMISE_CAPTURE === 'off' ? [] : extractCommitments(hookInput.last_assistant_message);
390
+ for (const promise of newPromises) {
389
391
  if (openKeys.has(promise.key) || openKeys.size >= PROMISE_CAP_OPEN) continue;
390
392
  openKeys.add(promise.key);
391
393
  led.items.push({ schemaVersion: 1, kind: PROMISE_KIND, text: promise.text, key: promise.key, done: false, at,
@@ -303,24 +303,24 @@ export function recordingLine(status, now = Date.now()) {
303
303
  return `AgentDB: recording ✓ (last write ${ago(now - status.lastCommitAt)} ago, ${status.eventsToday} event(s) today, outbox ${status.pending} pending)`;
304
304
  }
305
305
 
306
- /**
307
- * The Claude Stop line, at most ONCE per session per condition (review S3: it repeated at every turn).
308
- * State is a tiny file in the project's own .swarm, bounded to the last 20 sessions.
309
- */
310
- export function stopNotice({ journal, status, session }) {
311
- if (!status?.stuck || !status.problem) return '';
312
- const file = path.join(journal.swarm, NOTICE_STATE);
306
+ /** Shared notices: once per session/condition, bounded to the last twenty sessions in .swarm. */
307
+ export function conditionNotice({ swarm, session, condition, message, now = Date.now }) {
308
+ const file = path.join(swarm, NOTICE_STATE);
313
309
  let state = {};
314
310
  try { state = JSON.parse(fs.readFileSync(file, 'utf8')) || {}; } catch { /* first notice */ }
315
311
  const id = String(session || 'unknown');
316
312
  const shown = Array.isArray(state[id]?.conditions) ? state[id].conditions : [];
317
- if (shown.includes(status.problem)) return '';
318
- state[id] = { at: new Date(journal.now()).toISOString(), conditions: [...shown, status.problem] };
313
+ if (shown.includes(condition)) return '';
314
+ state[id] = { at: new Date(now()).toISOString(), conditions: [...shown, condition] };
319
315
  const recent = Object.entries(state).sort((a, b) => String(b[1]?.at).localeCompare(String(a[1]?.at))).slice(0, 20);
320
316
  try { fs.writeFileSync(file, JSON.stringify(Object.fromEntries(recent)), { mode: 0o600 }); } catch { /* still show it once */ }
321
- return `[RuvNet Brain] ${recordingLine(status, journal.now())}`;
317
+ return message;
318
+ }
319
+ export function stopNotice({ journal, status, session }) {
320
+ if (!status?.stuck || !status.problem) return '';
321
+ return conditionNotice({ swarm: journal.swarm, session, condition: status.problem, now: () => journal.now(),
322
+ message: `[RuvNet Brain] ${recordingLine(status, journal.now())}` });
322
323
  }
323
-
324
324
  function defaultStore({ ruflo, db, key, value }) {
325
325
  const cwd = rufloRunDir(db);
326
326
  try {
@@ -86,7 +86,8 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); }
86
86
  const TABLE = {
87
87
  'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' },
88
88
  'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
89
- 'capacity-aware-parallel-work': { file: 'capacity-aware-parallel-work.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 32768 },
89
+ // Capacity advice is a Brain prompt hint; OFF must not execute its resource probes or steer work.
90
+ 'capacity-aware-parallel-work': { file: 'capacity-aware-parallel-work.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 },
90
91
  // ADR-063 / issue #103: `blocking` so an opt-in refusal can actually reach the host. The hook
91
92
  // still exits 0 for every user at the shipped default (managedMemoryBoundary=advise), so this
92
93
  // changes the CEILING of what it may do, not what it does.
@@ -46,10 +46,11 @@ const storeList = (source) => {
46
46
  return stores && typeof stores === 'object' ? Object.entries(stores).map(([kbName, value]) => ({ kbName, ...value })) : [];
47
47
  };
48
48
  const stemOf = (file) => path.basename(String(file)).replace(/(?:\.big)?\.rvf$/i, '').toLowerCase();
49
- // The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store>.meta.json, <store>.passages.jsonl,
50
- // <store>.symbols.json, <store>-primer.md.
49
+ // The store a sidecar belongs to: <store>.big.rvf[.embed|.idmap].json, <store>[.big].meta.json,
50
+ // <store>[.big].passages.jsonl, <store>[.big].symbols.json, <store>-primer.md.
51
51
  const storeStem = (file) => path.basename(String(file)).toLowerCase()
52
- .replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '').replace(/\.(?:meta|symbols)\.json$|\.passages\.jsonl$/, '');
52
+ .replace(/-primer\.md$/, '').replace(/(?:\.big)?\.rvf(?:\.[a-z]+\.json)?$/, '')
53
+ .replace(/(?:\.big)?(?:\.(?:meta|symbols)\.json|\.passages\.jsonl)$/, '');
53
54
  // Files the installer/updater writes into a KB that no bundle ships (bin/install.mjs placeUpdater,
54
55
  // placeTrustedCoverageValidator, ensureVerifier; the updater's snapshot receipt). Re-created on every install.
55
56
  const INSTALLER_WRITTEN = new Set(['coverage-integrity.mjs', 'RUNTIME-IDENTITY.json', '.refresh-snapshot.json',
@@ -101,8 +101,12 @@ export function describeFailedRefreshRun(receipt, { reasonLimit = 200 } = {}) {
101
101
  const ledger = receipt.phases.map((entry) => entry?.phase);
102
102
  if (ledger.length > declared.length || ledger.some((phase, index) => phase !== declared[index])) return null;
103
103
  if (ledger.length === 0) return `failed before its first phase (${receipt.terminalVerdict || 'unknown'})`;
104
- const failing = [...receipt.phases].reverse().find((entry) => entry?.status !== 'PASS') || receipt.phases[receipt.phases.length - 1];
105
- const reason = String(failing.evidence?.updateResult?.reason ?? failing.evidence?.reason ?? '').split('\n')[0].trim();
104
+ const failing = receipt.phases.find((entry) => entry?.required !== false && entry?.status === 'FAIL')
105
+ || receipt.phases.find((entry) => entry?.status === 'FAIL')
106
+ || [...receipt.phases].reverse().find((entry) => entry?.status !== 'PASS')
107
+ || receipt.phases[receipt.phases.length - 1];
108
+ const reason = String(failing.evidence?.updateResult?.reason ?? failing.evidence?.error
109
+ ?? failing.evidence?.reason ?? '').split('\n')[0].trim();
106
110
  return reason ? `failed at ${failing.phase}: ${reason.slice(0, reasonLimit)}` : `failed at ${failing.phase}`;
107
111
  }
108
112
 
@@ -6,12 +6,14 @@ import { spawn, spawnSync } from 'node:child_process';
6
6
  import { fileURLToPath } from 'node:url';
7
7
  import { developmentHooksSuspended } from './development-maintenance.mjs';
8
8
  import { resolveTurnDb } from './turn-outcome-capture.mjs';
9
+ import { enrichStateWithObservation } from './project-progression-hook.mjs';
9
10
  import { buildProjectProgression } from './project-progression-producer.mjs';
10
11
  import { resolveProjectStore } from './project-store-resolver.mjs';
11
12
  import { redactProgression } from './project-progression-contract.mjs';
12
13
  import { ProgressionOutbox } from './project-progression-outbox.mjs';
13
14
  import { captureNormalizedTransition } from './project-transition-hook.mjs';
14
15
  import { boundedStoreFactory, runSessionSnapshotHook } from './session-snapshot-hook.mjs';
16
+ import { operatorProgressionSuspension } from './project-progression-suspension.mjs';
15
17
 
16
18
  /**
17
19
  * How long the detached worker may spend per step. The lock is refreshed between steps and goes stale
@@ -48,6 +50,7 @@ const mtimeOf = (projectDir, name) => { try { return fs.statSync(path.join(proje
48
50
  */
49
51
  export function queueCapture({ projectDir, originProjectDir = projectDir, event, host, payload, env = process.env }) {
50
52
  try {
53
+ if (operatorProgressionSuspension(env)) return null;
51
54
  const consent = resolveTurnDb({ projectDir: originProjectDir, requestedStorePath: path.join(projectDir, '.swarm', 'memory.db'),
52
55
  brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain') });
53
56
  if (consent.skipped) return null;
@@ -57,6 +60,10 @@ export function queueCapture({ projectDir, originProjectDir = projectDir, event,
57
60
  if (!progression && !payload?.normalizedTransition) {
58
61
  try { progression = buildProjectProgression({ resolution: resolveProjectStore({ projectDir: originProjectDir }), projectDir: originProjectDir, payload, host, trigger: event }).projectProgression; } catch { return null; }
59
62
  }
63
+ // Freeze the bounded native observation before discarding raw host input. Replay receives no
64
+ // tool fields, so the native writer cannot append it twice.
65
+ if (progression && (payload?.tool_name || payload?.tool_input)) progression = { ...progression,
66
+ completeProjectState: enrichStateWithObservation(progression.completeProjectState, { ...payload, hook_event_name: event }) };
60
67
  // This queue is durable: never serialize arbitrary host prompts, tool input or output.
61
68
  const minimized = { session_id: payload?.session_id, hook_event_name: event,
62
69
  ...(progression ? { projectProgression: progression } : {}),
@@ -227,13 +234,16 @@ export function releaseReplayLock(projectDir, token) {
227
234
  }
228
235
 
229
236
  /** Hand the lock (or take it, if free) to a detached worker. Returns whether one was started. Never throws. */
230
- export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn } = {}) {
237
+ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn, env = process.env } = {}) {
238
+ try {
239
+ if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return false; }
240
+ } catch { if (token) releaseReplayLock(projectDir, token); return false; }
231
241
  const held = token || takeReplayLock(projectDir);
232
242
  if (!held) return false;
233
243
  try {
234
244
  const child = spawnFn(process.execPath, [path.join(path.dirname(fileURLToPath(import.meta.url)), 'session-snapshot-hook.mjs'), '--replay-outbox'], {
235
245
  cwd: projectDir, detached: true, stdio: 'ignore', windowsHide: true,
236
- env: { ...process.env, RUVNET_REPLAY_LOCK_TOKEN: held },
246
+ env: { ...env, RUVNET_REPLAY_LOCK_TOKEN: held },
237
247
  });
238
248
  child.unref?.();
239
249
  return true;
@@ -254,10 +264,11 @@ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn
254
264
  */
255
265
  export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
256
266
  makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null,
257
- captureNormalized = captureNormalizedTransition, onCaptured = null } = {}) {
267
+ captureNormalized = captureNormalizedTransition, onCaptured = null, env = process.env } = {}) {
258
268
  const deadlineAt = now() + budgetMs;
259
269
  if (developmentHooksSuspended(projectDir)) return 0;
260
- const brainHome = process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
270
+ if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return 0; }
271
+ const brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
261
272
  try {
262
273
  const consent = resolveTurnDb({ projectDir, brainHome });
263
274
  if (consent.skipped && !consent.skipped.startsWith('no project memory db')) return 0;
@@ -269,14 +280,17 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
269
280
  if (!adoptReplayLock(projectDir, held)) return replayed;
270
281
  reclaimOrphans(projectDir);
271
282
  const resolution = resolveProjectStore({ projectDir });
272
- const store = makeStoreFactory(deadlineAt)({ projectDir, requestedStorePath: resolution.canonicalAgentDbPath });
283
+ const store = makeStoreFactory(deadlineAt)({ projectDir, env, requestedStorePath: resolution.canonicalAgentDbPath });
273
284
  for (const snapshot of store.outbox.pendingSnapshots()) {
285
+ if (operatorProgressionSuspension(env)) return replayed;
274
286
  if (now() >= deadlineAt) return replayed;
275
287
  if (!refreshReplayLock(projectDir, held)) return replayed;
276
- store.outbox.markCommitted(store.appendExact(snapshot));
288
+ store.captureFrozen(snapshot, { canCommit: () => refreshReplayLock(projectDir, held) });
289
+ // captureFrozen commits only the verified key, retaining conflicting original history.
277
290
  replayed += 1;
278
291
  }
279
292
  for (const file of queuedCaptures(projectDir)) {
293
+ if (operatorProgressionSuspension(env)) return replayed;
280
294
  if (now() >= deadlineAt) return replayed;
281
295
  if (!refreshReplayLock(projectDir, held)) return replayed;
282
296
  const claimed = claimQueued(file);
@@ -286,6 +300,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
286
300
  returnClaim(claimed);
287
301
  return replayed;
288
302
  }
303
+ if (operatorProgressionSuspension(env)) { returnClaim(claimed); return replayed; }
289
304
  let job = null;
290
305
  try { job = JSON.parse(fs.readFileSync(claimed, 'utf8')); } catch { /* torn: dropped below */ }
291
306
  let committed = false;
@@ -296,7 +311,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
296
311
  const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome });
297
312
  if (developmentHooksSuspended(job.originProjectDir || projectDir)
298
313
  || (consent.skipped && !consent.skipped.startsWith('no project memory db'))) { returnClaim(claimed); return replayed; }
299
- const options = { rawInput: JSON.stringify(job.payload), host: job.host,
314
+ const options = { rawInput: JSON.stringify(job.payload), host: job.host, env,
300
315
  budgetMs: Math.max(0, deadlineAt - now()), makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
301
316
  captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
302
317
  captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) };
@@ -321,6 +336,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
321
336
 
322
337
  /** Synchronous bounded startup drain. Pending debt must downgrade restore, never disappear. */
323
338
  export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } = {}) {
339
+ if (operatorProgressionSuspension(options.env || process.env)) return { state: 'suspended', replayed: 0, pending: null };
324
340
  const startedAt = Date.now();
325
341
  const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: Math.max(1, Math.min(300, budgetMs)) });
326
342
  const root = resolution.projectRoot;
@@ -330,4 +346,3 @@ export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } =
330
346
  const pending = queuedWork(root) + outboxPending;
331
347
  return { state: pending ? 'pending' : 'settled', replayed, pending };
332
348
  }
333
-
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { createProgressionSnapshot } from './project-progression-contract.mjs';
5
5
  import { ProjectProgressionStore } from './project-progression-store.mjs';
6
+ import { redactText } from './continuity-events.mjs';
6
7
 
7
8
  const HOSTS = new Set(['claude', 'codex']);
8
9
  const CAPTURE_TRIGGERS = new Set([
@@ -105,15 +106,17 @@ function toolAction(payload) {
105
106
  const exitCode = Number.isSafeInteger(explicitCode)
106
107
  ? explicitCode
107
108
  : textualCode ? Number(textualCode[1]) : undefined;
108
- const interrupted = responseRecord?.interrupted === true || responseRecord?.signal === 'SIGINT';
109
+ const error = boundedText(typeof responseRecord?.error === 'string' ? redactText(responseRecord.error) : undefined);
110
+ const signal = boundedText(typeof responseRecord?.signal === 'string' ? redactText(responseRecord.signal) : undefined);
111
+ const interrupted = responseRecord?.interrupted === true || Boolean(signal);
109
112
  const explicitError = responseRecord?.isError === true || payload.is_error === true;
110
113
  const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(responseRecord?.outcome)
111
114
  ? responseRecord.outcome : null;
112
- const failed = explicitError || (Number.isSafeInteger(exitCode) && exitCode !== 0);
115
+ const failed = explicitError || Boolean(error) || (Number.isSafeInteger(exitCode) && exitCode !== 0);
113
116
  const terminal = failed || Number.isSafeInteger(exitCode)
114
117
  || responseRecord?.success === true || responseRecord?.ok === true;
115
118
  const outcome = payload.hook_event_name === 'PostToolUse'
116
- ? (interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
119
+ ? (error ? 'failure' : interrupted ? 'interrupted' : failed ? 'failure' : declaredOutcome || (terminal ? 'success' : 'unknown'))
117
120
  : 'pending';
118
121
  const observation = {
119
122
  trigger: payload.hook_event_name,
@@ -121,7 +124,9 @@ function toolAction(payload) {
121
124
  ...(command ? { command } : {}),
122
125
  ...(filePath && !command ? { filePath } : {}),
123
126
  outcome,
124
- ...(Number.isSafeInteger(exitCode) ? { exitCode } : {}),
127
+ ...(Number.isSafeInteger(exitCode) ? { exitCode } : responseRecord?.exit_code === null || responseRecord?.exitCode === null ? { exitCode: null } : {}),
128
+ ...(error ? { error } : {}),
129
+ ...(signal ? { signal } : {}),
125
130
  ...(interrupted ? { interrupted: true } : {}),
126
131
  ...(explicitError ? { isError: true } : {}),
127
132
  };
@@ -137,7 +142,7 @@ function toolAction(payload) {
137
142
  return observation;
138
143
  }
139
144
 
140
- function enrichStateWithObservation(state, payload) {
145
+ export function enrichStateWithObservation(state, payload) {
141
146
  requireRecord(state, 'completeProjectState');
142
147
  const observation = toolAction(payload);
143
148
  if (!observation) return state;
@@ -164,6 +169,8 @@ export function captureProjectTransition({
164
169
  payload,
165
170
  projectDir,
166
171
  adapterVersion = readProgressionAdapterVersion(),
172
+ recoverFrozen = false,
173
+ canCommit,
167
174
  storeFactory = (options) => new ProjectProgressionStore(options),
168
175
  } = {}) {
169
176
  const normalizedHost = requireString(host, 'host').toLowerCase();
@@ -191,7 +198,7 @@ export function captureProjectTransition({
191
198
  aliased(progression, 'sourceIdentity', 'source_identity'),
192
199
  store.resolution.checkoutRoot, projectDir,
193
200
  );
194
- const snapshot = createProgressionSnapshot({
201
+ let snapshot = createProgressionSnapshot({
195
202
  projectIdentity: store.resolution.projectIdentity,
196
203
  sourceIdentity,
197
204
  hostIdentity: { host: normalizedHost, adapterVersion },
@@ -206,7 +213,9 @@ export function captureProjectTransition({
206
213
  payload,
207
214
  ),
208
215
  });
209
- const receipt = store.capture(snapshot);
216
+ let receipt;
217
+ if (recoverFrozen) ({ snapshot, receipt } = store.captureFrozen(snapshot, { canCommit }));
218
+ else receipt = store.capture(snapshot);
210
219
  verifyReceipt(snapshot, receipt);
211
220
  return { snapshot, receipt };
212
221
  }