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
|
@@ -18,11 +18,12 @@
|
|
|
18
18
|
* tests/unit/ruvnet-gate1-pattern.test.mjs. ground-ruvnet.sh itself is UNTOUCHED by this file — it
|
|
19
19
|
* is a hot, heavily-tuned, every-prompt hook, and this feature does not need to change it.
|
|
20
20
|
*
|
|
21
|
-
* WHAT THIS WRITES: a marker
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
21
|
+
* WHAT THIS WRITES: a per-host/session marker carrying a random episode nonce, typed native identity,
|
|
22
|
+
* and a digest of the real project path. Same-turn queued prompts merge without changing the nonce;
|
|
23
|
+
* an observed new native turn or project replaces the episode. PostToolUse records successful search
|
|
24
|
+
* receipts through this same module and lock. Receipts retain only product terms and query/answer
|
|
25
|
+
* hashes. Stop consumes the marker and its bound receipt together; shared 24h freshness is not proof
|
|
26
|
+
* that this session searched during this turn.
|
|
26
27
|
*
|
|
27
28
|
* 2026-09-30 — TWO ARMS, ONE MARKER (ADR-0030 decision point #1). The marker now also records, as
|
|
28
29
|
* JSON content, whether the prompt ASKS for a capability / feasibility / architecture judgement about
|
|
@@ -52,9 +53,11 @@ import path from 'node:path';
|
|
|
52
53
|
import { fileURLToPath } from 'node:url';
|
|
53
54
|
import { readStdinBounded, isHarnessGenerated } from './hook-input.mjs';
|
|
54
55
|
import { ruvnetGate1Matches, groundingScopeMatches, groundingSubjectAllowed, normalizeGroundingScope, mergeGroundingScopes } from './ruvnet-gate1-pattern.mjs';
|
|
55
|
-
import { randomUUID } from 'node:crypto';
|
|
56
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
56
57
|
import { loadSettings, writeAtomic } from './user-settings.mjs';
|
|
57
58
|
import { classifyPrompt, loadVocabulary } from './grounding-turn-evidence.mjs';
|
|
59
|
+
import { answerOf, brainAnsweredResponse } from './grounding-answer.mjs';
|
|
60
|
+
import { RUVNET_GATE1_TERMS } from './ruvnet-gate1-pattern.mjs';
|
|
58
61
|
|
|
59
62
|
const HOME = os.homedir();
|
|
60
63
|
export const MARKER_DIR = process.env.RUVNET_GROUNDING_TURN_DIR
|
|
@@ -63,9 +66,10 @@ export const MARKER_DIR = process.env.RUVNET_GROUNDING_TURN_DIR
|
|
|
63
66
|
/** Filesystem-safe key for a session id — mirrors continuation-gate.mjs's own `replace(':', '-')`
|
|
64
67
|
* idiom, generalised: a session id is host-supplied and must never be trusted as a bare path
|
|
65
68
|
* segment. */
|
|
66
|
-
export function markerPathFor(sessionId, dir = MARKER_DIR) {
|
|
69
|
+
export function markerPathFor(sessionId, dir = MARKER_DIR, host = 'claude') {
|
|
70
|
+
if (!['claude', 'codex'].includes(host)) return null;
|
|
67
71
|
const safe = String(sessionId ?? '').replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, 200);
|
|
68
|
-
return safe ? path.join(dir, `${safe}.json`) : null;
|
|
72
|
+
return safe ? path.join(dir, `${host}-${safe}.json`) : null;
|
|
69
73
|
}
|
|
70
74
|
|
|
71
75
|
const promptOf = (hookInput) => String(hookInput?.prompt ?? hookInput?.user_prompt ?? hookInput?.input ?? '');
|
|
@@ -111,7 +115,8 @@ export function readMarker(file, { locked = false } = {}) {
|
|
|
111
115
|
if (!m || typeof m !== 'object') return conservativeArm();
|
|
112
116
|
return { gate1: uncertain || m.gate1 !== false, assert: !!m.assert, architecture: !!m.architecture,
|
|
113
117
|
groundingScope: uncertain ? 'all' : normalizeGroundingScope(m.groundingScope ?? 'all').value,
|
|
114
|
-
subjects: Array.isArray(m.subjects) ? m.subjects.map(String) : [], at: m.at
|
|
118
|
+
subjects: Array.isArray(m.subjects) ? m.subjects.map(String) : [], at: m.at,
|
|
119
|
+
host: m.host, nativeKind: m.nativeKind, sessionId: m.sessionId, turnId: m.turnId, projectId: m.projectId, nonce: uncertain ? null : m.nonce };
|
|
115
120
|
} catch (e) { return uncertain || e.code !== 'ENOENT' ? conservativeArm() : null; }
|
|
116
121
|
}
|
|
117
122
|
|
|
@@ -152,11 +157,16 @@ export function writeArm(file, arm, meta = {}, now = Date.now()) {
|
|
|
152
157
|
let st = null;
|
|
153
158
|
try { st = fs.lstatSync(file); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
154
159
|
const prior = readMarker(file, { locked: true });
|
|
155
|
-
|
|
156
|
-
|
|
160
|
+
// Unknown native identity cannot inherit a proved episode or its search evidence.
|
|
161
|
+
// Pure scope-only callers may merge obligations, but carry no native identity to certify.
|
|
162
|
+
const scopeOnly = (value) => ['host', 'nativeKind', 'sessionId', 'turnId', 'projectId'].every(key => value?.[key] == null);
|
|
163
|
+
const sameEpisode = !prior || sameGroundingIdentity(prior, meta) || (scopeOnly(prior) && scopeOnly(meta));
|
|
164
|
+
const prev = prior && sameEpisode && (!st || now - st.mtimeMs < STALE_MS) ? prior : null;
|
|
165
|
+
const next = prev ? { ...meta, sessionId: prev.sessionId || meta.sessionId, turnId: prev.turnId || meta.turnId,
|
|
166
|
+
projectId: prev.projectId || meta.projectId, nonce: prev.nonce || randomUUID(), at: prev.at, gate1: prev.gate1 || arm.gate1, assert: prev.assert || arm.assert,
|
|
157
167
|
architecture: prev.architecture || arm.architecture, groundingScope: mergeGroundingScopes(prev.groundingScope, arm.groundingScope),
|
|
158
168
|
subjects: [...new Set([...prev.subjects, ...arm.subjects])].slice(0, 32) }
|
|
159
|
-
: { ...meta, at: new Date(now).toISOString(), ...arm };
|
|
169
|
+
: { ...meta, nonce: randomUUID(), at: new Date(now).toISOString(), ...arm };
|
|
160
170
|
if (!owns()) throw new Error('marker owner changed');
|
|
161
171
|
const staged = `${file}.staged-${randomUUID()}`;
|
|
162
172
|
try {
|
|
@@ -165,6 +175,7 @@ export function writeArm(file, arm, meta = {}, now = Date.now()) {
|
|
|
165
175
|
fs.renameSync(staged, file);
|
|
166
176
|
} finally { try { fs.unlinkSync(staged); } catch {} }
|
|
167
177
|
if (prev && st && owns()) fs.utimesSync(file, st.atime, st.mtime);
|
|
178
|
+
if (!prev && prior?.nonce) retireSearchEvidence(file, prior);
|
|
168
179
|
return next;
|
|
169
180
|
});
|
|
170
181
|
} catch { return failSafeArm(file); }
|
|
@@ -172,7 +183,7 @@ export function writeArm(file, arm, meta = {}, now = Date.now()) {
|
|
|
172
183
|
}
|
|
173
184
|
|
|
174
185
|
/** Stop reads and consumes the same episode under the same exclusive protocol. */
|
|
175
|
-
export function consumeMarker(file) {
|
|
186
|
+
export function consumeMarker(file, expected = null) {
|
|
176
187
|
// No episode directory is ordinary silence, not a lock failure or a grounding obligation.
|
|
177
188
|
try { fs.lstatSync(path.dirname(file)); } catch (e) { if (e.code === 'ENOENT') return null; }
|
|
178
189
|
let held;
|
|
@@ -182,37 +193,110 @@ export function consumeMarker(file) {
|
|
|
182
193
|
if (observedLatches === null) throw new Error('marker latch read failed');
|
|
183
194
|
const marker = readMarker(file, { locked: true });
|
|
184
195
|
if (!marker) return null;
|
|
196
|
+
// An old native Stop must not retire a newer prompt's episode.
|
|
197
|
+
if (expected && ['host', 'nativeKind', 'sessionId', 'turnId', 'projectId'].some(key =>
|
|
198
|
+
marker[key] && expected[key] && marker[key] !== expected[key])) return null;
|
|
185
199
|
let markerMs = Date.now();
|
|
186
200
|
try { markerMs = fs.lstatSync(file).mtimeMs; } catch {}
|
|
201
|
+
// An unbound Stop cannot prove ownership of a fully typed episode. Preserve its proof,
|
|
202
|
+
// while exposing an UNKNOWN observation for this stop rather than consuming the successor.
|
|
203
|
+
if (expected && sameGroundingIdentity(marker, marker) && !sameGroundingIdentity(expected, expected)) {
|
|
204
|
+
return { marker, markerMs, ownershipUnknown: true };
|
|
205
|
+
}
|
|
187
206
|
// Suspicious sidecars remain in place; never follow or retire their targets.
|
|
188
207
|
if (observedLatches.some((flag) => fs.lstatSync(flag).isSymbolicLink())) return { marker: conservativeArm(), markerMs };
|
|
189
208
|
if (!owns()) throw new Error('marker owner changed');
|
|
209
|
+
const searchEvidence = readSearchEvidence(file, marker);
|
|
190
210
|
if (exists(file)) fs.unlinkSync(file);
|
|
211
|
+
retireSearchEvidence(file, marker);
|
|
191
212
|
// Consume only witnessed latches; a contender publishes a new unique obligation.
|
|
192
213
|
for (const flag of observedLatches) fs.unlinkSync(flag);
|
|
193
|
-
return { marker, markerMs };
|
|
214
|
+
return { marker, markerMs, searchEvidence };
|
|
194
215
|
});
|
|
195
216
|
} catch { failSafeArm(file); return { marker: conservativeArm(), markerMs: Date.now() }; }
|
|
196
217
|
if (!held.ok) { failSafeArm(file); return { marker: conservativeArm(), markerMs: Date.now() }; }
|
|
197
218
|
return held.value;
|
|
198
219
|
}
|
|
199
220
|
|
|
221
|
+
const sha256 = (value) => createHash('sha256').update(String(value)).digest('hex');
|
|
222
|
+
/** Trusted adapter host selects its documented identity: Claude prompt_id, Codex turn_id.
|
|
223
|
+
* Never borrow the other field, and never copy a project path into the evidence receipt. */
|
|
224
|
+
export function groundingIdentity(input, env = process.env) {
|
|
225
|
+
const host = ['claude', 'codex'].includes(env.RUVNET_HOOK_HOST) ? env.RUVNET_HOOK_HOST : null;
|
|
226
|
+
const nativeKind = host === 'claude' ? 'claude-prompt-id' : host === 'codex' ? 'codex-turn-id' : null;
|
|
227
|
+
const nativeId = host === 'claude' ? input.prompt_id : host === 'codex' ? input.turn_id : null;
|
|
228
|
+
let projectId = null;
|
|
229
|
+
try { projectId = sha256(fs.realpathSync(input.cwd || env.CLAUDE_PROJECT_DIR || process.cwd())); } catch {}
|
|
230
|
+
return { host, nativeKind, sessionId: input.session_id || null,
|
|
231
|
+
turnId: typeof nativeId === 'string' && nativeId.trim() ? nativeId : null, projectId };
|
|
232
|
+
}
|
|
233
|
+
function searchEvidencePath(file, marker) {
|
|
234
|
+
return /^[a-f0-9-]{36}$/.test(marker?.nonce || '') ? `${file}.search-${marker.nonce}` : null;
|
|
235
|
+
}
|
|
236
|
+
function retireSearchEvidence(file, marker) {
|
|
237
|
+
const receipt = searchEvidencePath(file, marker);
|
|
238
|
+
if (receipt) try { fs.unlinkSync(receipt); } catch {}
|
|
239
|
+
}
|
|
240
|
+
export const sameGroundingIdentity = (left, right) => ['host', 'nativeKind', 'sessionId', 'turnId', 'projectId'].every(key =>
|
|
241
|
+
typeof left?.[key] === 'string' && !!left[key] && left[key] === right?.[key]);
|
|
242
|
+
/** Missing native identity is UNKNOWN; global product freshness cannot prove a turn. */
|
|
243
|
+
export function readSearchEvidence(file, marker) {
|
|
244
|
+
const receipt = searchEvidencePath(file, marker);
|
|
245
|
+
if (!receipt) return null;
|
|
246
|
+
try {
|
|
247
|
+
const st = fs.lstatSync(receipt);
|
|
248
|
+
if (!st.isFile() || st.isSymbolicLink() || st.size > 32768) return null;
|
|
249
|
+
const row = JSON.parse(fs.readFileSync(receipt, 'utf8'));
|
|
250
|
+
if (row.schemaVersion !== 1 || !sameGroundingIdentity(row, marker) || row.nonce !== marker.nonce
|
|
251
|
+
|| !Number.isSafeInteger(row.searchCount) || row.searchCount < 1
|
|
252
|
+
|| !Array.isArray(row.terms) || !row.terms.every(term => typeof term === 'string')
|
|
253
|
+
|| !Array.isArray(row.sources) || !row.sources.length
|
|
254
|
+
|| !row.sources.every(source => /^[a-f0-9]{64}$/.test(source.querySha256) && /^[a-f0-9]{64}$/.test(source.answerSha256))) return null;
|
|
255
|
+
return row;
|
|
256
|
+
} catch { return null; }
|
|
257
|
+
}
|
|
258
|
+
/** Existing PostToolUse handler publishes only answered, native-turn-bound, redacted receipts. */
|
|
259
|
+
export function recordSearchEvidence(input, env = process.env) {
|
|
260
|
+
if (!/(?:^|__)search_ruvnet$/.test(input.tool_name || '') || !brainAnsweredResponse(input.tool_response)) return false;
|
|
261
|
+
const identity = groundingIdentity(input, env), file = markerPathFor(input.session_id, MARKER_DIR, identity.host);
|
|
262
|
+
if (!file || !identity.turnId || !identity.projectId) return false;
|
|
263
|
+
const held = markerLock(file, owns => {
|
|
264
|
+
const marker = readMarker(file, { locked: true }), receipt = searchEvidencePath(file, marker);
|
|
265
|
+
if (!receipt || !sameGroundingIdentity(marker, identity)) return false;
|
|
266
|
+
const prior = readSearchEvidence(file, marker), query = String(input.tool_input?.query || '');
|
|
267
|
+
const terms = [...RUVNET_GATE1_TERMS, 'aidefence', 'agentic-qe', 'ruv-swarm']
|
|
268
|
+
.filter(term => query.toLowerCase().includes(term.toLowerCase()));
|
|
269
|
+
const row = { schemaVersion: 1, ...identity, nonce: marker.nonce, searchCount: (prior?.searchCount || 0) + 1,
|
|
270
|
+
terms: [...new Set([...(prior?.terms || []), ...terms])], sources: [...(prior?.sources || []),
|
|
271
|
+
{ querySha256: sha256(query), answerSha256: sha256(answerOf(input.tool_response)) }].slice(-32) };
|
|
272
|
+
if (!owns()) return false;
|
|
273
|
+
writeAtomic(receipt, JSON.stringify(row) + '\n');
|
|
274
|
+
return true;
|
|
275
|
+
});
|
|
276
|
+
return held.ok && held.value === true;
|
|
277
|
+
}
|
|
278
|
+
|
|
200
279
|
async function main() {
|
|
201
280
|
let hookInput;
|
|
202
281
|
try {
|
|
203
|
-
const raw = (await readStdinBounded()).toString('utf8');
|
|
282
|
+
const raw = (await readStdinBounded({ maxBytes: process.argv.includes('--record-search') ? 2097152 : 65536 })).toString('utf8');
|
|
204
283
|
hookInput = JSON.parse(raw || '{}');
|
|
205
284
|
} catch { process.exit(0); }
|
|
206
285
|
|
|
286
|
+
if (process.argv.includes('--record-search')) {
|
|
287
|
+
try { recordSearchEvidence(hookInput); } catch {}
|
|
288
|
+
process.exit(0);
|
|
289
|
+
}
|
|
290
|
+
|
|
207
291
|
let arm = null;
|
|
208
292
|
const scope = loadSettings().values.groundingScope;
|
|
209
293
|
try { arm = armFor(hookInput, loadVocabulary(), scope); } catch { arm = shouldMark(hookInput, scope) ? { gate1: true, assert: false, architecture: false, subjects: [], groundingScope: scope } : null; }
|
|
210
294
|
if (!arm) process.exit(0);
|
|
211
295
|
|
|
212
|
-
const file = markerPathFor(hookInput.session_id);
|
|
296
|
+
const identity = groundingIdentity(hookInput), file = markerPathFor(hookInput.session_id, MARKER_DIR, identity.host);
|
|
213
297
|
if (!file) process.exit(0);
|
|
214
298
|
try {
|
|
215
|
-
writeArm(file, arm,
|
|
299
|
+
writeArm(file, arm, identity);
|
|
216
300
|
} catch { /* fail-open: no marker means the Stop gate stays silent, never a false block */ }
|
|
217
301
|
process.exit(0);
|
|
218
302
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
|
|
7
|
+
export const FRAME_KIND = 'brain-owned-context-blocks';
|
|
8
|
+
const digest = (value) => crypto.createHash('sha256').update(value).digest('hex');
|
|
9
|
+
const bytes = (value) => Buffer.byteLength(value, 'utf8');
|
|
10
|
+
export function advisoryBudgetFor(handler, event) {
|
|
11
|
+
if (handler === 'ground-ruvnet') return 8192;
|
|
12
|
+
if (handler === 'decision-gate') return 4096;
|
|
13
|
+
if (handler === 'unprompted-speech') return event === 'UserPromptSubmit' ? 2048 : 1024;
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
export function contextBlock(text, { id = 'block', critical = false } = {}) {
|
|
17
|
+
return { id: `${id.replace(/[^a-zA-Z0-9:_-]/g, '_').slice(0, 64)}:${digest(text).slice(0, 16)}`, text, critical };
|
|
18
|
+
}
|
|
19
|
+
export function contextFrame(handler, event, blocks) {
|
|
20
|
+
return { kind: FRAME_KIND, schemaVersion: 1, handler, event, blocks };
|
|
21
|
+
}
|
|
22
|
+
export function readContextFrame(raw) {
|
|
23
|
+
let value; try { value = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return null; }
|
|
24
|
+
if (value?.kind !== FRAME_KIND || value.schemaVersion !== 1 || !Array.isArray(value.blocks)
|
|
25
|
+
|| Object.keys(value).some((key) => !['kind','schemaVersion','handler','event','blocks'].includes(key))
|
|
26
|
+
|| typeof value.handler !== 'string' || typeof value.event !== 'string'
|
|
27
|
+
|| value.blocks.some((block) => typeof block?.id !== 'string' || block.id.length > 96
|
|
28
|
+
|| typeof block.text !== 'string' || typeof block.critical !== 'boolean')) return null;
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
export function selectContextFrame(frame, limit = advisoryBudgetFor(frame.handler, frame.event)) {
|
|
32
|
+
if (!Number.isSafeInteger(limit) || limit < 0) throw new Error('No safe advisory allocation');
|
|
33
|
+
const seen = new Set(); const selected = []; const deferred = [];
|
|
34
|
+
let advisoryBytes = 0; let criticalBytes = 0;
|
|
35
|
+
for (const block of frame.blocks) {
|
|
36
|
+
const identity = JSON.stringify(block);
|
|
37
|
+
if (seen.has(identity)) continue;
|
|
38
|
+
seen.add(identity);
|
|
39
|
+
if (block.critical) { selected.push(block); criticalBytes += bytes(block.text) + 2; continue; }
|
|
40
|
+
const cost = bytes(block.text) + 2;
|
|
41
|
+
if (advisoryBytes + cost > limit) deferred.push(block.id);
|
|
42
|
+
else { selected.push(block); advisoryBytes += cost; }
|
|
43
|
+
}
|
|
44
|
+
const selectedIds = selected.map((block) => block.id);
|
|
45
|
+
return { frame: { ...frame, blocks: selected }, receipt: { handler: frame.handler, event: frame.event,
|
|
46
|
+
budgetBytes: limit, advisoryBytes, criticalBytes, criticalExemption: criticalBytes > 0,
|
|
47
|
+
selectedIds: selectedIds.slice(0, 8), selectedCount: selectedIds.length,
|
|
48
|
+
deferredIds: deferred.slice(0, 8), deferredCount: deferred.length,
|
|
49
|
+
idsDigest: digest(JSON.stringify({ selectedIds, deferred })), scope: 'typed-advisories-per-event-not-whole-turn' } };
|
|
50
|
+
}
|
|
51
|
+
export function renderContextFrame(frame, { rawText = false } = {}) {
|
|
52
|
+
const text = frame.blocks.map((block) => block.text).join('\n\n');
|
|
53
|
+
return rawText ? text : JSON.stringify({ hookSpecificOutput: { hookEventName: frame.event, additionalContext: text } });
|
|
54
|
+
}
|
|
55
|
+
export function recordContextBudget(receipt, env = process.env) {
|
|
56
|
+
if (env.RUVNET_BRAIN_METER === '0') return;
|
|
57
|
+
try {
|
|
58
|
+
const directory = path.join(env.XDG_CACHE_HOME || path.join(env.HOME || os.homedir(), '.cache'), 'ruvnet-brain');
|
|
59
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
60
|
+
if (fs.lstatSync(directory).isSymbolicLink()) return;
|
|
61
|
+
const file = path.join(directory, 'token-ledger.jsonl');
|
|
62
|
+
if (fs.existsSync(file)) { const stat = fs.lstatSync(file); if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) return; }
|
|
63
|
+
const line = JSON.stringify({ ts: new Date().toISOString(), source: 'hook', class: 'context-budget', ...receipt });
|
|
64
|
+
if (bytes(line) > 2048) return;
|
|
65
|
+
const fd = fs.openSync(file, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW || 0), 0o600);
|
|
66
|
+
try { fs.writeSync(fd, line + '\n'); } finally { fs.closeSync(fd); }
|
|
67
|
+
} catch { /* Metrics never alter a guard or permission decision. */ }
|
|
68
|
+
}
|
|
69
|
+
export function emitOwnedContext(frame, { env = process.env, rawText = false } = {}) {
|
|
70
|
+
const selected = selectContextFrame(frame);
|
|
71
|
+
recordContextBudget(selected.receipt, env);
|
|
72
|
+
return env.RUVNET_CODEX_CONTEXT_FRAMES === '1' ? JSON.stringify(selected.frame) : renderContextFrame(selected.frame, { rawText });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let direct = false;
|
|
76
|
+
try { direct = fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); } catch {}
|
|
77
|
+
if (direct && process.argv[2] === '--ground-dir') {
|
|
78
|
+
const directory = process.argv[3]; const blocks = [];
|
|
79
|
+
for (const id of fs.readdirSync(directory).filter((id) => /^\d+-\d+-[\w-]+$/.test(id)).sort((a, b) => Number(a.split('-')[0]) - Number(b.split('-')[0]))) {
|
|
80
|
+
let picked; try { picked = fs.readFileSync(path.join(directory, id + '.pick'), 'utf8').trim(); } catch { continue; }
|
|
81
|
+
if (!['full', 'short'].includes(picked)) continue;
|
|
82
|
+
const text = fs.readFileSync(path.join(directory, id + (picked === 'short' ? '.short' : '')), 'utf8');
|
|
83
|
+
blocks.push(contextBlock(text, { id, critical: id.split('-')[1] === '0' }));
|
|
84
|
+
}
|
|
85
|
+
try {
|
|
86
|
+
const deferred = fs.readFileSync(path.join(directory, '.budget-deferred'), 'utf8').split('\n').filter(Boolean);
|
|
87
|
+
if (deferred.length) recordContextBudget({ handler: 'ground-ruvnet', event: 'UserPromptSubmit', scope: 'pre-mark-budget-deferral', deferredCount: deferred.length, deferredIds: deferred.slice(0, 8) });
|
|
88
|
+
} catch { /* No optional block was deferred by the existing assembler. */ }
|
|
89
|
+
process.stdout.write(emitOwnedContext(contextFrame('ground-ruvnet', 'UserPromptSubmit', blocks), { rawText: true }));
|
|
90
|
+
}
|
|
@@ -69,6 +69,8 @@
|
|
|
69
69
|
import fs from 'node:fs';
|
|
70
70
|
import path from 'node:path';
|
|
71
71
|
import os from 'node:os';
|
|
72
|
+
import crypto from 'node:crypto';
|
|
73
|
+
import { createRequire, isBuiltin } from 'node:module';
|
|
72
74
|
import { fileURLToPath } from 'node:url';
|
|
73
75
|
|
|
74
76
|
const HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -488,6 +490,130 @@ export function census(reg) {
|
|
|
488
490
|
};
|
|
489
491
|
}
|
|
490
492
|
|
|
493
|
+
const auditDigest = value => crypto.createHash('sha256').update(typeof value === 'string' || Buffer.isBuffer(value) ? value : JSON.stringify(value)).digest('hex');
|
|
494
|
+
|
|
495
|
+
/** Contract IDs already distinguish event and host; foreign commands never inherit ownership. */
|
|
496
|
+
export function canonicalHookAuditId(record) {
|
|
497
|
+
const host = record.layer === 'codex' ? 'codex' : 'claude';
|
|
498
|
+
const id = record.shimId ?? record.codexHookId;
|
|
499
|
+
return ['plugin', 'codex'].includes(record.layer) && id ? `${host}/${record.event}/${id}`
|
|
500
|
+
: `foreign/${host}/${auditDigest([record.layer, record.file, record.event, record.command]).slice(0, 24)}`;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** A hash is integrity evidence only. No PASS or readComplete flag authorizes semantic closure. */
|
|
504
|
+
export function hookAuditReviewStatus(entry, review) {
|
|
505
|
+
if (!review || typeof review.reviewer !== 'string' || !review.reviewer || !Array.isArray(review.readCoverage)
|
|
506
|
+
|| !Array.isArray(review.findings) || !['source-read', 'subprocess', 'native'].includes(review.verificationStrength)) {
|
|
507
|
+
return { state: 'UNKNOWN', reason: 'substantive source-bound reviewer evidence missing' };
|
|
508
|
+
}
|
|
509
|
+
const evidence = { reviewer: review.reviewer, findings: review.findings, verificationStrength: review.verificationStrength,
|
|
510
|
+
reference: review.reference ?? null, readCoverage: review.readCoverage };
|
|
511
|
+
if (review.fingerprint !== entry.fingerprint) return { ...evidence, state: 'STALE', reason: 'registration, source, import or configuration fingerprint changed' };
|
|
512
|
+
const uncovered = entry.inputs.filter(input => input.sha256 && !review.readCoverage.some(read => read.path === input.path
|
|
513
|
+
&& read.sha256 === input.sha256 && typeof read.notes === 'string' && read.notes.trim()
|
|
514
|
+
&& Array.isArray(read.lineRanges) && read.lineRanges.some(range => Array.isArray(range) && range.length === 2
|
|
515
|
+
&& Number.isInteger(range[0]) && Number.isInteger(range[1]) && range[0] > 0 && range[1] >= range[0] && range[1] <= input.lines)));
|
|
516
|
+
if (review.state !== 'complete' || uncovered.length || !Array.isArray(review.contractDimensions)
|
|
517
|
+
|| !['purpose', 'inputs', 'outputs', 'authority', 'scope', 'failure', 'budgets', 'dependencies', 'parity'].every(dimension => review.contractDimensions.includes(dimension))) {
|
|
518
|
+
return { ...evidence, state: 'UNKNOWN', reason: 'review coverage remains open', uncovered: uncovered.map(input => input.path) };
|
|
519
|
+
}
|
|
520
|
+
return { ...evidence, state: 'REVIEW_RECORDED',
|
|
521
|
+
limitation: 'reviewer declaration bound to source; not native execution or promotion authority' };
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** Read-only audit of the existing registry. This does not resolve every effective native layer. */
|
|
525
|
+
export function buildHookAudit({ repo = REPO, home = os.homedir(), includeMachine = true,
|
|
526
|
+
registry = buildRegistry({ repo, home, includeMachine }), configFiles = [], reviews = [], sourceSha = null } = {}) {
|
|
527
|
+
repo = fs.realpathSync(repo);
|
|
528
|
+
const scriptRoot = fs.existsSync(path.join(repo, 'plugin/scripts/hook-shim.mjs')) ? path.join(repo, 'plugin/scripts') : path.join(repo, 'scripts');
|
|
529
|
+
const contractsFile = registry.contractsFile;
|
|
530
|
+
const cache = new Map(), texts = new Map();
|
|
531
|
+
const input = (file, kind) => {
|
|
532
|
+
const name = file.startsWith(repo + path.sep) ? path.relative(repo, file).split(path.sep).join('/') : file;
|
|
533
|
+
if (cache.has(file)) return { ...cache.get(file), kind };
|
|
534
|
+
try {
|
|
535
|
+
const stat = fs.lstatSync(file);
|
|
536
|
+
if (!stat.isFile() || stat.isSymbolicLink() || fs.realpathSync(file) !== file || stat.size > 1024 * 1024) throw new Error('unbounded or noncanonical source');
|
|
537
|
+
const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW);
|
|
538
|
+
let bytes;
|
|
539
|
+
try { bytes = Buffer.alloc(stat.size); let offset = 0;
|
|
540
|
+
while (offset < bytes.length) { const count = fs.readSync(fd, bytes, offset, Math.min(65536, bytes.length - offset), offset); if (!count) throw new Error('source truncated'); offset += count; }
|
|
541
|
+
const after = fs.fstatSync(fd), current = fs.lstatSync(file);
|
|
542
|
+
if (after.size !== stat.size || after.mtimeMs !== stat.mtimeMs || after.ino !== stat.ino || after.dev !== stat.dev
|
|
543
|
+
|| current.ino !== stat.ino || current.dev !== stat.dev || current.size !== stat.size || current.mtimeMs !== stat.mtimeMs || current.isSymbolicLink()) throw new Error('source changed during audit');
|
|
544
|
+
} finally { fs.closeSync(fd); }
|
|
545
|
+
const text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
546
|
+
const row = { path: name, sha256: auditDigest(bytes), bytes: bytes.length, lines: text.split('\n').length, uid: stat.uid };
|
|
547
|
+
texts.set(file, text);
|
|
548
|
+
cache.set(file, row); return { ...row, kind };
|
|
549
|
+
} catch (error) { return { path: name, sha256: null, kind, state: error.code === 'ENOENT' ? 'absent' : 'unavailable', reason: error.code === 'ENOENT' ? null : String(error.message) }; }
|
|
550
|
+
};
|
|
551
|
+
input(contractsFile, 'contract-declaration');
|
|
552
|
+
let document; try { document = JSON.parse(texts.get(contractsFile)); } catch { document = {}; }
|
|
553
|
+
const contracts = Array.isArray(document.contracts) ? document.contracts : [];
|
|
554
|
+
const packageFile = path.join(repo, 'package.json');
|
|
555
|
+
input(packageFile, 'package-declaration');
|
|
556
|
+
let packageDeclaration; try { packageDeclaration = JSON.parse(texts.get(packageFile)); } catch { packageDeclaration = null; }
|
|
557
|
+
const entries = registry.records.map(record => {
|
|
558
|
+
const host = record.layer === 'codex' ? 'codex' : 'claude', dispatchId = record.shimId ?? record.codexHookId;
|
|
559
|
+
const contract = contracts.find(row => row.id === dispatchId && row.event === record.event && row.hosts?.includes(host));
|
|
560
|
+
const purpose = document._eventOwners?.find(row => row.owner === dispatchId && row.event === record.event && row.hosts?.includes(host));
|
|
561
|
+
const seeds = [record.file, contractsFile, path.join(scriptRoot, 'hook-shim.mjs'), path.join(scriptRoot, record.handler ?? 'missing-body')];
|
|
562
|
+
if (host === 'codex') seeds.push(path.join(scriptRoot, 'codex-hook-wrapper.mjs'), path.join(scriptRoot, 'codex-hook-adapter.mjs'));
|
|
563
|
+
const refs = new Map(), unresolved = [], pending = [...seeds], seen = new Set(), allowedRoots = new Set([repo]);
|
|
564
|
+
while (pending.length && seen.size < 512) {
|
|
565
|
+
const file = path.resolve(pending.shift()); if (seen.has(file)) continue; seen.add(file);
|
|
566
|
+
const ref = input(file, 'source'); refs.set(ref.path, ref);
|
|
567
|
+
if (!ref.sha256 || !/\.(?:mjs|cjs|js|sh)$/.test(file)) continue;
|
|
568
|
+
const text = texts.get(file);
|
|
569
|
+
// Same literal relative-import mechanism used by the shipped-package falsifier. Dynamic
|
|
570
|
+
// resolution remains explicit incomplete evidence; this is never semantic read coverage.
|
|
571
|
+
for (const match of text.matchAll(/(?:from|import|require)\s*\(?\s*['"](\.[^'"]+)['"]/g)) {
|
|
572
|
+
const dependency = path.resolve(path.dirname(file), match[1]);
|
|
573
|
+
if ([...allowedRoots].some(root => dependency.startsWith(root + path.sep))) pending.push(dependency);
|
|
574
|
+
else unresolved.push({ file: ref.path, kind: 'outside-root-import', specifier: match[1] });
|
|
575
|
+
}
|
|
576
|
+
for (const match of text.matchAll(/(?:from|import|require)\s*\(?\s*['"]([^.'"][^'"]*)['"]/g)) {
|
|
577
|
+
if (isBuiltin(match[1])) continue;
|
|
578
|
+
try {
|
|
579
|
+
const resolved = createRequire(file).resolve(match[1]);
|
|
580
|
+
let directory = path.dirname(resolved);
|
|
581
|
+
while (!fs.existsSync(path.join(directory, 'package.json')) && directory !== path.dirname(directory)) directory = path.dirname(directory);
|
|
582
|
+
if (!fs.existsSync(path.join(directory, 'package.json'))) throw new Error('package declaration missing');
|
|
583
|
+
allowedRoots.add(directory); pending.push(resolved, path.join(directory, 'package.json'));
|
|
584
|
+
} catch { unresolved.push({ file: ref.path, kind: 'unresolved-package-import', specifier: match[1] }); }
|
|
585
|
+
}
|
|
586
|
+
if (/\bimport\s*\(\s*[^'"\s]/.test(text)) unresolved.push({ file: ref.path, kind: 'dynamic-import' });
|
|
587
|
+
if (/\.(?:sh)$/.test(file)) unresolved.push({ file: ref.path, kind: 'shell-child/config-resolution-requires-review' });
|
|
588
|
+
}
|
|
589
|
+
if (pending.length) unresolved.push({ kind: 'source-closure-bound' });
|
|
590
|
+
for (const file of configFiles) { const ref = input(path.resolve(file), 'configuration'); refs.set(ref.path, ref); }
|
|
591
|
+
const pkg = input(packageFile, 'package-declaration'); refs.set(pkg.path, pkg);
|
|
592
|
+
const inputs = [...refs.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
593
|
+
let declarationMatches = false;
|
|
594
|
+
try { declarationMatches = ['plugin', 'codex'].includes(record.layer) && readRegistrations(record.file).some(row => row.event === record.event
|
|
595
|
+
&& row.matcher === record.matcher && row.command === record.command && row.timeout === record.timeout); } catch { /* ownership remains unknown */ }
|
|
596
|
+
const managed = declarationMatches && contract && packageDeclaration?.name === 'ruvnet-brain' && record.file.startsWith(repo + path.sep)
|
|
597
|
+
&& inputs.every(ref => ref.sha256 && ref.uid === process.getuid?.());
|
|
598
|
+
const registration = { host, event: record.event, matcher: record.matcher, commandSha256: auditDigest(record.command), timeout: record.timeout,
|
|
599
|
+
asyncRewake: record.asyncRewake, condition: record.if, dispatchId, contract };
|
|
600
|
+
const entry = { id: canonicalHookAuditId(record), host, event: record.event, dispatchId, layer: record.layer,
|
|
601
|
+
purpose: purpose?.responsibility ?? null, intendedHosts: contract?.hosts ?? [], declaredMode: record.mode,
|
|
602
|
+
controlGuarantee: dispatchId === 'capacity-aware-parallel-work' && record.mode === 'advisory' ? 'advisory-only' : 'not-proven-by-inventory',
|
|
603
|
+
ownership: { status: managed ? 'CORRELATED_PACKAGE_SOURCE' : 'UNKNOWN', mutationAuthority: false,
|
|
604
|
+
proof: managed ? { declarationMatches, packageName: packageDeclaration.name, packageVersion: packageDeclaration.version, canonicalRoot: repo, uid: process.getuid() } : null },
|
|
605
|
+
registration, inputs, dependencyCoverage: { state: 'PARTIAL_STATIC', unresolved,
|
|
606
|
+
limitation: 'literal reachable imports and resolved package entries only; dynamic dispatch and runtime configuration require explicit reviewer coverage' },
|
|
607
|
+
fingerprint: auditDigest({ registration, inputs, unresolved }), nativeExecution: 'UNKNOWN' };
|
|
608
|
+
entry.review = hookAuditReviewStatus(entry, reviews.find(review => review.id === entry.id));
|
|
609
|
+
return entry;
|
|
610
|
+
});
|
|
611
|
+
const duplicates = entries.filter((entry, index) => entries.findIndex(other => other.id === entry.id) !== index).map(entry => entry.id);
|
|
612
|
+
return { schemaVersion: 1, kind: 'hook-registry-source-audit', sourceSha, entries, duplicates, registryErrors: registry.errors,
|
|
613
|
+
inventoryCoverage: 'existing registry discovery only; local/managed/native-active overlays are not exhaustive',
|
|
614
|
+
auditSha256: auditDigest(entries), promotionAuthority: false };
|
|
615
|
+
}
|
|
616
|
+
|
|
491
617
|
// ── THE MESH INVARIANTS (ADR-055 §7) ────────────────────────────────────────────────────────────
|
|
492
618
|
//
|
|
493
619
|
// Each is a PURE function from records → findings, for three reasons that all matter:
|
|
@@ -630,7 +756,9 @@ const invokedDirectly = process.argv[1] && path.resolve(process.argv[1]).endsWit
|
|
|
630
756
|
if (invokedDirectly) {
|
|
631
757
|
const includeMachine = !process.argv.includes('--machine=0') && process.env.CI !== 'true';
|
|
632
758
|
const reg = buildRegistry({ includeMachine });
|
|
633
|
-
if (process.argv.includes('--json')) {
|
|
759
|
+
if (process.argv.includes('--audit-json')) {
|
|
760
|
+
process.stdout.write(`${JSON.stringify(buildHookAudit({ registry: reg, includeMachine }), null, 2)}\n`);
|
|
761
|
+
} else if (process.argv.includes('--json')) {
|
|
634
762
|
process.stdout.write(`${JSON.stringify(reg.records, null, 2)}\n`);
|
|
635
763
|
} else if (process.argv.includes('--lint')) {
|
|
636
764
|
const found = lintAll(reg);
|
|
@@ -31,9 +31,11 @@ import fs from 'node:fs';
|
|
|
31
31
|
import path from 'node:path';
|
|
32
32
|
import os from 'node:os';
|
|
33
33
|
import { spawnSync } from 'node:child_process';
|
|
34
|
+
import { advisoryBudgetFor, recordContextBudget } from './hook-context-budget.mjs';
|
|
34
35
|
import { fileURLToPath } from 'node:url';
|
|
35
36
|
import { resolveBash, skipNoBash } from './hook-shim-bash.mjs';
|
|
36
37
|
import { developmentHooksSuspended } from './development-maintenance.mjs';
|
|
38
|
+
import { sessionStartDeadlineAt } from './session-start-budget.mjs';
|
|
37
39
|
|
|
38
40
|
if (developmentHooksSuspended()) process.exit(0);
|
|
39
41
|
|
|
@@ -186,7 +188,7 @@ const TABLE = {
|
|
|
186
188
|
|
|
187
189
|
const hookId = process.argv[2];
|
|
188
190
|
// Native Claude supplies this exact registered plugin root; do not guess a host from absence.
|
|
189
|
-
if (['session-snapshot', 'continuation-gate'].includes(hookId)
|
|
191
|
+
if (['session-snapshot', 'continuation-gate', 'grounding-turn-mark', 'grounding-stamp', 'grounding-turn-gate'].includes(hookId)
|
|
190
192
|
&& process.env.RUVNET_HOOK_HOST === undefined && process.env.CLAUDE_PLUGIN_ROOT) {
|
|
191
193
|
try {
|
|
192
194
|
const ownPluginRoot = fs.realpathSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'));
|
|
@@ -338,13 +340,29 @@ function runHook(file, activeVersion = '') {
|
|
|
338
340
|
// The node running THIS shim, for bash bodies that need node (grounding-stamp.sh's verdict) on a
|
|
339
341
|
// machine where `node` is not on the PATH the host hands its hooks. Additive: older bodies ignore it.
|
|
340
342
|
RUVNET_NODE_BIN: process.execPath,
|
|
343
|
+
...(['unprompted-speech', 'ground-ruvnet', 'decision-gate'].includes(hookId) ? { RUVNET_HOOK_CONTEXT_BUDGET: '1' } : {}),
|
|
341
344
|
...(activeVersion ? { RUVNET_BRAIN_ACTIVE_VERSION: activeVersion } : {}),
|
|
342
345
|
...(BRAIN_OFF && entry.offBehavior === 'partial' ? { RUVNET_BRAIN_OFF: '1' } : {}),
|
|
343
346
|
};
|
|
344
347
|
const io = hookInput !== null
|
|
345
348
|
? { stdio: ['pipe', 'inherit', 'inherit'], input: hookInput }
|
|
346
349
|
: { stdio: 'inherit' };
|
|
347
|
-
|
|
350
|
+
let sessionTimeout;
|
|
351
|
+
if (hookId === 'session-start') {
|
|
352
|
+
try {
|
|
353
|
+
const deadlineAt = sessionStartDeadlineAt({ host: env.RUVNET_HOOK_HOST || 'claude', env,
|
|
354
|
+
startedAt: performance.timeOrigin, pluginRoot: path.resolve(path.dirname(file), '..') });
|
|
355
|
+
env.RUVNET_SESSION_START_DEADLINE_AT = String(deadlineAt);
|
|
356
|
+
sessionTimeout = Math.floor(deadlineAt - Date.now());
|
|
357
|
+
if (sessionTimeout < 1) throw new Error('SessionStart deadline exhausted before restore');
|
|
358
|
+
} catch (error) {
|
|
359
|
+
process.stderr.write(`[RuvNet Brain — PROJECT CONTINUITY UNKNOWN] ${error.message}; restoration was not attempted.\n`);
|
|
360
|
+
return 0;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if (advisoryBudgetFor(hookId, extraArgs[0] || '') === null) recordContextBudget({ handler: hookId, event: extraArgs[0] || hookId, scope: 'unknown-unframed-context-unchanged' }, env);
|
|
364
|
+
const r = spawnSync(cmd, [file, ...extraArgs], { ...io, env,
|
|
365
|
+
...(sessionTimeout ? { timeout: sessionTimeout, killSignal: 'SIGKILL' } : {}) });
|
|
348
366
|
if (r.error) {
|
|
349
367
|
process.stderr.write(`[hook-shim] ${entry.file}: ${r.error.message}\n`);
|
|
350
368
|
return entry.mode === 'blocking' ? 1 : 0;
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Code and Codex, so lifecycle updates cannot drift into host-specific shell pipelines again.
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
5
|
import { automaticInvocation, automaticPath, updateSource } from './automatic-update.mjs';
|
|
6
|
+
import { developerCoordinatorOwner } from './developer-update-owner.mjs';
|
|
6
7
|
|
|
7
8
|
const CHILD_ENV_KEYS = new Set([
|
|
8
9
|
'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL',
|
|
@@ -12,7 +13,7 @@ const CHILD_ENV_KEYS = new Set([
|
|
|
12
13
|
'CODEX_HOME', 'CLAUDE_CONFIG_DIR',
|
|
13
14
|
'HTTP_PROXY', 'HTTPS_PROXY', 'NO_PROXY', 'http_proxy', 'https_proxy', 'no_proxy',
|
|
14
15
|
'RUVNET_BRAIN_HOME', 'RUVNET_BRAIN_KB', 'RUVNET_BRAIN_MODEL_CACHE',
|
|
15
|
-
'RUVNET_BRAIN_NO_UPDATE_FALLBACK', 'RUVNET_BRAIN_TEST',
|
|
16
|
+
'RUVNET_BRAIN_NO_UPDATE_FALLBACK', 'RUVNET_BRAIN_TEST', 'RUVNET_DEVELOPER_UPDATE_TOKEN',
|
|
16
17
|
]);
|
|
17
18
|
|
|
18
19
|
export function childEnvironment(source = process.env) {
|
|
@@ -57,7 +58,9 @@ if (knowledgeAt >= 0) {
|
|
|
57
58
|
} catch { /* the next session reports a launch with no outcome as a failure */ }
|
|
58
59
|
};
|
|
59
60
|
try {
|
|
60
|
-
const
|
|
61
|
+
const coordinator = developerCoordinatorOwner();
|
|
62
|
+
if (coordinator.active && !coordinator.ready) throw new Error(`coordinated updater is not ready: ${coordinator.reason}`);
|
|
63
|
+
const source = coordinator.active ? 'developer-suite' : updateSource();
|
|
61
64
|
// --if-newer <kbDir> <checkFile> <resultFile>: the newer-published identity check (2026-10-02). Run the
|
|
62
65
|
// INSTALLED updater's --check (bounded transient GET retries of the canonical releases/latest pointer, no download) and only a
|
|
63
66
|
// newer identity proceeds to the update below. Exit codes are forge-update.mjs's own: 0 current (or
|
|
@@ -114,7 +117,7 @@ if (knowledgeAt >= 0) {
|
|
|
114
117
|
fs.renameSync(tmp, attemptFile);
|
|
115
118
|
}
|
|
116
119
|
const probe = process.env.RUVNET_AUTO_UPDATE_PROBE_URL || 'https://registry.npmjs.org/ruvnet-brain/latest';
|
|
117
|
-
let online = source === 'installed';
|
|
120
|
+
let online = source === 'installed' || source === 'developer-suite';
|
|
118
121
|
if (!online) try { online = (await fetch(probe, { signal: AbortSignal.timeout(5_000) })).ok; } catch { /* offline */ }
|
|
119
122
|
if (!online) {
|
|
120
123
|
record({ outcome: 'offline', code: null, reason: `registry unreachable (${probe})` });
|
|
@@ -313,7 +313,7 @@ const effectiveTriggers = command === null
|
|
|
313
313
|
const seen = new Set();
|
|
314
314
|
const candidates = [];
|
|
315
315
|
for (const t of effectiveTriggers) {
|
|
316
|
-
for (const l of lessonsFor(t, lessons, { limit: 3 })) {
|
|
316
|
+
for (const l of lessonsFor(t, lessons.filter((l) => isHome(l) || isUniversal(l)), { limit: 3 })) {
|
|
317
317
|
if (seen.has(l.id)) continue;
|
|
318
318
|
// Away from home, only a lesson with cross-project evidence may speak.
|
|
319
319
|
if (!isHome(l) && !isUniversal(l)) continue;
|