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
@@ -26,12 +26,21 @@ const sha256 = (value) => crypto.createHash('sha256').update(value).digest('hex'
26
26
  /** A stable digest for "there is genuinely nothing to digest here", never an empty string. */
27
27
  const ABSENT_DIGEST = sha256('ruvnet-brain:absent');
28
28
 
29
- function git(cwd, args) {
29
+ function checkDeadline(deadlineAt, signal) {
30
+ if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
31
+ }
32
+ function git(cwd, args, deadlineAt = Infinity, signal) {
33
+ checkDeadline(deadlineAt, signal);
30
34
  try {
31
35
  return execFileSync('git', args, {
32
36
  cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], maxBuffer: 64 * 1024 * 1024,
37
+ ...(Number.isFinite(deadlineAt) ? { timeout: Math.max(1, Math.floor(deadlineAt - Date.now())), killSignal: 'SIGKILL' } : {}),
33
38
  });
34
- } catch { return null; }
39
+ } catch (error) {
40
+ checkDeadline(deadlineAt, signal);
41
+ if (error.code === 'ETIMEDOUT' && Number.isFinite(deadlineAt)) throw new Error('restore deadline exceeded');
42
+ return null;
43
+ }
35
44
  }
36
45
 
37
46
  /**
@@ -58,7 +67,8 @@ const SOURCE_PATHSPEC = ['--', '.', ...BRAIN_STATE_PATHSPEC_EXCLUDES];
58
67
  * digests describe a tree that existed at no single instant. A capture still happens — a slightly
59
68
  * smeared snapshot is worth far more than no snapshot — but it never claims to be atomic.
60
69
  */
