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