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.
Files changed (86) hide show
  1. package/README.md +15 -19
  2. package/bin/developer-update.mjs +3 -0
  3. package/bin/install.mjs +15 -3
  4. package/bin/nightly-refresh.mjs +17 -1
  5. package/config/practical-rule-catalog.json +111 -0
  6. package/console/CONTRACT.md +47 -3
  7. package/console/app.js +112 -20
  8. package/package.json +5 -2
  9. package/plugin/.claude-plugin/plugin.json +1 -1
  10. package/plugin/.codex-plugin/plugin.json +1 -1
  11. package/plugin/commands/brain-console.md +13 -1
  12. package/plugin/commands/configure.md +2 -2
  13. package/plugin/commands/rnb.md +13 -1
  14. package/plugin/commands/rnbc.md +13 -1
  15. package/plugin/commands/rvbc.md +13 -1
  16. package/plugin/commands/rvcb.md +13 -1
  17. package/plugin/hooks/codex-hooks.json +13 -1
  18. package/plugin/hooks/hook-contracts.json +20 -4
  19. package/plugin/scripts/adr-currency-gate.mjs +75 -31
  20. package/plugin/scripts/advocacy-outcomes.mjs +18 -4
  21. package/plugin/scripts/agentdb-recall.mjs +161 -26
  22. package/plugin/scripts/automatic-update.mjs +11 -2
  23. package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
  24. package/plugin/scripts/capability-registry.mjs +55 -114
  25. package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
  26. package/plugin/scripts/codex-hook-adapter.mjs +33 -6
  27. package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
  28. package/plugin/scripts/continuation-gate.mjs +2 -1
  29. package/plugin/scripts/continuity-brief.mjs +55 -22
  30. package/plugin/scripts/continuity-events.mjs +93 -17
  31. package/plugin/scripts/continuity-hook-policy.mjs +1 -1
  32. package/plugin/scripts/continuity-journal.mjs +77 -29
  33. package/plugin/scripts/decision-gate.mjs +62 -37
  34. package/plugin/scripts/decision-outcomes.mjs +192 -199
  35. package/plugin/scripts/detach.mjs +53 -13
  36. package/plugin/scripts/developer-update-cleanup.mjs +38 -0
  37. package/plugin/scripts/developer-update-lock.mjs +30 -0
  38. package/plugin/scripts/developer-update-maintenance.mjs +178 -0
  39. package/plugin/scripts/developer-update-owner.mjs +24 -0
  40. package/plugin/scripts/developer-update-policy.mjs +48 -0
  41. package/plugin/scripts/developer-update.mjs +435 -0
  42. package/plugin/scripts/doc-currency.mjs +993 -0
  43. package/plugin/scripts/ground-ruvnet.sh +16 -3
  44. package/plugin/scripts/grounding-answer.mjs +18 -16
  45. package/plugin/scripts/grounding-stamp.sh +3 -0
  46. package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
  47. package/plugin/scripts/grounding-turn-gate.mjs +34 -29
  48. package/plugin/scripts/grounding-turn-mark.mjs +101 -17
  49. package/plugin/scripts/hook-context-budget.mjs +90 -0
  50. package/plugin/scripts/hook-registry.mjs +129 -1
  51. package/plugin/scripts/hook-shim.mjs +20 -2
  52. package/plugin/scripts/host-update.mjs +6 -3
  53. package/plugin/scripts/lesson-gate.mjs +1 -1
  54. package/plugin/scripts/nightly-scheduler.mjs +55 -16
  55. package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
  56. package/plugin/scripts/project-capture-queue.mjs +22 -15
  57. package/plugin/scripts/project-progression-producer.mjs +15 -8
  58. package/plugin/scripts/project-progression-reader.mjs +14 -4
  59. package/plugin/scripts/project-progression-session-start.mjs +29 -17
  60. package/plugin/scripts/project-progression-sources.mjs +24 -10
  61. package/plugin/scripts/project-progression-store.mjs +7 -2
  62. package/plugin/scripts/project-store-resolver.mjs +10 -7
  63. package/plugin/scripts/project-transition-hook.mjs +20 -12
  64. package/plugin/scripts/session-snapshot-hook.mjs +20 -9
  65. package/plugin/scripts/session-start-budget.mjs +18 -0
  66. package/plugin/scripts/session-start-core.mjs +32 -6
  67. package/plugin/scripts/session-start-trace.mjs +2 -1
  68. package/plugin/scripts/turn-outcome-capture.mjs +7 -5
  69. package/plugin/scripts/unprompted-runtime.mjs +36 -10
  70. package/plugin/scripts/user-settings.mjs +7 -1
  71. package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
  72. package/scripts/codex-managed-terminal.mjs +1 -1
  73. package/scripts/console-runtime-identity.mjs +2 -0
  74. package/scripts/console-suite-update.mjs +117 -0
  75. package/scripts/doc-currency.mjs +6 -990
  76. package/scripts/hook-qualify-core.mjs +30 -1
  77. package/scripts/model-managed-prompt.mjs +1 -1
  78. package/scripts/model-managed-workflow-service.mjs +113 -14
  79. package/scripts/model-routing-controller.mjs +112 -21
  80. package/scripts/onboarding-console.mjs +41 -12
  81. package/scripts/practical-rule-selector.mjs +51 -0
  82. package/scripts/product-integrity-contract.mjs +11 -4
  83. package/scripts/release-qualification-contract.mjs +14 -0
  84. package/scripts/remedy-registry.mjs +1 -0
  85. package/scripts/source-scope-receipt.mjs +9 -4
  86. 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 file under ~/.cache/ruvnet-brain/grounding-turn/<session_id>, whose
22
- * MTIME is the signal (same idiom grounding-stamp.sh and ground-before-write.sh already use for
23
- * their own 24h stamps — this reuses that mtime-comparison convention rather than inventing a new
24
- * one). Its CONTENT is a JSON blob for a human reading the cache, but the Stop-time gate only ever
25
- * trusts the mtime.
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
- const prev = prior && (!st || now - st.mtimeMs < STALE_MS) ? prior : null;
156
- const next = prev ? { ...meta, at: prev.at, gate1: prev.gate1 || arm.gate1, assert: prev.assert || arm.assert,
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, { sessionId: hookInput.session_id, turnId: hookInput.turn_id || hookInput.prompt_id || null });
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
- const r = spawnSync(cmd, [file, ...extraArgs], { ...io, env });
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 source = updateSource();
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;