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,6 +18,7 @@
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
20
  import { spawn } from 'node:child_process';
21
+ import { contextBlock, contextFrame, readContextFrame, emitOwnedContext } from './hook-context-budget.mjs';
21
22
  import { fileURLToPath } from 'node:url';
22
23
  // resolveBash ONLY. `skipNoBash` is not a predicate — it is a one-time notice emitter that returns 0
23
24
  // and WRITES TO STDERR, which on this hot path is the refusal channel: calling it would have injected
@@ -240,6 +241,36 @@ export function decide(verdicts) {
240
241
  return { allow: false, refusals: refusals.map((v) => v.id), reason };
241
242
  }
242
243
 
244
+ /** Policy advice is context, separate from the winning refusal and the speech channel. */
245
+ export function policyContext(results, speechOutput = '') {
246
+ const contexts = [];
247
+ for (const result of results) {
248
+ if (result.skipped) contexts.push(`Policy ${result.id} did not obtain a verdict (${result.skipped}${result.reason ? `: ${String(result.reason).slice(0, 4096)}` : ''}).`);
249
+ if (result.code !== ALLOW || !result.stdout?.trim()) continue;
250
+ let parsed; try { parsed = JSON.parse(result.stdout); } catch {}
251
+ const text = typeof parsed?.hookSpecificOutput?.additionalContext === 'string'
252
+ ? parsed.hookSpecificOutput.additionalContext : result.stdout.trim();
253
+ if (text) contexts.push(text.slice(0, 4096));
254
+ }
255
+ if (process.env.RUVNET_HOOK_CONTEXT_BUDGET === '1') {
256
+ const speechFrame = readContextFrame(speechOutput);
257
+ const blocks = contexts.map((text) => contextBlock(text, { id: 'policy-diagnostic', critical: true }));
258
+ if (speechFrame) blocks.push(...speechFrame.blocks);
259
+ else if (speechOutput.trim()) { let value; try { value = JSON.parse(speechOutput); } catch {}
260
+ const text = value?.hookSpecificOutput?.additionalContext || speechOutput.trim();
261
+ blocks.push(contextBlock(text, { id: 'unframed-speech-preserved', critical: true })); }
262
+ if (!blocks.length) return '';
263
+ return emitOwnedContext(contextFrame('decision-gate', 'PreToolUse', blocks));
264
+ }
265
+ if (!contexts.length) return speechOutput;
266
+ let speech; try { speech = JSON.parse(speechOutput); } catch {}
267
+ const prior = typeof speech?.hookSpecificOutput?.additionalContext === 'string'
268
+ ? speech.hookSpecificOutput.additionalContext : speechOutput.trim();
269
+ return JSON.stringify({ ...(speech && typeof speech === 'object' && !Array.isArray(speech) ? speech : {}),
270
+ hookSpecificOutput: { ...(speech?.hookSpecificOutput || {}), hookEventName: 'PreToolUse',
271
+ additionalContext: [...new Set([prior, ...contexts].filter(Boolean))].join('\n') } });
272
+ }
273
+
243
274
  const firstLine = (s) => String(s || '').trim().split('\n').map((l) => l.trim()).filter(Boolean)[0] || '';
244
275
 
245
276
  /** Payload accessors — tolerant, because a malformed payload must degrade to "no measurement". */
@@ -258,23 +289,21 @@ function payloadInput(payload) { return parsed(payload).tool_input || {}; }
258
289
  function speechEventFor(event) { return event === 'bash' ? 'PreToolUse-bash' : 'PreToolUse-write'; }
259
290
 
260
291
  // ── Runtime ──────────────────────────────────────────────────────────────────────────────────────
