ruvnet-brain 4.5.16 → 4.5.17

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 (57) hide show
  1. package/README.md +1 -1
  2. package/bin/install.mjs +1 -1
  3. package/config/practical-rule-catalog.json +111 -0
  4. package/package.json +2 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/hooks/codex-hooks.json +13 -1
  8. package/plugin/hooks/hook-contracts.json +20 -4
  9. package/plugin/scripts/adr-currency-gate.mjs +75 -31
  10. package/plugin/scripts/advocacy-outcomes.mjs +18 -4
  11. package/plugin/scripts/agentdb-recall.mjs +161 -26
  12. package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
  13. package/plugin/scripts/capability-registry.mjs +55 -114
  14. package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
  15. package/plugin/scripts/codex-hook-adapter.mjs +33 -6
  16. package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
  17. package/plugin/scripts/continuity-brief.mjs +55 -22
  18. package/plugin/scripts/continuity-events.mjs +93 -17
  19. package/plugin/scripts/continuity-hook-policy.mjs +1 -1
  20. package/plugin/scripts/continuity-journal.mjs +77 -29
  21. package/plugin/scripts/decision-gate.mjs +63 -22
  22. package/plugin/scripts/decision-outcomes.mjs +192 -199
  23. package/plugin/scripts/detach.mjs +53 -13
  24. package/plugin/scripts/doc-currency.mjs +993 -0
  25. package/plugin/scripts/ground-ruvnet.sh +16 -3
  26. package/plugin/scripts/grounding-answer.mjs +18 -16
  27. package/plugin/scripts/grounding-stamp.sh +3 -0
  28. package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
  29. package/plugin/scripts/grounding-turn-gate.mjs +34 -29
  30. package/plugin/scripts/grounding-turn-mark.mjs +101 -17
  31. package/plugin/scripts/hook-context-budget.mjs +90 -0
  32. package/plugin/scripts/hook-registry.mjs +129 -1
  33. package/plugin/scripts/hook-shim.mjs +20 -2
  34. package/plugin/scripts/lesson-gate.mjs +1 -1
  35. package/plugin/scripts/project-capture-queue.mjs +22 -15
  36. package/plugin/scripts/project-progression-producer.mjs +15 -8
  37. package/plugin/scripts/project-progression-reader.mjs +14 -4
  38. package/plugin/scripts/project-progression-session-start.mjs +29 -17
  39. package/plugin/scripts/project-progression-sources.mjs +24 -10
  40. package/plugin/scripts/project-progression-store.mjs +7 -2
  41. package/plugin/scripts/project-store-resolver.mjs +10 -7
  42. package/plugin/scripts/project-transition-hook.mjs +20 -12
  43. package/plugin/scripts/session-snapshot-hook.mjs +20 -9
  44. package/plugin/scripts/session-start-budget.mjs +18 -0
  45. package/plugin/scripts/session-start-core.mjs +32 -6
  46. package/plugin/scripts/session-start-trace.mjs +2 -1
  47. package/plugin/scripts/turn-outcome-capture.mjs +7 -5
  48. package/plugin/scripts/unprompted-runtime.mjs +36 -10
  49. package/plugin/scripts/user-settings.mjs +7 -1
  50. package/scripts/doc-currency.mjs +6 -990
  51. package/scripts/hook-qualify-core.mjs +1 -1
  52. package/scripts/model-managed-prompt.mjs +1 -1
  53. package/scripts/model-managed-workflow-service.mjs +113 -14
  54. package/scripts/model-routing-controller.mjs +112 -21
  55. package/scripts/practical-rule-selector.mjs +51 -0
  56. package/scripts/product-integrity-contract.mjs +11 -4
  57. package/scripts/source-scope-receipt.mjs +9 -4
@@ -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;