61
- export function readSourceIdentity({ checkoutRoot, kind = 'git' } = {}) {
70
+ export function readSourceIdentity({ checkoutRoot, kind = 'git', deadlineAt = Infinity, signal } = {}) {
71
+ checkDeadline(deadlineAt, signal);
62
72
  const worktreeId = sha256(checkoutRoot);
63
73
  if (kind !== 'git') {
64
74
  // A non-git project has no index, no HEAD and no diff. Say that in the fields rather than
@@ -73,18 +83,22 @@ export function readSourceIdentity({ checkoutRoot, kind = 'git' } = {}) {
73
83
  };
74
84
  }
75
85
 
76
- const headBefore = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
77
- const branch = git(checkoutRoot, ['rev-parse', '--abbrev-ref', 'HEAD'])?.trim() || 'detached';
78
- const tracked = git(checkoutRoot, ['ls-files', '-s', ...SOURCE_PATHSPEC]);
79
- const untrackedList = git(checkoutRoot, ['ls-files', '--others', '--exclude-standard', ...SOURCE_PATHSPEC]);
80
- const diff = git(checkoutRoot, ['diff', 'HEAD', ...SOURCE_PATHSPEC]);
81
- const headAfter = git(checkoutRoot, ['rev-parse', 'HEAD'])?.trim() || 'unborn';
86
+ const headBefore = git(checkoutRoot, ['rev-parse', 'HEAD'], deadlineAt, signal)?.trim() || 'unborn';
87
+ const branch = git(checkoutRoot, ['rev-parse', '--abbrev-ref', 'HEAD'], deadlineAt, signal)?.trim() || 'detached';
88
+ const tracked = git(checkoutRoot, ['ls-files', '-s', ...SOURCE_PATHSPEC], deadlineAt, signal);
89
+ const untrackedList = git(checkoutRoot, ['ls-files', '--others', '--exclude-standard', ...SOURCE_PATHSPEC], deadlineAt, signal);
90
+ const diff = git(checkoutRoot, ['diff', 'HEAD', ...SOURCE_PATHSPEC], deadlineAt, signal);
91
+ const headAfter = git(checkoutRoot, ['rev-parse', 'HEAD'], deadlineAt, signal)?.trim() || 'unborn';
82
92
 
83
93
  const untrackedLines = String(untrackedList ?? '').split('\n').filter(Boolean).map((relative) => {
94
+ checkDeadline(deadlineAt, signal);
84
95
  let content;
96
+ if (Number.isFinite(deadlineAt) && fs.statSync(path.join(checkoutRoot, relative)).size > 64 * 1024 * 1024) throw new Error('source identity file exceeds bounded read');
85
97
  try { content = fs.readFileSync(path.join(checkoutRoot, relative)); } catch { return `unreadable ${relative}`; }
86
- return `${sha256(content)} ${relative}`;
98
+ const digest = sha256(content); checkDeadline(deadlineAt, signal);
99
+ return `${digest} ${relative}`;
87
100
  });
101
+ checkDeadline(deadlineAt, signal);
88
102
 
89
103
  return {
90
104
  identity: {
@@ -351,9 +351,13 @@ export class ProjectProgressionStore {
351
351
  // The read-only fast path (project-progression-reader.mjs). READS ONLY: `ruflo memory store`
352
352
  // stays the sole writer of memory.db. Pass `reader: null` to force every read through the CLI.
353
353
  reader = withProgressionReader,
354
+ deadlineAt = Infinity,
355
+ signal,
354
356
  } = {}) {
355
357
  if (!rufloBinary) throw new Error(RUFLO_MISSING);
356
- this.resolution = resolveProjectStore({ projectDir, requestedStorePath });
358
+ this.deadlineAt = deadlineAt;
359
+ this.signal = signal;
360
+ this.resolution = resolveProjectStore({ projectDir, requestedStorePath, deadlineAt });
357
361
  // Best effort: a cleanup that cannot run must never stop a capture or a restore.
358
362
  try { this.legacyDebris = cleanLegacyRufloDebris(path.dirname(this.resolution.canonicalAgentDbPath)); }
359
363
  catch (error) { this.legacyDebris = { removed: [], refused: [{ path: null, reason: error.message }] }; }
@@ -371,8 +375,9 @@ export class ProjectProgressionStore {
371
375
  * Structural errors propagate unchanged — only "I cannot answer authoritatively" falls back.
372
376
  */
373
377
  readFast(work) {
378
+ if (this.signal?.aborted || Date.now() >= this.deadlineAt) throw new Error('restore deadline exceeded');
374
379
  if (!this.reader) return { ok: false, reason: 'reader disabled' };
375
- return this.reader(this.resolution.canonicalAgentDbPath, work);
380
+ return this.reader(this.resolution.canonicalAgentDbPath, work, { deadlineAt: this.deadlineAt, signal: this.signal });
376
381
  }
377
382
 
378
383
  run(args) {
@@ -40,13 +40,16 @@ function isWithin(root, candidate) {
40
40
  return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
41
41
  }
42
42
 
43
- function gitValue(cwd, args, gitTimeoutMs) {
43
+ function gitValue(cwd, args, gitTimeoutMs, deadlineAt) {
44
+ const remaining = deadlineAt - Date.now();
45
+ if (remaining <= 0) throw new Error('restore deadline exceeded');
44
46
  try {
45
47
  return execFileSync('git', args, {
46
48
  cwd,
47
49
  encoding: 'utf8',
48
50
  stdio: ['ignore', 'pipe', 'ignore'],
49
- ...(gitTimeoutMs ? { timeout: gitTimeoutMs, killSignal: 'SIGKILL' } : {}),
51
+ ...(Number.isFinite(remaining) || gitTimeoutMs
52
+ ? { timeout: Math.max(1, Math.floor(Math.min(gitTimeoutMs || Infinity, remaining))), killSignal: 'SIGKILL' } : {}),
50
53
  }).trim();
51
54
  } catch (error) {
52
55
  // A timed-out Git identity check is not evidence for a non-git project.
@@ -55,9 +58,9 @@ function gitValue(cwd, args, gitTimeoutMs) {
55
58
  }
56
59
  }
57
60
 
58
- function gitProject(projectDir, gitTimeoutMs) {
59
- const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir'], gitTimeoutMs);
60
- const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel'], gitTimeoutMs);
61
+ function gitProject(projectDir, gitTimeoutMs, deadlineAt) {
62
+ const commonValue = gitValue(projectDir, ['rev-parse', '--path-format=absolute', '--git-common-dir'], gitTimeoutMs, deadlineAt);
63
+ const checkoutValue = gitValue(projectDir, ['rev-parse', '--show-toplevel'], gitTimeoutMs, deadlineAt);
61
64
  if (!commonValue || !checkoutValue) return null;
62
65
  const gitCommonDir = canonicalDirectory(commonValue, 'Git common directory');
63
66
  const checkoutRoot = canonicalDirectory(checkoutValue, 'Git checkout root');
@@ -71,10 +74,10 @@ function gitProject(projectDir, gitTimeoutMs) {
71
74
  };
72
75
  }
73
76
 
74
- export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath, gitTimeoutMs } = {}) {
77
+ export function resolveProjectStore({ projectDir = process.cwd(), requestedStorePath, gitTimeoutMs, deadlineAt = Infinity } = {}) {
75
78
  const canonicalInput = canonicalDirectory(projectDir, 'projectDir');
76
79
  if (gitTimeoutMs !== undefined && (!Number.isSafeInteger(gitTimeoutMs) || gitTimeoutMs <= 0)) throw new TypeError('gitTimeoutMs must be a positive integer');
77
- const git = gitProject(canonicalInput, gitTimeoutMs);
80
+ const git = gitProject(canonicalInput, gitTimeoutMs, deadlineAt);
78
81
  const resolved = git ?? {
79
82
  gitCommonDir: null,
80
83
  checkoutRoot: canonicalInput,
@@ -9,7 +9,7 @@ import { privateTransitionObservation } from './turn-capture-privacy.mjs';
9
9
  import { automaticProgressionSuspensionResult } from './project-progression-suspension.mjs';
10
10
  import { execFileSync } from 'node:child_process';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { redactText } from './continuity-events.mjs';
12
+ import { redactText, normalizeToolOutcome } from './continuity-events.mjs';
13
13
  import { conditionNotice } from './continuity-journal.mjs';
14
14
  import { normalizeHostEvent } from './hook-input.mjs';
15
15
  import { resolveProjectStore } from './project-store-resolver.mjs';
@@ -77,20 +77,22 @@ export function normalizeTransition(payload, event, { now = () => new Date().toI
77
77
  if (!MEANINGFUL_TOOLS.test(String(input.tool_name ?? ''))) return { skipped: 'non-material tool observation' };
78
78
  const toolInput = input.tool_input ?? {};
79
79
  const response = input.tool_response && typeof input.tool_response === 'object' ? input.tool_response : {};
80
- const exitCode = [response.exit_code, response.exitCode, response.status].find(Number.isSafeInteger);
80
+ const normalized = normalizeToolOutcome({ ...input, content: input.tool_response,
81
+ ...(event === 'PostToolUseFailure' ? { is_error: true } : {}) });
82
+ const exitCode = normalized.exitCode;
81
83
  const error = typeof response.error === 'string' && response.error ? redactText(response.error).slice(0, 4096) : undefined;
82
84
  const signal = typeof response.signal === 'string' && response.signal ? redactText(response.signal).slice(0, 100) : undefined;
83
- const failure = Boolean(error) || event === 'PostToolUseFailure' || response.isError === true || input.is_error === true || (Number.isSafeInteger(exitCode) && exitCode !== 0);
84
- const interrupted = response.interrupted === true || Boolean(signal);
85
- const declaredOutcome = ['success', 'failure', 'interrupted', 'unknown'].includes(response.outcome) ? response.outcome : null;
86
85
  // Measured native Claude Bash PostToolUse has no exit code. Its completion envelope
87
86
  // is host-reported success, never an inferred exit-code zero. Other hosts stay unknown.
88
87
  const claudeBashCompletion = host === 'claude' && input.tool_name === 'Bash' && event === 'PostToolUse'
89
88
  && typeof response.stdout === 'string' && typeof response.stderr === 'string'
90
89
  && response.interrupted === false && typeof response.isImage === 'boolean'
91
90
  && typeof response.noOutputExpected === 'boolean';
92
- const outcome = event === 'PreToolUse' ? 'pending' : error ? 'failure' : interrupted ? 'interrupted' : failure ? 'failure'
93
- : declaredOutcome || (Number.isSafeInteger(exitCode) || response.success === true || response.ok === true || claudeBashCompletion ? 'success' : 'unknown');
91
+ const outcome = event === 'PreToolUse' ? 'pending'
92
+ : normalized.outcome === 'fail' ? 'failure'
93
+ : ['interrupted', 'pending'].includes(normalized.outcome) ? normalized.outcome
94
+ : normalized.outcome === 'pass' || normalized.successfulToolResult ? 'success'
95
+ : !normalized.uncertain && (response.outcome === 'success' || claudeBashCompletion) ? 'success' : 'unknown';
94
96
  return { ...common, kind: 'tool-observation', tool: String(input.tool_name).split('__').at(-1).slice(0, 100),
95
97
  intent: semanticIntent(toolInput.description ?? toolInput.command ?? toolInput.cmd ?? toolInput.file_path), outcome,
96
98
  ...(outcome === 'success' && claudeBashCompletion ? { outcomeEvidence: 'claude-bash-completion' } : {}),
@@ -151,18 +153,23 @@ export function observeTransitionSource(resolution, projectDir = resolution.chec
151
153
 
152
154
  /** Called only by the fenced queue drainer; current heads merge, original observations stay fixed. */
153
155
  export function captureNormalizedTransition(job, { readHistory = readTransitionHistory, capture = runSessionSnapshotHook,
154
- budgetMs = 6500, makeStoreFactory, now = Date.now, env = process.env } = {}) {
156
+ budgetMs = 6500, makeStoreFactory, now = Date.now, env = process.env,
157
+ deadlineAt: inheritedDeadlineAt = Infinity, signal } = {}) {
155
158
  const suspended = automaticProgressionSuspensionResult(env);
156
159
  if (suspended) return suspended;
157
- const deadlineAt = now() + budgetMs;
158
- const resolution = resolveProjectStore({ projectDir: job.originProjectDir, gitTimeoutMs: Math.max(1, Math.min(500, budgetMs)) });
160
+ const deadlineAt = Math.min(inheritedDeadlineAt, now() + budgetMs);
161
+ const checkDeadline = () => { if (signal?.aborted || now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
162
+ checkDeadline();
163
+ const resolution = resolveProjectStore({ projectDir: job.originProjectDir, gitTimeoutMs: Math.max(1, Math.min(500, budgetMs)), deadlineAt });
159
164
  const normalized = job.payload.normalizedTransition;
160
165
  if (!normalized || normalized.observation?.authoritative !== false || !normalized.observation?.id
161
166
  || normalized.sourceIdentity?.checkoutPath !== resolution.checkoutRoot) throw new Error('invalid normalized transition binding');
162
- const policy = resolveTurnDb({ projectDir: job.originProjectDir, brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain') });
167
+ const policy = resolveTurnDb({ projectDir: job.originProjectDir, brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain'), deadlineAt, signal });
168
+ checkDeadline();
163
169
  if (policy.skipped) throw new Error(policy.skipped);
164
170
  if (digestCanonical(privateTransitionObservation(normalized.observation, policy.contentPathExcludes, job.originProjectDir)) !== digestCanonical(normalized.observation)) throw new Error('content exclusions changed; immutable transition retained');
165
171
  const snapshots = readHistory(resolution, { deadlineAt });
172
+ checkDeadline();
166
173
  const restored = restoreProjectProgression(snapshots, { expectedProjectIdentity: resolution.projectIdentity });
167
174
  if (snapshots.length && !restored.ok) {
168
175
  throw new Error('nonempty transition journal has no coherent ancestry');
@@ -181,7 +188,8 @@ export function captureNormalizedTransition(job, { readHistory = readTransitionH
181
188
  const progression = buildRestoredTransitionProgression({ resolution, observation: normalized.observation,
182
189
  snapshots, sessionIdentity: job.payload.session_id, host: job.host,
183
190
  sourceIdentity: normalized.sourceIdentity }, restored);
184
- const result = capture(job.originProjectDir, job.event, { host: job.host, env, budgetMs: Math.max(0, deadlineAt - now()),
191
+ checkDeadline();
192
+ const result = capture(job.originProjectDir, job.event, { host: job.host, env, budgetMs: Math.max(0, deadlineAt - now()), deadlineAt, signal,
185
193
  ...(makeStoreFactory ? { makeStoreFactory } : {}), writeMetadata: false,
186
194
  rawInput: JSON.stringify({ session_id: job.payload.session_id, hook_event_name: job.event, projectProgression: progression }),
187
195
  captureTurn: () => ({ recorded: false, skipped: 'transition boundary' }),
@@ -129,13 +129,19 @@ export function runSessionSnapshotHook(projectDir, event, {
129
129
  spawnReplay = replayOutboxDetached,
130
130
  ordered = null,
131
131
  captureEvents = captureContinuityEvents,
132
+ deadlineAt: inheritedDeadlineAt = Infinity,
133
+ signal,
132
134
  } = {}) {
135
+ const deadlineAt = Math.min(inheritedDeadlineAt, now() + budgetMs);
136
+ const checkDeadline = () => { if (signal?.aborted || now() >= deadlineAt) throw new Error('restore deadline exceeded'); };
133
137
  const suspended = (reason) => ({ metadataWritten: false, progressionCaptured: false, receipt: null, skipped: reason,
134
138
  turn: { event, recorded: false, skipped: reason }, continuity: { event, recorded: 0, launched: false, skipped: reason } });
135
139
  // Consent is checked before metadata, transcript inspection or any durable capture queue.
136
140
  try {
141
+ checkDeadline();
137
142
  const consent = resolveTurnDb({ projectDir, brainHome: env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain'),
138
- gitTimeoutMs: Math.max(1, Math.min(500, budgetMs)) });
143
+ gitTimeoutMs: Math.max(1, Math.min(500, deadlineAt - now())), deadlineAt, signal });
144
+ checkDeadline();
139
145
  if (consent.skipped) return suspended(consent.skipped);
140
146
  } catch (error) {
141
147
  return suspended(`capture consent unavailable: ${error.message}`);
@@ -148,14 +154,14 @@ export function runSessionSnapshotHook(projectDir, event, {
148
154
  // (a project without a store requires persisted opt-in — turn-outcome-capture.mjs).
149
155
  // It only reads and spawns a detached writer, so it costs the progression budget below nothing.
150
156
  let turn;
151
- try { turn = captureTurn({ projectDir, event, payload, host, env }); } catch (error) {
157
+ try { checkDeadline(); turn = captureTurn({ projectDir, event, payload, host, env, deadlineAt, signal }); checkDeadline(); } catch (error) {
152
158
  turn = { recorded: false, skipped: `turn capture failed: ${error.message}` };
153
159
  }
154
160
  // MATERIAL EVENTS (continuity-journal.mjs): commits, releases, gates, findings, decisions, lessons —
155
161
  // journalled to the durable outbox with one fsync and committed by a detached drainer. Like turn
156
162
  // capture it is independent of the progression lock below and costs this boundary only a read.
157
163
  let continuity;
158
- try { continuity = captureEvents({ projectDir, event, payload, host, env }); } catch (error) {
164
+ try { checkDeadline(); continuity = captureEvents({ projectDir, event, payload, host, env, deadlineAt, signal }); checkDeadline(); } catch (error) {
159
165
  continuity = { recorded: 0, skipped: `continuity capture failed: ${error.message}` };
160
166
  }
161
167
  const idle = { metadataWritten, progressionCaptured: false, receipt: null, turn, continuity };
@@ -163,11 +169,14 @@ export function runSessionSnapshotHook(projectDir, event, {
163
169
  if (operatorSuspended) return operatorSuspended;
164
170
 
165
171
  if (hasProjectProgression(payload)) {
172
+ checkDeadline();
166
173
  if (payload.hook_event_name !== event) {
167
174
  throw new Error(`progression boundary mismatch: expected ${event}, received ${payload.hook_event_name}`);
168
175
  }
169
176
  const result = captureProgression({ host, payload, projectDir, env, recoverFrozen: Boolean(ordered),
170
- canCommit: () => Boolean(ordered) && refreshReplayLock(resolveProjectStore({ projectDir }).projectRoot, ordered), storeFactory: (options) => makeStoreFactory(now() + budgetMs)({ ...options, env }) });
177
+ canCommit: () => now() < deadlineAt && !signal?.aborted && Boolean(ordered)
178
+ && refreshReplayLock(resolveProjectStore({ projectDir, deadlineAt }).projectRoot, ordered),
179
+ storeFactory: (options) => makeStoreFactory(deadlineAt)({ ...options, env, deadlineAt, signal }) });
171
180
  return { ...idle, progressionCaptured: true, receipt: result.receipt };
172
181
  }
173
182
 
@@ -178,7 +187,7 @@ export function runSessionSnapshotHook(projectDir, event, {
178
187
  }
179
188
 
180
189
  let resolution;
181
- try { resolution = resolveProjectStore({ projectDir }); } catch {
190
+ try { checkDeadline(); resolution = resolveProjectStore({ projectDir, deadlineAt }); } catch {
182
191
  return { ...idle, skipped: 'project store could not be resolved' };
183
192
  }
184
193
  if (!fs.existsSync(path.dirname(resolution.canonicalAgentDbPath))) {
@@ -206,7 +215,7 @@ export function runSessionSnapshotHook(projectDir, event, {
206
215
  let token = ordered;
207
216
  const handOff = (why) => {
208
217
  let frozen;
209
- try { frozen = produce({ resolution, projectDir, payload, host, env, trigger: event }); } catch { frozen = null; }
218
+ try { checkDeadline(); frozen = produce({ resolution, projectDir, payload, host, env, trigger: event, deadlineAt, signal }); checkDeadline(); } catch { frozen = null; }
210
219
  const queued = frozen?.projectProgression ? queueCapture({ projectDir: root, originProjectDir: projectDir, env, event, host,
211
220
  payload: { session_id: payload.session_id, hook_event_name: event, projectProgression: frozen.projectProgression } }) : null;
212
221
  const handed = queued ? spawnReplay({ projectDir: root, token, env }) : false;
@@ -228,8 +237,8 @@ export function runSessionSnapshotHook(projectDir, event, {
228
237
 
229
238
  let handedLock = false;
230
239
  try {
231
- const deadlineAt = now() + budgetMs;
232
- const storeFactory = (options) => makeStoreFactory(deadlineAt)({ ...options, env });
240
+ checkDeadline();
241
+ const storeFactory = (options) => makeStoreFactory(deadlineAt)({ ...options, env, deadlineAt, signal });
233
242
  let replayed = 0;
234
243
  if (budgetMs >= REPLAY_MIN_BUDGET_MS) {
235
244
  try {
@@ -247,7 +256,9 @@ export function runSessionSnapshotHook(projectDir, event, {
247
256
 
248
257
  let produced;
249
258
  try {
250
- produced = produce({ resolution, projectDir, payload, host, env, trigger: event });
259
+ checkDeadline();
260
+ produced = produce({ resolution, projectDir, payload, host, env, trigger: event, deadlineAt, signal });
261
+ checkDeadline();
251
262
  } catch (error) {
252
263
  return { ...idle, replayed, skipped: `producer failed: ${error.message}` };
253
264
  }
@@ -59,3 +59,21 @@ export function sumBudgetsMs(budgets = STAGE_BUDGETS_MS) {
59
59
  // constant, not folded silently into one stage's budget, so a boot-time regression shows up as its
60
60
  // own line instead of quietly eating an unrelated stage's headroom.
61
61
  export const MEASURED_NODE_BOOT_MS = 250;
62
+
63
+ /** Use the registered host envelope, including Codex's shorter outer launcher. */
64
+ export function sessionStartDeadlineAt({ host = 'claude', env = process.env, startedAt = Date.now(),
65
+ pluginRoot = path.resolve(HERE, '..') } = {}) {
66
+ const registry = path.join(pluginRoot, 'hooks', host === 'codex' ? 'codex-hooks.json' : 'hooks.json');
67
+ const hooks = JSON.parse(fs.readFileSync(registry, 'utf8'))?.hooks?.SessionStart;
68
+ const declaration = hooks?.flatMap(entry => entry.hooks || []).find(entry => /\bsession-start\b/.test(entry.command || ''));
69
+ if (!Number.isFinite(declaration?.timeout) || declaration.timeout <= 0) throw new Error('SessionStart host budget unavailable');
70
+ let envelope = declaration.timeout * 1000;
71
+ if (host === 'codex') {
72
+ const outer = /(?:^|\s)(\d+)\s+session-start(?:\s|$)/.exec(declaration.command);
73
+ if (!outer) throw new Error('SessionStart Codex launcher budget unavailable');
74
+ envelope = Math.min(envelope, Number(outer[1]));
75
+ }
76
+ const inherited = Number(env.RUVNET_SESSION_START_DEADLINE_AT);
77
+ return Math.floor(Math.min(startedAt + Math.max(1, envelope - MEASURED_NODE_BOOT_MS),
78
+ Number.isFinite(inherited) && inherited > 0 ? inherited : Infinity));
79
+ }
@@ -38,6 +38,8 @@ import { stableSpine, heartbeat, knowledgeAutoUpdate, footprintCheck } from './s
38
38
  import { FOOTPRINT_LINE_PREFIX } from './brain-confirmation.mjs';
39
39
  import { describeLifecycleHooks, readHookContracts } from './session-start-hook-description.mjs';
40
40
  import { createStageTracer } from './session-start-trace.mjs';
41
+ import { sessionStartDeadlineAt, STAGE_BUDGETS_MS } from './session-start-budget.mjs';
42
+ import { loadSettings } from './user-settings.mjs';
41
43
  import { sessionStartProofRecorder } from './session-start-proof.mjs';
42
44
  import { ALIAS_NOTICE, probeCodexConsoleAlias, readSessionSource } from './codex-console-alias.mjs';
43
45
 
@@ -93,7 +95,10 @@ export async function runSessionStart({
93
95
  runHeartbeat = true,
94
96
  sessionSource = 'startup',
95
97
  probeConsoleAlias = probeCodexConsoleAlias,
98
+ startedAt = Date.now(),
99
+ deadlineAt = Infinity,
96
100
  } = {}) {
101
+ const hookDeadlineAt = Math.min(deadlineAt, sessionStartDeadlineAt({ host: env.RUVNET_HOOK_HOST || 'claude', env, startedAt }));
97
102
  const lines = [];
98
103
  // SessionStart is context plumbing, not an instruction channel. Keep factual, actionable
99
104
  // health/CI/issue signals and the single status line; suppress response scripts, setup
@@ -185,15 +190,31 @@ export async function runSessionStart({
185
190
  };
186
191
 
187
192
  const restoreStart = Date.now();
193
+ const restoreDeadlineAt = Math.min(hookDeadlineAt, restoreStart + STAGE_BUDGETS_MS.restore);
194
+ const restoreController = new AbortController();
195
+ let restoreTimer;
188
196
  let restoreFailed = false;
189
197
  try {
190
- const continuity = await restoreContinuity({ env, cwd });
198
+ const remaining = restoreDeadlineAt - Date.now();
199
+ if (remaining <= 0) throw new Error('restore deadline exhausted');
200
+ const expired = new Promise((_, reject) => {
201
+ restoreTimer = setTimeout(() => {
202
+ restoreController.abort(); reject(new Error('restore deadline exceeded'));
203
+ }, remaining);
204
+ });
205
+ const continuity = await Promise.race([Promise.resolve().then(() => restoreContinuity({ env, cwd,
206
+ deadlineAt: restoreDeadlineAt, signal: restoreController.signal })), expired]);
207
+ if (Date.now() >= restoreDeadlineAt) throw new Error('restore deadline exceeded');
208
+ restoreFailed = continuity?.status === 'unknown' || continuity?.brief?.status === 'unknown';
191
209
  if (continuity?.context) emit(continuity.context);
192
- } catch {
210
+ } catch (error) {
211
+ restoreController.abort();
193
212
  restoreFailed = true;
194
213
  emit('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]');
195
- emit('The SessionStart restore boundary failed unexpectedly. Do not claim project state was restored; verify the canonical store before relying on remembered state.');
196
- }
214
+ emit(/deadline/i.test(error?.message || '')
215
+ ? 'The SessionStart restore deadline expired. Durable work is retained; no project restoration is claimed.'
216
+ : 'The SessionStart restore boundary failed unexpectedly. Do not claim project state was restored; verify the canonical store before relying on remembered state.');
217
+ } finally { clearTimeout(restoreTimer); }
197
218
  const restoreProof = { name: 'restore', ms: Date.now() - restoreStart, failed: restoreFailed };
198
219
  // Turn health remains visible even when the separate progression restore fails.
199
220
  const turnStatus = turnRecordingStatus({ projectDir: cwd, env, home, noticeOnFirstUse: true });
@@ -209,6 +230,7 @@ export async function runSessionStart({
209
230
  }
210
231
 
211
232
  const tracer = createStageTracer({
233
+ deadlineAt: hookDeadlineAt,
212
234
  enabled: env.RUVNET_BRAIN_SESSION_START_TRACE === '1',
213
235
  write: (chunk) => stderr.write(chunk),
214
236
  });
@@ -320,7 +342,10 @@ export async function runSessionStart({
320
342
 
321
343
  tracer.stage('misc', () => {
322
344
  const star = path.join(stateDir, '.star-ask-shown');
323
- if (!brain.off && exists(path.join(stateDir, '.grounded-once')) && !exists(star) && write(star, '')) {
345
+ const preferences = loadSettings(env.RUVNET_SETTINGS_FILE || path.join(home, '.config', 'ruvnet-brain', 'settings.json'));
346
+ const promotionAllowed = preferences.healthy && !preferences.fromFuture && preferences.values.brainEnabled
347
+ && [4, 5].includes(preferences.values.advocacy);
348
+ if (promotionAllowed && !brain.off && exists(path.join(stateDir, '.grounded-once')) && !exists(star) && write(star, '')) {
324
349
  emit('[RuvNet Brain — one-time note. This will NEVER be shown again on this machine.]');
325
350
  emit('At a natural CLOSING moment (after the work, never as an interruption), relay this line once and move on — never repeated, never embellished, in this or any future session:');
326
351
  emit(' "Finding this useful? Star github.com/stuinfla/ruvnet-brain or leave feedback — it keeps the nightly updates coming."');
@@ -438,7 +463,8 @@ function isDirectInvocation() {
438
463
  const direct = isDirectInvocation();
439
464
  if (direct) {
440
465
  const sessionSource = await readSessionSource();
441
- runSessionStart({ sessionSource }).catch((error) => {
466
+ runSessionStart({ sessionSource, startedAt: performance.timeOrigin }).catch((error) => {
467
+ process.stderr.write('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN] SessionStart could not complete; no restoration success is claimed.\n');
442
468
  if (process.env.RUVNET_SESSION_TRACE === '1') {
443
469
  process.stderr.write(`SESSION_TRACE native-fail-open ${error?.message || error}\n`);
444
470
  }
@@ -33,6 +33,7 @@ export function createStageTracer({
33
33
  write = () => {},
34
34
  budgets = STAGE_BUDGETS_MS,
35
35
  deadlineMs = sumBudgetsMs(budgets) - (budgets.restore || 0),
36
+ deadlineAt,
36
37
  } = {}) {
37
38
  const stages = [];
38
39
  const bodyStart = Date.now();
@@ -52,7 +53,7 @@ export function createStageTracer({
52
53
  * "assume the worst case" posture the hooks.json timeout itself takes. */
53
54
  const wouldExceedDeadline = (name) => {
54
55
  const budget = budgets[name] ?? 0;
55
- return (Date.now() - bodyStart) + budget > deadlineMs;
56
+ return Date.now() + budget > (deadlineAt ?? bodyStart + deadlineMs);
56
57
  };
57
58
 
58
59
  const stage = (name, fn) => {
@@ -162,8 +162,10 @@ const consentMap = (value) => value !== null && typeof value === 'object'
162
162
  && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype
163
163
  && Object.values(value).every((setting) => setting === 'on' || setting === 'off');
164
164
 
165
- export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false } = {}) {
166
- const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs });
165
+ export function resolveTurnDb({ projectDir, brainHome, requestedStorePath, gitTimeoutMs = 1000, unknownOriginalPath = false, deadlineAt = Infinity, signal } = {}) {
166
+ if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
167
+ const resolved = resolveProjectStore({ projectDir, requestedStorePath, gitTimeoutMs, deadlineAt });
168
+ if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
167
169
  const capturePath = fs.realpathSync.native(projectDir);
168
170
  let policy = {};
169
171
  const file = brainHome && turnCapturePolicyFile(brainHome);
@@ -286,9 +288,9 @@ export function captureTurnOutcome({
286
288
  // the owner's and is never edited by the product, so where it is registered the product DEFERS
287
289
  // (RUVNET_TURN_CAPTURE=force keeps both). Codex turns are not seen by that Claude-only hook.
288
290
  const deferTo = event === 'Stop' && host === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force'
289
- && userLevelAgentdbHooks({ home }).turnCapture;
291
+ && userLevelAgentdbHooks({ home, event, projectDir, env }).turnCapture;
290
292
  if (deferTo) {
291
- report.skipped = 'deferred: the user-level ~/.claude/hooks/agentdb-turn-capture.mjs records this turn (one writer per turn)';
293
+ report.skipped = 'deferred: an enabled user-level Stop writer explicitly targets the canonical turn store; actual native delivery is unproven';
292
294
  report.deferredToUserLevel = true;
293
295
  } else if (event === 'Stop') {
294
296
  const sessionKey = String(payload.session_id || payload.transcript_path || '');
@@ -471,7 +473,7 @@ export function turnRecordingStatus({ projectDir = process.cwd(), env = process.
471
473
  let target;
472
474
  try { target = resolveTurnDb({ projectDir, brainHome }); } catch { return { state: 'unknown', line: 'turn recording unavailable: canonical store resolution failed' }; }
473
475
  if (String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() === 'off' || target.skipped) return { state: 'unknown', line: `turn recording n/a — ${target.skipped || 'RUVNET_TURN_CAPTURE=off'}` };
474
- const deferred = String(env.RUVNET_HOOK_HOST || 'claude') === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force' && userLevelAgentdbHooks({ home }).turnCapture;
476
+ const deferred = String(env.RUVNET_HOOK_HOST || 'claude') === 'claude' && String(env.RUVNET_TURN_CAPTURE || '').toLowerCase() !== 'force' && userLevelAgentdbHooks({ home, event: 'Stop', projectDir, env }).turnCapture;
475
477
  const notice = noticeOnFirstUse && !deferred ? firstTurnCaptureNotice({ target, brainHome, policyFile: turnCapturePolicyFile(brainHome), env }) : null;
476
478
  const status = (value) => ({ ...value, ...(notice ? { notice } : {}) });
477
479
  let rows = [];
@@ -83,6 +83,7 @@
83
83
  // ─────────────────────────────────────────────────────────────────────────────────────────────────
84
84
 
85
85
  import fs from 'node:fs';
86
+ import { contextBlock, contextFrame, emitOwnedContext, selectContextFrame, recordContextBudget } from './hook-context-budget.mjs';
86
87
  import path from 'node:path';
87
88
  import { spawnSync } from 'node:child_process';
88
89
  import { fileURLToPath, pathToFileURL } from 'node:url';
@@ -169,7 +170,7 @@ const lesson = (subEvent) => ({ argv: [BASH, path.join(SCRIPTS_DIR, 'lesson-hook
169
170
  const ADVOCACY_ROUTE = { argv: [process.execPath, path.join(SCRIPTS_DIR, 'advocacy-route.mjs')], feedStdin: true, channels: ['advocacy'] };
170
171
 
171
172
  const BUILTIN_REGISTRY = {
172
- 'UserPromptSubmit': [ANTICIPATE, ADVOCACY_ROUTE, lesson('UserPromptSubmit')],
173
+ 'UserPromptSubmit': [lesson('UserPromptSubmit'), ANTICIPATE, ADVOCACY_ROUTE],
173
174
  'PreToolUse-write': [lesson('PreToolUse-write')],
174
175
  'PreToolUse-bash': [lesson('PreToolUse-bash')],
175
176
  'PreToolUse-push': [lesson('PreToolUse-push')],
@@ -273,6 +274,12 @@ for (const p of producers) {
273
274
  timeout: remaining,
274
275
  maxBuffer: MAX_BUFFER,
275
276
  });
277
+ // Never forward producer diagnostics verbatim: lesson failures may include private paths.
278
+ // Preserve the fact that memory degraded instead of presenting it as an empty healthy store.
279
+ if (p.channels.includes('lesson') && (r.error || r.signal || r.status !== 0 || r.stderr?.length)) {
280
+ rawLines.push({ s: JSON.stringify({ channel: 'lesson', effect: 'advisory',
281
+ hookEventName: event, copy: 'Project lessons could not be fully loaded. Learning context is incomplete; verify relevant project decisions before consequential changes.' }), channels: ['lesson'] });
282
+ }
276
283
  // FAIL CLOSED (GPT-5.6-Sol): a producer that errored, exited non-zero, was signalled (a timeout kill),
277
284
  // or overflowed maxBuffer has UNTRUSTWORTHY partial output — discard it, never deliver a fragment.
278
285
  if (!r.error && r.status === 0 && !r.signal && r.stdout) out = r.stdout.toString('utf8');
@@ -296,7 +303,7 @@ for (const { s, channels } of rawLines) {
296
303
  // not authorised for this channel → drop
297
304
  if (!VALID_EFFECTS.has(c.effect)) continue; // unknown/absent effect → drop
298
305
  if (typeof c.copy !== 'string' || !c.copy.trim()) continue; // nothing to say → drop
299
- c.copy = c.copy.slice(0, MAX_COPY); // cap size; delivery below is synchronous
306
+ if (process.env.RUVNET_HOOK_CONTEXT_BUDGET !== '1') c.copy = c.copy.slice(0, MAX_COPY); // cap size; delivery below is synchronous
300
307
  candidates.push(c);
301
308
  }
302
309
  if (!candidates.length) silent();
@@ -338,11 +345,13 @@ async function advocacyLevel() {
338
345
  try {
339
346
  const m = await import(pathToFileURL(SETTINGS_MODULE).href);
340
347
  if (typeof m.loadSettings === 'function') {
341
- const v = m.loadSettings().values?.advocacy; // loadSettings already migrates legacy strings → 1-5
348
+ const settings = m.loadSettings();
349
+ if (!settings.healthy || settings.fromFuture || settings.errors?.length) return (_level = 1);
350
+ const v = settings.values?.advocacy; // loadSettings already migrates legacy strings → 1-5
342
351
  const n = typeof v === 'number' ? v : LEGACY_LEVEL[v];
343
352
  if (Number.isInteger(n) && n >= 1 && n <= 5) _level = n;
344
353
  }
345
- } catch { /* keep the safe default */ }
354
+ } catch { _level = 1; } // Unavailable preferences cannot authorize optional speech.
346
355
  return _level;
347
356
  }
348
357
  async function policy() { return LEVEL_POLICY[await advocacyLevel()] || LEVEL_POLICY[3]; }
@@ -363,6 +372,15 @@ async function ledger() {
363
372
 
364
373
  // ── Apply per-channel policy ───────────────────────────────────────────────────────────────────────
365
374
  const advisories = []; // { copy, hookEventName }
375
+ const budgetDeferredIds = [];
376
+ const framedBlock = (a) => contextBlock(a.copy, { id: a.channel, critical: a.channel === 'alarm' });
377
+ const hasAdvisoryRoom = (copy, channel) => {
378
+ if (process.env.RUVNET_HOOK_CONTEXT_BUDGET !== '1' || channel === 'alarm') return true;
379
+ const candidate = framedBlock({ copy, channel });
380
+ const selected = selectContextFrame(contextFrame('unprompted-speech', EVENT.split('-')[0], [...advisories.map(framedBlock), candidate]));
381
+ if (selected.frame.blocks.some((block) => block.id === candidate.id)) return true;
382
+ budgetDeferredIds.push(candidate.id); return false;
383
+ };
366
384
  const blocks = []; // reason strings
367
385
 
368
386
  for (const c of candidates) {
@@ -373,7 +391,7 @@ for (const c of candidates) {
373
391
  case 'alarm':
374
392
  // Always delivered, never gated. Alarms inform; they do not refuse — a stray block effect on an
375
393
  // alarm is treated as advisory rather than allowed to refuse the user's work.
376
- advisories.push({ copy, hookEventName });
394
+ if (hasAdvisoryRoom(copy, c.channel)) advisories.push({ copy, hookEventName, channel: c.channel });
377
395
  break;
378
396
 
379
397
  case 'lesson':
@@ -383,7 +401,7 @@ for (const c of candidates) {
383
401
  // and never swallows one.
384
402
  blocks.push(copy);
385
403
  } else {
386
- advisories.push({ copy, hookEventName });
404
+ if (hasAdvisoryRoom(copy, c.channel)) advisories.push({ copy, hookEventName, channel: c.channel });
387
405
  }
388
406
  break;
389
407
 
@@ -393,7 +411,7 @@ for (const c of candidates) {
393
411
  // violation → drop it.
394
412
  if (c.effect !== 'advisory') break;
395
413
  if (!(await policy()).promotion) break; // levels 1–3 ⇒ no promotion nudges
396
- advisories.push({ copy, hookEventName });
414
+ if (hasAdvisoryRoom(copy, c.channel)) advisories.push({ copy, hookEventName, channel: c.channel });
397
415
  break;
398
416
  }
399
417
 
@@ -414,6 +432,7 @@ for (const c of candidates) {
414
432
  let offer = true;
415
433
  try { offer = led.shouldStillOffer(findingId, { severity, stateHash }); } catch { offer = false; }
416
434
  if (!offer) break; // dismissed / budget spent → drop
435
+ if (!hasAdvisoryRoom(copy, c.channel)) break;
417
436
  // Persist the OFFERED denominator before delivery. A recommendation whose delivery receipt was
418
437
  // not durably written cannot participate in the later applied/dismissed lifecycle; emitting it
419
438
  // anyway would create a card the next prompt cannot resolve and would make precision lie.
@@ -421,7 +440,7 @@ for (const c of candidates) {
421
440
  try { receipt = led.record({ id: findingId, action: led.ACTIONS.OFFERED, severity, stateHash }); }
422
441
  catch { receipt = null; }
423
442
  if (!receipt?.ok) break;
424
- advisories.push({ copy, hookEventName });
443
+ advisories.push({ copy, hookEventName, channel: c.channel });
425
444
  break;
426
445
  }
427
446
 
@@ -439,7 +458,11 @@ if (blocks.length) {
439
458
  process.exit(2);
440
459
  }
441
460
 
442
- if (!advisories.length) silent();
461
+ if (!advisories.length && !budgetDeferredIds.length) silent();
462
+ if (budgetDeferredIds.length) {
463
+ recordContextBudget({ handler: 'unprompted-speech', event: EVENT.split('-')[0], scope: 'pre-offer-budget-deferral', deferredCount: budgetDeferredIds.length, deferredIds: budgetDeferredIds.slice(0, 8) });
464
+
465
+ }
443
466
 
444
467
  // hookEventName MUST name the firing CC event or the harness discards the envelope. DERIVE it from the
445
468
  // runtime's own EVENT (GPT-5.6-Sol) rather than trusting a producer-stamped value — the runtime knows the
@@ -447,7 +470,10 @@ if (!advisories.length) silent();
447
470
  const hookEventName = EVENT ? EVENT.split('-')[0] : 'UserPromptSubmit';
448
471
 
449
472
  // SYNCHRONOUS write — the truncation fix on the byte-owner itself (see the block path above).
473
+ if (!advisories.length) silent();
450
474
  const additionalContext = advisories.map((a) => a.copy).join('\n\n');
451
- try { fs.writeSync(1, JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } })); }
475
+ try { fs.writeSync(1, process.env.RUVNET_HOOK_CONTEXT_BUDGET === '1'
476
+ ? emitOwnedContext(contextFrame('unprompted-speech', hookEventName, advisories.map((a) => contextBlock(a.copy, { id: a.channel, critical: a.channel === 'alarm' }))))
477
+ : JSON.stringify({ hookSpecificOutput: { hookEventName, additionalContext } })); }
452
478
  catch { /* if even the final write fails, silence is the fail-safe */ }
453
479
  process.exit(0);
@@ -355,7 +355,13 @@ export function loadSettings(file = STORE_PATH) {
355
355
  const envelope = { path: file, exists: false, healthy: true, fromFuture: false, values: defaults(), errors: [], warnings: [] };
356
356
  let raw;
357
357
  try { raw = fs.readFileSync(file, 'utf8'); }
358
- catch { return envelope; } // no file yet is the NORMAL state, not a fault — empty-first, house rule 3
358
+ catch (error) {
359
+ if (error.code !== 'ENOENT') {
360
+ envelope.healthy = false;
361
+ envelope.errors.push({ key: null, reason: 'settings file unavailable — preferences unknown' });
362
+ }
363
+ return envelope; // A missing file is normal; an unreadable file is not consent.
364
+ }
359
365
 
360
366
  envelope.exists = true;
361
367
  let parsed;