261
-
262
-
263
292
  if (isMain()) {
264
293
  const started = Date.now();
265
294
  const payload = normalizePayloadText(readPayload());
295
+ const shape = classifyPayload(payload);
296
+ const session = sessionOf(payload) || (shape.truncated ? shape.session : '');
266
297
  const selected = policiesFor(EVENT);
267
298
  // An unknown event is not an occasion to refuse anything. Same rule as unprompted-runtime's
268
299
  // "never speak on a guess", pointed at the other decision. 'write' is the only registered route
269
300
  // (H5 removed the dead 'bash' one — see the REGISTRY comment above), so it is the only exception.
270
301
  if (!selected.length && EVENT !== 'write') process.exit(ALLOW);
271
-
272
302
  const budgetMs = Number(process.env.RUVNET_DECISION_BUDGET_MS) || DEFAULT_BUDGET_MS;
273
- const deadline = started + budgetMs;
303
+ const deadline = Math.min(started + budgetMs, Number(process.env.RUVNET_DECISION_DEADLINE) || Infinity);
274
304
  // Resolved ONCE. On win32 resolveBash() can shell out to `where.exe`; four bash policies meant up
275
305
  // to four of those per tool call, for an answer that cannot change mid-invocation.
276
306
  BASH = resolveBash();
277
-
278
307
  const trace = []; // one row per policy — surfaced by RUVNET_DECISION_TRACE=1
279
308
  const unconsulted = []; // policies the budget cost us. NEVER silent; see reportBudget().
280
309
  const toolInput = payloadInput(payload);
@@ -284,7 +313,6 @@ if (isMain()) {
284
313
  if (why) trace.push({ id: p.id, ms: 0, skipped: why });
285
314
  else consulted.push(p);
286
315
  }
287
-
288
316
  // ── PARALLEL, and the reason is arithmetic ─────────────────────────────────────────────────────
289
317
  // Sequentially the gate's wall time was SUM(policies); run together it is MAX(policies). Measured
290
318
  // on a `git push` payload by the 2026-08-13 audit: 182 + 345 + 2145..3990 + 629 + 743 ≈ 4.0-5.9s
@@ -299,41 +327,36 @@ if (isMain()) {
299
327
  // A policy that exits SKIPPED (3) did not vote and said why (duplicate-gate under overload). Same
300
328
  // two channels as a blown budget, below — never silence standing in for a verdict.
301
329
  const selfSkipped = results.filter((r) => r.skipped === 'self');
302
-
303
330
  const decision = decide(verdicts);
304
-
305
- // ── OBEDIENCE MEASUREMENT (ADR-067 §outcomes) ──────────────────────────────────────────────────
306
- // This gate is the ONLY thing that sees every Write/Edit/Bash, so it can close the loop with no new
307
- // hook: resolve first (did this call retry something we refused?), then open a new debt if we are
308
- // about to refuse. Order matters — resolving after recording would close the debt we just opened.
309
- // Entirely best-effort: measurement may never affect the verdict, so it runs after `decide`.
310
- // An oversize payload is cut mid-JSON (see TRANSPORT_CAP_BYTES): recover session/tool/path from its
311
- // prefix so its refusals and allows are counted like any other.
312
- const shape = classifyPayload(payload);
313
- const session = sessionOf(payload) || (shape.truncated ? shape.session : '');
331
+ // Measurement follows the FINAL gate verdict; it must never delay a blocking producer.
332
+ const measureFinalDecision = (allowed, policies = decision.refusals) => {
333
+ const metricDeadline = Math.min(deadline, Date.now() + 25);
314
334
  try {
315
335
  const key = shape.truncated
316
336
  ? actionKey(shape.tool, { file_path: shape.filePath })
317
337
  : actionKey(payloadTool(payload), toolInput);
318
338
  const ts = Date.now();
319
339
  if (session && key) {
320
- sweepStale({ session, ts }); // debts from dead sessions become `abandoned`, never vanish
321
- resolveOutcome({ session, key, allowed: decision.allow, ts });
322
- if (!decision.allow) recordRefusal({ session, key, policies: decision.refusals, ts });
340
+ let observed = {}; try { observed = JSON.parse(payload); } catch { /* truncated payload: adapter/process scope */ }
341
+ const attribution = { host: observed.host || process.env.RUVNET_HOOK_HOST || 'claude',
342
+ project: observed.cwd || process.env.CLAUDE_PROJECT_DIR || process.cwd() };
343
+ sweepStale({ session, ts, deadlineAt: metricDeadline, nonBlocking: true }); // only proven observation age expires debt; foreign sessions are not abandoned
344
+ resolveOutcome({ session, key, allowed: allowed, ts, ...attribution, deadlineAt: metricDeadline, nonBlocking: true });
345
+ if (!allowed) recordRefusal({ session, key, policies: policies, ts, ...attribution, deadlineAt: metricDeadline, nonBlocking: true });
323
346
  }
324
347
  } catch { /* a ledger must never break a tool call */ }
325
-
348
+ };
326
349
  reportSelfSkipped({ session, selfSkipped });
327
350
  if (decision.allow && shape.truncated) {
328
351
  // OVERSIZE POLICY (header): allowed on a check of the first TRANSPORT_CAP_BYTES only. Recorded, never silent.
329
352
  try { appendOutcome({ kind: 'payload-oversize', event: EVENT, session, bytesSeen: shape.bytes, capBytes: TRANSPORT_CAP_BYTES, tool: shape.tool, filePath: shape.filePath, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
330
353
  }
331
354
  if (!decision.allow) {
332
- reportBudget({ session, unconsulted, trace, started, budgetMs });
355
+ measureFinalDecision(false);
356
+ reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
333
357
  process.stderr.write(`${decision.reason}\n`);
334
358
  process.exit(REFUSE);
335
359
  }
336
-
337
360
  // Nothing refused: run the speech chokepoint and forward its envelope verbatim. It owns its own
338
361
  // per-channel policy; this gate does not inspect or re-decide anything it says.
339
362
  //
@@ -347,18 +370,23 @@ if (isMain()) {
347
370
  const speech = await runPolicy(SPEECH, payload, deadline, speechEventFor(EVENT), trace);
348
371
  if (speech.skipped === 'budget') unconsulted.push(SPEECH.id);
349
372
  if (speech.code === REFUSE) {
350
- reportBudget({ session, unconsulted, trace, started, budgetMs });
373
+ measureFinalDecision(false, [...decision.refusals, SPEECH.id]);
374
+ reportBudget({ session, unconsulted, trace, started, budgetMs, refused: true });
351
375
  process.stderr.write(`${(speech.stderr || '').trim()}\n`);
352
376
  process.exit(REFUSE);
353
377
  }
354
378
  reportBudget({ session, unconsulted, trace, started, budgetMs });
355
- if (speech.stdout?.trim()) process.stdout.write(speech.stdout);
379
+ measureFinalDecision(true);
380
+ const context = policyContext(results, speech.stdout || '');
381
+ if (context) process.stdout.write(context);
356
382
  process.exit(ALLOW);
357
383
  }
358
384
  reportBudget({ session, unconsulted, trace, started, budgetMs });
385
+ measureFinalDecision(true);
386
+ const context = policyContext([...results, ...unconsulted.filter((id) => !results.some((r) => r.id === id && r.skipped)).map((id) => ({ id, skipped: 'budget' }))]);
387
+ if (context) process.stdout.write(context);
359
388
  process.exit(ALLOW);
360
389
  }
361
-
362
390
  /**
363
391
  * ── A BUDGET THAT CAN BE EXCEEDED SILENTLY FAILS OPEN WITHOUT SAYING SO ──────────────────────────
364
392
  *
@@ -378,7 +406,7 @@ if (isMain()) {
378
406
  * the applicability skip, an ordinary write measures ~360ms against a 4000ms budget, so a trip
379
407
  * there is not noise to be tolerated — it is the defect, and the suite should go red for it.
380
408
  */
381
- function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
409
+ function reportBudget({ session, unconsulted, trace, started, budgetMs, refused = false }) {
382
410
  const elapsed = Date.now() - started;
383
411
  if (process.env.RUVNET_DECISION_TRACE === '1') {
384
412
  process.stderr.write(`[decision-gate] ${EVENT} ${elapsed}ms budget=${budgetMs}ms ${JSON.stringify(trace)}\n`);
@@ -388,19 +416,17 @@ function reportBudget({ session, unconsulted, trace, started, budgetMs }) {
388
416
  appendOutcome({ kind: 'budget-exceeded', event: EVENT, session, unconsulted, elapsedMs: elapsed, budgetMs, ts: Date.now() });
389
417
  } catch { /* a ledger must never break a tool call */ }
390
418
  process.stderr.write(
391
- `[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ALLOWED WITHOUT CONSULTING: `
392
- + `${unconsulted.join(', ')}. These policies did not vote; this allow is a timeout, not a verdict.\n`,
419
+ `[decision-gate] ${budgetMs}ms budget exhausted after ${elapsed}ms — ${refused ? 'REFUSED; UNCONSULTED' : 'ALLOWED WITHOUT CONSULTING'}: `
420
+ + `${unconsulted.join(', ')}. These policies did not vote; ${refused ? 'another policy refused the action.' : 'this allow is a timeout, not a verdict.'}\n`,
393
421
  );
394
422
  }
395
-
396
423
  /** Record and print each policy that skipped itself (exit 3), with its own one-line reason. */
397
424
  function reportSelfSkipped({ session, selfSkipped }) {
398
425
  for (const r of selfSkipped) {
399
426
  try { appendOutcome({ kind: 'policy-skipped', event: EVENT, session, policy: r.id, reason: r.reason, ts: Date.now() }); } catch { /* a ledger must never break a tool call */ }
400
- process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; this allow is not its verdict.\n`);
427
+ process.stderr.write(`[decision-gate] ${r.reason || `${r.id} skipped`} — ${r.id} did not vote; it supplied no authorization verdict.\n`);
401
428
  }
402
429
  }
403
-
404
430
  /**
405
431
  * Run one policy as a CAPTURED child. Never lets its bytes touch the real streams.
406
432
  *
@@ -427,19 +453,19 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
427
453
  if (extraArg) args.push(extraArg);
428
454
  const left = deadline - Date.now();
429
455
  if (left <= 0) return Promise.resolve(done({ id: p.id, skipped: 'budget' }));
430
-
431
456
  return new Promise((resolve) => {
432
457
  let settled = false;
433
458
  let timer = null;
434
459
  const finish = (r) => { if (settled) return; settled = true; clearTimeout(timer); resolve(done(r)); };
435
460
  let child;
436
461
  try {
437
- child = spawn(cmd, args, { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, RUVNET_DECISION_GATE: '1', RUVNET_DECISION_DEADLINE: String(deadline) } });
462
+ child = spawn(cmd, args, { detached: process.platform !== 'win32', stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, RUVNET_DECISION_GATE: '1', RUVNET_DECISION_DEADLINE: String(deadline) } });
438
463
  } catch { return finish({ id: p.id, skipped: 'spawn' }); }
439
464
  // SIGKILL, not SIGTERM: a bash policy that has spawned its own child (jq, node, ruflo) can sit in
440
465
  // a TERM handler, and the host's own kill is what we are racing. The whole batch shares ONE
441
466
  // deadline, so a single slow policy cancels only the time it actually consumed.
442
- timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch { /* already gone */ } finish({ id: p.id, skipped: 'budget' }); }, left);
467
+ const retireTree = () => { try { if (process.platform !== 'win32') process.kill(-child.pid, 'SIGKILL'); else child.kill('SIGKILL'); } catch { /* already gone */ } };
468
+ timer = setTimeout(() => { retireTree(); finish({ id: p.id, skipped: 'budget' }); }, Math.max(1, left - 100));
443
469
  let stdout = ''; let stderr = ''; let bytes = 0;
444
470
  const MAX = 1 << 20; // same ceiling spawnSync's maxBuffer enforced; a policy is not a data source
445
471
  child.stdout.on('data', (d) => { if (bytes < MAX) { stdout += d; bytes += d.length; } });
@@ -449,6 +475,7 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
449
475
  // Unhandled, that error event would take the whole gate down and turn an allow into a hook error.
450
476
  child.stdin.on('error', () => { /* the child did not want the payload; that is not a failure */ });
451
477
  child.on('close', (code) => {
478
+ retireTree();
452
479
  // A spawn failure, a timeout, or any code other than 0/2 is an ERROR — and an error here must
453
480
  // never be mistaken for a refusal. That distinction is the one lesson-gate.mjs had to learn twice.
454
481
  if (code === SKIPPED_SELF) return finish({ id: p.id, skipped: 'self', reason: firstLine(stderr) });
@@ -458,12 +485,10 @@ function runPolicy(p, payload, deadline, extraArg, trace) {
458
485
  try { child.stdin.end(payload); } catch { /* handled by the stdin error listener above */ }
459
486
  });
460
487
  }
461
-
462
488
  function readPayload() {
463
489
  if (process.stdin.isTTY) return '';
464
490
  try { return fs.readFileSync(0, 'utf8'); } catch { return ''; }
465
491
  }
466
-
467
492
  /** Never able to crash a caller that merely imported this (see tests/unit/entrypoint-guard-safety). */
468
493
  function isMain() {
469
494
  try {