ruvnet-brain 4.5.16 → 4.5.17
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/bin/install.mjs +1 -1
- package/config/practical-rule-catalog.json +111 -0
- package/package.json +2 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/hooks/codex-hooks.json +13 -1
- package/plugin/hooks/hook-contracts.json +20 -4
- package/plugin/scripts/adr-currency-gate.mjs +75 -31
- package/plugin/scripts/advocacy-outcomes.mjs +18 -4
- package/plugin/scripts/agentdb-recall.mjs +161 -26
- package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
- package/plugin/scripts/capability-registry.mjs +55 -114
- package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
- package/plugin/scripts/codex-hook-adapter.mjs +33 -6
- package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
- package/plugin/scripts/continuity-brief.mjs +55 -22
- package/plugin/scripts/continuity-events.mjs +93 -17
- package/plugin/scripts/continuity-hook-policy.mjs +1 -1
- package/plugin/scripts/continuity-journal.mjs +77 -29
- package/plugin/scripts/decision-gate.mjs +63 -22
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/doc-currency.mjs +993 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -3
- package/plugin/scripts/grounding-answer.mjs +18 -16
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
- package/plugin/scripts/grounding-turn-gate.mjs +34 -29
- package/plugin/scripts/grounding-turn-mark.mjs +101 -17
- package/plugin/scripts/hook-context-budget.mjs +90 -0
- package/plugin/scripts/hook-registry.mjs +129 -1
- package/plugin/scripts/hook-shim.mjs +20 -2
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/project-capture-queue.mjs +22 -15
- package/plugin/scripts/project-progression-producer.mjs +15 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -4
- package/plugin/scripts/project-progression-session-start.mjs +29 -17
- package/plugin/scripts/project-progression-sources.mjs +24 -10
- package/plugin/scripts/project-progression-store.mjs +7 -2
- package/plugin/scripts/project-store-resolver.mjs +10 -7
- package/plugin/scripts/project-transition-hook.mjs +20 -12
- package/plugin/scripts/session-snapshot-hook.mjs +20 -9
- package/plugin/scripts/session-start-budget.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +32 -6
- package/plugin/scripts/session-start-trace.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -5
- package/plugin/scripts/unprompted-runtime.mjs +36 -10
- package/plugin/scripts/user-settings.mjs +7 -1
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +1 -1
- package/scripts/model-managed-prompt.mjs +1 -1
- package/scripts/model-managed-workflow-service.mjs +113 -14
- package/scripts/model-routing-controller.mjs +112 -21
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/source-scope-receipt.mjs +9 -4
|
@@ -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;
|
|
@@ -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;
|
|
@@ -264,38 +264,42 @@ export function replayOutboxDetached({ projectDir, token = null, spawnFn = spawn
|
|
|
264
264
|
*/
|
|
265
265
|
export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_LOCK_TOKEN || null, budgetMs = DETACHED_REPLAY_BUDGET_MS,
|
|
266
266
|
makeStoreFactory = boundedStoreFactory, now = Date.now, runCapture = runSessionSnapshotHook, onClaim = null,
|
|
267
|
-
captureNormalized = captureNormalizedTransition, onCaptured = null, env = process.env
|
|
268
|
-
|
|
267
|
+
captureNormalized = captureNormalizedTransition, onCaptured = null, env = process.env,
|
|
268
|
+
deadlineAt: inheritedDeadlineAt = Infinity, signal } = {}) {
|
|
269
|
+
const deadlineAt = Math.min(inheritedDeadlineAt, now() + budgetMs);
|
|
270
|
+
if (signal?.aborted || now() >= deadlineAt) return 0;
|
|
269
271
|
if (developmentHooksSuspended(projectDir)) return 0;
|
|
270
272
|
if (operatorProgressionSuspension(env)) { if (token) releaseReplayLock(projectDir, token); return 0; }
|
|
271
273
|
const brainHome = env.RUVNET_BRAIN_HOME || path.join(env.HOME || os.homedir(), '.cache', 'ruvnet-brain');
|
|
272
274
|
try {
|
|
273
|
-
const consent = resolveTurnDb({ projectDir, brainHome });
|
|
275
|
+
const consent = resolveTurnDb({ projectDir, brainHome, deadlineAt, signal });
|
|
274
276
|
if (consent.skipped && !consent.skipped.startsWith('no project memory db')) return 0;
|
|
275
277
|
} catch { return 0; }
|
|
278
|
+
if (signal?.aborted || now() >= deadlineAt) return 0;
|
|
276
279
|
let held = token || takeReplayLock(projectDir);
|
|
277
280
|
let replayed = 0;
|
|
278
|
-
for (let round = 0; held && round < 8 && now() < deadlineAt; round += 1) {
|
|
281
|
+
for (let round = 0; held && round < 8 && now() < deadlineAt && !signal?.aborted; round += 1) {
|
|
279
282
|
try {
|
|
280
283
|
if (!adoptReplayLock(projectDir, held)) return replayed;
|
|
281
284
|
reclaimOrphans(projectDir);
|
|
282
|
-
const resolution = resolveProjectStore({ projectDir });
|
|
283
|
-
const store = makeStoreFactory(deadlineAt)({ projectDir, env, requestedStorePath: resolution.canonicalAgentDbPath });
|
|
285
|
+
const resolution = resolveProjectStore({ projectDir, deadlineAt });
|
|
286
|
+
const store = makeStoreFactory(deadlineAt)({ projectDir, env, requestedStorePath: resolution.canonicalAgentDbPath, deadlineAt, signal });
|
|
284
287
|
for (const snapshot of store.outbox.pendingSnapshots()) {
|
|
285
288
|
if (operatorProgressionSuspension(env)) return replayed;
|
|
286
|
-
if (now() >= deadlineAt) return replayed;
|
|
289
|
+
if (signal?.aborted || now() >= deadlineAt) return replayed;
|
|
287
290
|
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
288
|
-
store.captureFrozen(snapshot, { canCommit: () => refreshReplayLock(projectDir, held) });
|
|
291
|
+
store.captureFrozen(snapshot, { canCommit: () => !signal?.aborted && now() < deadlineAt && refreshReplayLock(projectDir, held) });
|
|
289
292
|
// captureFrozen commits only the verified key, retaining conflicting original history.
|
|
290
293
|
replayed += 1;
|
|
291
294
|
}
|
|
292
295
|
for (const file of queuedCaptures(projectDir)) {
|
|
293
296
|
if (operatorProgressionSuspension(env)) return replayed;
|
|
294
|
-
if (now() >= deadlineAt) return replayed;
|
|
297
|
+
if (signal?.aborted || now() >= deadlineAt) return replayed;
|
|
295
298
|
if (!refreshReplayLock(projectDir, held)) return replayed;
|
|
296
299
|
const claimed = claimQueued(file);
|
|
297
300
|
if (!claimed) continue;
|
|
298
301
|
onClaim?.(claimed);
|
|
302
|
+
if (signal?.aborted || now() >= deadlineAt) { returnClaim(claimed); return replayed; }
|
|
299
303
|
if (!refreshReplayLock(projectDir, held)) {
|
|
300
304
|
returnClaim(claimed);
|
|
301
305
|
return replayed;
|
|
@@ -308,16 +312,17 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
308
312
|
if (job) {
|
|
309
313
|
// Pre-upgrade raw queues lack origin identity and cannot be truthfully reconstructed.
|
|
310
314
|
if (runCapture === runSessionSnapshotHook && (!job.originProjectDir || (!job.payload?.projectProgression && !job.payload?.normalizedTransition))) { returnClaim(claimed); return replayed; }
|
|
311
|
-
const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome });
|
|
315
|
+
const consent = resolveTurnDb({ projectDir: job.originProjectDir || projectDir, brainHome, deadlineAt, signal });
|
|
312
316
|
if (developmentHooksSuspended(job.originProjectDir || projectDir)
|
|
313
317
|
|| (consent.skipped && !consent.skipped.startsWith('no project memory db'))) { returnClaim(claimed); return replayed; }
|
|
314
318
|
const options = { rawInput: JSON.stringify(job.payload), host: job.host, env,
|
|
315
|
-
budgetMs: Math.max(0, deadlineAt - now()),
|
|
319
|
+
budgetMs: Math.max(0, deadlineAt - now()), deadlineAt, signal,
|
|
320
|
+
makeStoreFactory: () => makeStoreFactory(deadlineAt), now, ordered: held, writeMetadata: false,
|
|
316
321
|
captureTurn: () => ({ recorded: false, skipped: 'detached replay' }),
|
|
317
322
|
captureEvents: () => ({ recorded: 0, skipped: 'detached replay' }) };
|
|
318
323
|
const result = job.payload?.normalizedTransition ? captureNormalized(job, options)
|
|
319
324
|
: runCapture(job.originProjectDir || projectDir, job.event, options);
|
|
320
|
-
committed = result?.progressionCaptured === true && Boolean(result.receipt);
|
|
325
|
+
committed = now() < deadlineAt && !signal?.aborted && result?.progressionCaptured === true && Boolean(result.receipt);
|
|
321
326
|
if (committed) onCaptured?.(result);
|
|
322
327
|
}
|
|
323
328
|
} catch { /* retain the queue until an exact-readback receipt exists */ }
|
|
@@ -327,7 +332,7 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
327
332
|
} catch { /* the debt stays durable; the next boundary hands it on again */ } finally {
|
|
328
333
|
releaseReplayLock(projectDir, held);
|
|
329
334
|
}
|
|
330
|
-
held = now() < deadlineAt && queuedWork(projectDir) ? takeReplayLock(projectDir) : null;
|
|
335
|
+
held = !signal?.aborted && now() < deadlineAt && queuedWork(projectDir) ? takeReplayLock(projectDir) : null;
|
|
331
336
|
}
|
|
332
337
|
if (held) releaseReplayLock(projectDir, held);
|
|
333
338
|
return replayed;
|
|
@@ -338,9 +343,11 @@ export function runOutboxReplay({ projectDir, token = process.env.RUVNET_REPLAY_
|
|
|
338
343
|
export function drainCaptureQueue({ projectDir, budgetMs = 1000, ...options } = {}) {
|
|
339
344
|
if (operatorProgressionSuspension(options.env || process.env)) return { state: 'suspended', replayed: 0, pending: null };
|
|
340
345
|
const startedAt = Date.now();
|
|
341
|
-
const
|
|
346
|
+
const deadlineAt = Math.min(options.deadlineAt ?? Infinity, startedAt + budgetMs);
|
|
347
|
+
if (options.signal?.aborted || startedAt >= deadlineAt) return { state: 'pending', replayed: 0, pending: null, reason: 'restore deadline exceeded' };
|
|
348
|
+
const resolution = resolveProjectStore({ projectDir, gitTimeoutMs: Math.max(1, Math.min(300, budgetMs)), deadlineAt });
|
|
342
349
|
const root = resolution.projectRoot;
|
|
343
|
-
const replayed = runOutboxReplay({ ...options, projectDir: root, budgetMs: Math.max(0,
|
|
350
|
+
const replayed = runOutboxReplay({ ...options, deadlineAt, projectDir: root, budgetMs: Math.max(0, deadlineAt - Date.now()) });
|
|
344
351
|
let outboxPending = 0;
|
|
345
352
|
try { outboxPending = new ProgressionOutbox({ projectRoot: root }).pendingSnapshots().length; } catch { return { state: 'degraded', replayed, pending: null }; }
|
|
346
353
|
const pending = queuedWork(root) + outboxPending;
|