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.
- package/README.md +15 -19
- package/bin/developer-update.mjs +3 -0
- package/bin/install.mjs +15 -3
- package/bin/nightly-refresh.mjs +17 -1
- package/config/practical-rule-catalog.json +111 -0
- package/console/CONTRACT.md +47 -3
- package/console/app.js +112 -20
- package/package.json +5 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +13 -1
- package/plugin/commands/configure.md +2 -2
- package/plugin/commands/rnb.md +13 -1
- package/plugin/commands/rnbc.md +13 -1
- package/plugin/commands/rvbc.md +13 -1
- package/plugin/commands/rvcb.md +13 -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/automatic-update.mjs +11 -2
- 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/continuation-gate.mjs +2 -1
- 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 +62 -37
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/developer-update-cleanup.mjs +38 -0
- package/plugin/scripts/developer-update-lock.mjs +30 -0
- package/plugin/scripts/developer-update-maintenance.mjs +178 -0
- package/plugin/scripts/developer-update-owner.mjs +24 -0
- package/plugin/scripts/developer-update-policy.mjs +48 -0
- package/plugin/scripts/developer-update.mjs +435 -0
- 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/host-update.mjs +6 -3
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/nightly-scheduler.mjs +55 -16
- package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
- 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/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/codex-managed-terminal.mjs +1 -1
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/console-suite-update.mjs +117 -0
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +30 -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/onboarding-console.mjs +41 -12
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/release-qualification-contract.mjs +14 -0
- package/scripts/remedy-registry.mjs +1 -0
- package/scripts/source-scope-receipt.mjs +9 -4
- 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
|
|
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 {
|
|
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
|
-
|
|
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.
|
|
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
|
-
...(
|
|
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
|
|
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'
|
|
93
|
-
:
|
|
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
|
|
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
|
-
|
|
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,
|
|
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: () =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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;
|