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
@@ -356,6 +356,45 @@ function countCaptureCommands(groups) {
356
356
  return n;
357
357
  }
358
358
 
359
+ /** Read declared capture wiring only; plugin cache presence is not activation or execution. */
360
+ export function captureDeclarationCounts({ home = HOME, project = process.cwd() } = {}) {
361
+ const counts = { pre: 0, end: 0, unknown: false };
362
+ const enabled = {};
363
+ const settings = [...new Set([path.join(home, '.claude/settings.json'), path.join(project, '.claude/settings.json'), path.join(project, '.claude/settings.local.json')])];
364
+ const add = (hooks) => {
365
+ if (hooks !== undefined && (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))) { counts.unknown = true; return; }
366
+ const pre = countCaptureCommands(hooks?.PreCompact), end = countCaptureCommands(hooks?.SessionEnd);
367
+ if (pre === null || end === null) counts.unknown = true;
368
+ else { counts.pre += pre; counts.end += end; }
369
+ };
370
+ for (const file of settings) {
371
+ const result = readJSON(file);
372
+ if (result.missing) continue;
373
+ if (result.err) { counts.unknown = true; continue; }
374
+ add(result.value?.hooks);
375
+ if (result.value?.enabledPlugins !== undefined && (!result.value.enabledPlugins || typeof result.value.enabledPlugins !== 'object' || Array.isArray(result.value.enabledPlugins))) counts.unknown = true;
376
+ else Object.assign(enabled, result.value?.enabledPlugins || {});
377
+ }
378
+ const active = Object.entries(enabled).filter(([, value]) => value === true).map(([id]) => id);
379
+ if (active.length) {
380
+ const installed = readJSON(path.join(home, '.claude/plugins/installed_plugins.json'));
381
+ if (installed.missing || installed.err || !installed.value?.plugins || typeof installed.value.plugins !== 'object') counts.unknown = true;
382
+ else for (const id of active) {
383
+ const entries = installed.value.plugins[id];
384
+ if (!Array.isArray(entries) || !entries.length) { counts.unknown = true; continue; }
385
+ const candidates = entries.filter((entry) => entry?.scope !== 'project' || entry.projectPath === project);
386
+ if (candidates.length !== 1) { counts.unknown = true; continue; }
387
+ for (const entry of candidates) {
388
+ if (typeof entry?.installPath !== 'string' || !path.isAbsolute(entry.installPath)) { counts.unknown = true; continue; }
389
+ const manifest = readJSON(path.join(entry.installPath, 'hooks/hooks.json'));
390
+ if (manifest.missing || manifest.err) { counts.unknown = true; continue; }
391
+ add(manifest.value?.hooks);
392
+ }
393
+ }
394
+ }
395
+ return counts;
396
+ }
397
+
359
398
  // ── The capabilities ─────────────────────────────────────────────────────────────────────────────
360
399
  // Ordered by blast radius: the ones whose dormancy costs the most sit at the top, because this list
361
400
  // is rendered in order and nobody reads to the bottom.
@@ -578,70 +617,15 @@ export const CAPABILITIES = [
578
617
  // already standing in a ruvnet-brain checkout; everyone else got `Cannot find module`. A real
579
618
  // executor behind an unreachable path is a dead button with extra steps.
580
619
  turnOn: selfTurnOn('Route one read-only task through the cheap path', 'route-cheap.mjs', '--task "<text>"'),
581
- detect() {
582
- const bin = path.join(HOME, '.npm-global/bin/agentic-flow');
583
- const installed = fs.existsSync(bin);
584
- const receipts = process.env.METAHARNESS_RECEIPTS || path.join(HOME, '.claude/metaharness/routing-receipts.jsonl');
585
- const n = lineCount(receipts);
586
-
587
- // Receipts are the proof, and they outrank installation: a receipt file with lines means this
588
- // genuinely ran, even if the binary later moved. Absence of the binary AND of receipts is the
589
- // only honest 'absent'.
590
- if (n === null && !fs.existsSync(receipts)) {
591
- return installed
592
- ? row(STATE.OFF, 'agentic-flow is installed but no routing receipt has ever been written — the cheap path exists and has never been used')
593
- : row(STATE.ABSENT, 'agentic-flow is not installed and no routing receipts exist, so cheap routing has never been set up here');
594
- }
595
- if (n === null) return row(STATE.UNKNOWN, 'the routing receipt ledger exists but could not be read — usage not checked');
596
- if (n === 0) return row(STATE.OFF, 'the routing receipt ledger is present but empty — no task has been routed to a cheaper model');
597
- const age = daysSince(mtimeOf(receipts));
598
-
599
- // THE AGE NOW DECIDES, INSTEAD OF DECORATING. This line used to return ON for any n > 0 and
600
- // merely MENTION the age in the evidence — so a router with 38 receipts and nothing invoking it
601
- // for a fortnight read as healthy. We were holding the disproving fact and printing it politely.
602
- //
603
- // 7 days: this path should fire on ordinary sessions, so a full quiet week means something
604
- // upstream stopped calling it — not that the user had a light week. Measured on this machine
605
- // 2026-07-24: 38 receipts, last one 4.8 days old, and the PreToolUse gate that invokes it
606
- // (plugin/scripts/route-dispatch.sh, written 2026-07-13) had never been added to settings.json.
607
- // Built, correct, and unwired — which no state in this registry could previously express.
608
- // MEASURE THE CAUSE, NOT A SYMPTOM. An age threshold alone is a proxy and it FAILED on the real
609
- // case: measured 2026-07-24, the last receipt was 5 days old — under any sane horizon — while the
610
- // router was in fact never being consulted at all. A quiet week and a severed wire look identical
611
- // from the receipt file, so read the wire directly.
612
- //
613
- // Two things must both be true for the host-limited dispatch audit to record anything: a
614
- // PreToolUse hook on subagent dispatch and the opt-in profile it refuses to act without
615
- // (route-dispatch.sh exits 0 when
616
- // profile.json is absent). Either missing ⇒ the router cannot fire, regardless of how healthy
617
- // the receipt ledger looks.
618
- const profile = fs.existsSync(path.join(HOME, '.claude/model-router/profile.json'));
619
- // ONE READING OF THE WIRING, from the module that owns it — see dispatchGateWiring(). Scanning
620
- // settings.json here was a second, narrower implementation of that question, and it answered
621
- // "not wired" for every plugin-marketplace install (issue #112).
622
- const gate = dispatchGateWiring();
623
- const gateWired = gate.wired;
624
- // THE ONE RULE OF THIS FILE. A census we could not take is not a gate we observed to be
625
- // missing, and "nothing can invoke it" is a claim about the user's machine.
626
- if (gate.unreadable) return row(STATE.UNKNOWN, `${n} routing receipt${n === 1 ? '' : 's'} recorded, but the hook registries on this machine could not be read — whether anything is wired to invoke the router was not checked`);
627
-
628
- if (!gateWired || !profile) {
629
- const missing = [!gateWired && 'no PreToolUse gate on Task|Agent is wired to route-dispatch.sh',
630
- !profile && 'no ~/.claude/model-router/profile.json (the opt-in the gate requires)'].filter(Boolean).join('; and ');
631
- return row(STATE.IDLE,
632
- `set up and proven — ${n} routing receipt${n === 1 ? '' : 's'} recorded — but nothing can invoke it: ${missing}. `
633
- + 'Every receipt so far came from someone running the router by hand. Until the gate is wired, subagents keep '
634
- + 'inheriting this session\'s model, which is the single largest cost leak in the harness.');
635
- }
636
-
637
- const IDLE_AFTER_DAYS = 7;
638
- if (age !== null && age > IDLE_AFTER_DAYS) {
639
- return row(STATE.IDLE,
640
- `set up and proven — ${n} routing receipt${n === 1 ? '' : 's'} recorded — but nothing has routed through it in ${age} days. `
641
- + 'It is configured; something that should be calling it is not. Check that the subagent-dispatch gate is wired '
642
- + '(a PreToolUse hook on Task|Agent) and that ~/.claude/model-router/profile.json exists — without either, the router is never consulted.');
620
+ detect({ home = HOME } = {}) {
621
+ const config = readJSON(path.join(home, '.cache/ruvnet-brain/model-routing/terminal-launcher-config.json'));
622
+ if (config.err) return row(STATE.UNKNOWN, 'managed native routing settings are unreadable; routing and savings are not inferred');
623
+ if (!config.missing) {
624
+ const value = config.value;
625
+ if (!value || !path.isAbsolute(value.runtimeRoot || '') || !/^[a-f0-9]{64}$/.test(value.runtimeDigest || '')) return row(STATE.UNKNOWN, 'managed routing declaration is incomplete; no execution claim is made');
626
+ return row(STATE.UNKNOWN, 'a managed native routing runtime is configured. The retired Task dispatch hook is not a prerequisite. Configuration alone does not prove normal entry wiring, model allocation, execution or savings; those runtime facts were not checked by this declaration-only probe.');
643
627
  }
644
- return row(STATE.ON, `${n} routing receipt${n === 1 ? '' : 's'} recorded${age === null ? '' : `, most recent ${age} day${age === 1 ? '' : 's'} ago`}`);
628
+ return row(STATE.UNKNOWN, 'no managed routing declaration was observed in this inspected location; legacy Task routing receipts or binary presence do not establish current native routing or its absence');
645
629
  },
646
630
  },
647
631
 
@@ -733,17 +717,9 @@ export const CAPABILITIES = [
733
717
  {
734
718
  key: 'harness-evolution',
735
719
  label: 'Harness self-improvement',
736
- // Issue #116: this was `turnOn: null`, justified by a "VERIFIED NULL: evolve is not among them"
737
- // measurement that has since drifted — ruflo v3.34.0 ships evolve, bench and flywheel. The
738
- // precondition is named in the human text because brain-score/SKILL.md:97 requires the WRITE
739
- // layer's OPENROUTER_API_KEY to be disclosed rather than discovered on failure.
740
- turnOn: {
741
- human: 'Evolve the harness and keep only measured winners (needs OPENROUTER_API_KEY; without it, `--subcommand score` is the free read-only layer)',
742
- cmd: 'ruflo metaharness --subcommand evolve',
743
- },
744
720
  whatItBuysYou: 'The rules your AI works by get tested against each other, and the version that measurably does better becomes the new default.',
745
721
  scope: SCOPE.MACHINE,
746
- // VERIFIED NULL: `ruflo metaharness --help` enumerates its subcommands and `evolve` is not among them.
722
+ // No automatic enable action: policy evolution has separate authority and potential spend.
747
723
  turnOn: null,
748
724
  detect({ project = process.cwd() } = {}) {
749
725
  const policy = path.join(HOME, '.claude-flow/harness-active-policy.json');
@@ -808,48 +784,13 @@ export const CAPABILITIES = [
808
784
  scope: SCOPE.MACHINE,
809
785
  // Registering hooks means editing settings.json by hand — no single verified command.
810
786
  turnOn: null,
811
- detect() {
812
- const r = readJSON(path.join(HOME, '.claude/settings.json'));
813
- if (r.missing) return row(STATE.ABSENT, 'no Claude Code settings file exists on this machine yet');
814
- if (r.err) return row(STATE.UNKNOWN, `the settings file could not be parsed (${r.err}) — capture hooks not checked`);
815
- const hooksRoot = r.value?.hooks;
816
- if (hooksRoot !== undefined && (!hooksRoot || typeof hooksRoot !== 'object' || Array.isArray(hooksRoot))) {
817
- return row(STATE.UNKNOWN, 'the settings file has a hooks section this version cannot interpret — capture hooks not counted');
818
- }
819
- const hooks = hooksRoot || {};
820
- // COUNT COMMANDS, NOT MATCHER GROUPS. See countHookCommands: `[{matcher:'.*',hooks:[]}]` has
821
- // length 1 and executes nothing, and the old `.length` check called that "both boundaries are
822
- // covered" — a fabricated ON on a machine that saves nothing.
823
- //
824
- // AND COUNT *CAPTURE* COMMANDS, NOT ANY COMMAND. The old count accepted whatever was wired at
825
- // those two boundaries, so a shell logger and a terminal beep — neither of which saves a byte of
826
- // session state — produced "Session capture: ON". MEASURED with exactly that pair. The boundary
827
- // a command is attached to says WHEN it runs, never WHAT it does, and this row's whole claim is
828
- // about what it does. A command is only counted when it names a mechanism known to persist state.
829
- //
830
- // A MALFORMED GROUP POISONS THE COUNT rather than being skipped — the same rule, and the same
831
- // words, as learning-enable.readSettingsWiring, which documents at length why skipping an
832
- // unparseable entry and reporting the remainder as a total is this project's signature lie.
833
- // MEASURED: a PreCompact written as an object instead of an array was silently skipped and the
834
- // row reported OFF — "nothing is saved when a session compacts" — about a machine whose capture
835
- // hook we simply failed to parse. Identical structure to the bug fixed in that file, opposite
836
- // treatment, same commit.
837
- const pre = countCaptureCommands(hooks.PreCompact);
838
- const end = countCaptureCommands(hooks.SessionEnd);
839
- if (pre === null || end === null) {
840
- return row(STATE.UNKNOWN, `the ${pre === null ? 'pre-compaction' : 'session-end'} hook list could not be parsed, so whether anything is registered there cannot be read — no conclusion is drawn from the half that did parse`);
841
- }
842
- // "registered", never "capturing" — the same standard the MCP row holds itself to twenty lines
843
- // below. A settings entry proves a command is wired to fire; no local artifact proves it ever
844
- // ran or that it succeeded when it did, and claiming captured state from a config file would be
845
- // exactly the fabricated status this registry exists to refuse.
846
- if (pre && end) return row(STATE.ON, 'a state-saving hook is registered at both boundaries: one before compaction and one at session end — registered, which is not the same as proven to have captured anything');
847
- // ON, not OFF: one boundary IS covered. Partially configured is not never-used — half the
848
- // sessions are being saved today, and calling that "off" both understates what they have and
849
- // invites them to re-enable a thing already running. The gap is named in the evidence, which is
850
- // where a real but partial shortfall belongs. Found by GPT-5.6-Sol, 2026-07-24.
851
- if (pre || end) return row(STATE.ON, `a state-saving hook is registered only at ${pre ? 'the pre-compaction' : 'the session-end'} boundary — the other one loses its state`);
852
- return row(STATE.OFF, 'no hook that saves session state is registered at either boundary, so nothing is kept when a session compacts or closes');
787
+ detect({ home = HOME, project = process.cwd() } = {}) {
788
+ const { pre, end, unknown } = captureDeclarationCounts({ home, project });
789
+ if (unknown) return row(STATE.UNKNOWN, 'capture declarations could not be read completely; no absence or execution conclusion is drawn');
790
+ if (pre && end) return row(STATE.ON, 'commands naming capture mechanisms are declared at compaction and session end, including explicitly enabled installed-plugin manifests. Declared wiring is not proof of native activation or successful capture.');
791
+ if (pre || end) return row(STATE.ON, `a command naming a capture mechanism is declared at ${pre ? 'pre-compaction' : 'session end'} only in the inspected sources; actual capture and the other boundary remain unproved`);
792
+ return row(STATE.UNKNOWN, 'no state-saving declaration was observed in the inspected sources; runtime and invocation-only plugin hooks were not checked and may still exist');
793
+
853
794
  },
854
795
  },
855
796
 
@@ -178,7 +178,7 @@ function collectLoadOnly() {
178
178
  return { normalizedLoad: load / cpuCount, loadAvg1: load };
179
179
  }
180
180
 
181
- function collectMacPressure() {
181
+ export function collectMacPressure() {
182
182
  if (process.platform !== 'darwin') return collectLoadOnly();
183
183
  try {
184
184
  // One bounded subprocess gathers pressure, swap, and compressor bytes. Never use raw free RAM
@@ -28,6 +28,7 @@
28
28
  * is missing), so a non-zero status is forwarded verbatim and never reinterpreted.
29
29
  */
30
30
  import fs from 'node:fs';
31
+ import { readContextFrame, contextFrame, selectContextFrame, renderContextFrame, recordContextBudget } from './hook-context-budget.mjs';
31
32
  import path from 'node:path';
32
33
  import { spawnSync } from 'node:child_process';
33
34
  import { fileURLToPath } from 'node:url';
@@ -115,6 +116,7 @@ const env = {
115
116
  CLAUDE_PLUGIN_ROOT: String(process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT || ''),
116
117
  CLAUDE_PROJECT_DIR: projectDir,
117
118
  RUVNET_HOOK_HOST: 'codex',
119
+ RUVNET_CODEX_CONTEXT_FRAMES: '1',
118
120
  };
119
121
 
120
122
  // Dream Cycle 2026-09-05. Codex's own dispatch trampoline (codex-hooks.json) never passes `cwd:`
@@ -131,7 +133,8 @@ let shimCwd = process.cwd();
131
133
  try { if (fs.statSync(projectDir).isDirectory()) shimCwd = projectDir; } catch { /* keep the default */ }
132
134
 
133
135
  const runShim = (payload) => spawnSync(process.execPath, [shim, hookId, ...process.argv.slice(3)], {
134
- input: payload, encoding: 'utf8', env, cwd: shimCwd,
136
+ input: payload, encoding: 'utf8', env: { ...env, ...(BUDGET_MS ? { RUVNET_DECISION_DEADLINE: String(Date.now() + Math.max(1, BUDGET_MS - spent() - 350)) } : {}) }, cwd: shimCwd,
137
+ ...(BUDGET_MS ? { timeout: Math.max(1, BUDGET_MS - spent() - 100), killSignal: 'SIGKILL' } : {}),
135
138
  });
136
139
 
137
140
  /**
@@ -144,9 +147,9 @@ const runShim = (payload) => spawnSync(process.execPath, [shim, hookId, ...proce
144
147
  * away from the failure.
145
148
  *
146
149
  * BOUNDED, because the wrapper SIGKILLs this process at its own budget and a kill is invisible. The
147
- * wrapper hands its budget down; iteration stops at 75% of it and ALLOWS, which is decision-gate's
148
- * own rule for a blown budget ("a blown budget ALLOWS and says nothing"): the gate's slowness must
149
- * never be indistinguishable from the user doing something wrong.
150
+ * wrapper hands its budget down; advisory iteration may stop at 75% of it. A PreToolUse write-policy
151
+ * decision instead refuses the whole patch if any remaining path is unchecked, with explicit
152
+ * unchecked scope; a timeout can never authorize an uninspected write.
150
153
  */
151
154
  const BUDGET_MS = Number(process.env.RUVNET_CODEX_BUDGET_MS) || 0;
152
155
  const started = Date.now();
@@ -180,20 +183,44 @@ if (patchTool && hookId === 'md-stamp') {
180
183
  }
181
184
 
182
185
  const stdouts = [];
183
- for (const payload of payloads) {
186
+ const guardedPatch = patchTool && hookId === 'decision-gate' && event === 'PreToolUse';
187
+ // Materialize every path before consulting any policy; a move checks both ends.
188
+ const patchScope = files.map((file) => path.resolve(projectDir, file));
189
+ const refuseUnchecked = (index) => {
190
+ process.stderr.write(`Patch refused: write-policy inspection incomplete for ${JSON.stringify(patchScope.slice(index))}. Split the patch or retry after the bounded policy check is available.\n`);
191
+ process.exit(2);
192
+ };
193
+ if (guardedPatch && !patchScope.length) refuseUnchecked(0);
194
+ for (const [index, payload] of payloads.entries()) {
195
+ if (guardedPatch && BUDGET_MS && BUDGET_MS - spent() <= 100) refuseUnchecked(index);
184
196
  const r = runShim(payload);
185
197
  // A refusal (or any error) from ANY file is the decision for the whole patch, forwarded verbatim
186
198
  // and immediately — there is nothing to compose once one wall has said no.
199
+ if (guardedPatch && (r.error || r.signal || r.status === null)) refuseUnchecked(index);
187
200
  if (r.status && r.stderr) process.stderr.write(r.stderr);
188
201
  if (r.status) process.exit(r.status);
189
202
  if (r.stdout) stdouts.push(r.stdout);
190
- if (BUDGET_MS && spent() > BUDGET_MS * 0.75) break;
203
+ if (BUDGET_MS && spent() > BUDGET_MS * 0.75 && index + 1 < payloads.length) {
204
+ if (guardedPatch) refuseUnchecked(index + 1);
205
+ break;
206
+ }
191
207
  }
192
208
 
193
209
  if (!stdouts.length) process.exit(0);
194
210
 
195
211
  /** Merge N bodies' output into ONE value. Envelopes join by context; anything else joins as text. */
196
212
  function merge(outs) {
213
+ const frames = outs.map((value) => readContextFrame(value));
214
+ if (frames.every((frame) => frame && frame.event === event && frame.handler === hookId)) {
215
+ const selected = selectContextFrame(contextFrame(hookId, event, frames.flatMap((frame) => frame.blocks)));
216
+ recordContextBudget({ ...selected.receipt, stage: 'codex-final-fanout-merge' }, env);
217
+ return renderContextFrame(selected.frame);
218
+ }
219
+ if (frames.some(Boolean)) {
220
+ recordContextBudget({ handler: hookId, event, scope: 'mixed-framed-unframed-budget-unknown' }, env);
221
+ outs = outs.map((value, index) => frames[index] ? renderContextFrame({ ...frames[index], event }) : value);
222
+ }
223
+ if (!frames.some(Boolean)) recordContextBudget({ handler: hookId, event, scope: 'unframed-output-budget-unknown' }, env);
197
224
  if (outs.length === 1) return outs[0];
198
225
  const parsedAll = outs.map((s) => { try { return JSON.parse(s); } catch { return null; } });
199
226
  const contexts = parsedAll.map((p) => p?.hookSpecificOutput?.additionalContext);
@@ -60,8 +60,10 @@ const blockingHooks = new Set([
60
60
  const DETACHED_HOOKS = new Set(['learn-flush']);
61
61
 
62
62
  /** THE BUDGET IS DERIVED FROM WHAT THE HOOK MEASURABLY COSTS, never from what looks tidy. */
63
- function timeoutFor(hookId, event = '') {
63
+ function timeoutFor(hookId, event = '', sessionBudgetMs = 0) {
64
64
  const override = Number(process.env.RUVNET_CODEX_HOOK_TIMEOUT_MS);
65
+ if (hookId === 'session-start') return Math.floor(Math.min(sessionBudgetMs,
66
+ Number.isFinite(override) && override > 0 ? override : Infinity));
65
67
  if (Number.isFinite(override) && override > 0) return override;
66
68
  // decision-gate's own internal budget is 4000ms (RUVNET_DECISION_BUDGET_MS) and it is allowed to
67
69
  // spend all of it: measured 986–4015ms across ten runs in a project this plugin does not own. A
@@ -198,17 +200,35 @@ if (DETACHED_HOOKS.has(hookId)) {
198
200
  process.exit(0);
199
201
  }
200
202
 
201
- const budgetMs = timeoutFor(hookId, process.argv[3] || '');
203
+ let sessionDeadlineAt;
204
+ if (hookId === 'session-start') {
205
+ try {
206
+ const { sessionStartDeadlineAt } = await import(path.join(root, 'scripts', 'session-start-budget.mjs'));
207
+ sessionDeadlineAt = sessionStartDeadlineAt({ host: 'codex', env: process.env, startedAt: performance.timeOrigin });
208
+ } catch {
209
+ process.stderr.write('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN] SessionStart host budget unavailable; restoration was not attempted.\n');
210
+ process.exit(0);
211
+ }
212
+ }
213
+ const budgetMs = timeoutFor(hookId, process.argv[3] || '', Math.max(0, (sessionDeadlineAt || 0) - Date.now()));
214
+ if (hookId === 'session-start' && budgetMs < 1) {
215
+ process.stderr.write('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN] SessionStart deadline exhausted before restoration.\n');
216
+ process.exit(0);
217
+ }
202
218
  const result = spawnSync(process.execPath, [adapter, ...process.argv.slice(2)], {
203
219
  input,
204
220
  encoding: 'utf8',
205
221
  // The adapter may fan one multi-file patch out into several body runs, and only this process knows
206
222
  // when the axe falls. Hand the budget down so it can stop and fail open instead of being killed
207
223
  // mid-loop with nothing written — a SIGKILL here is invisible to the host and to the user.
208
- env: { ...process.env, RUVNET_CODEX_BUDGET_MS: String(budgetMs) },
224
+ env: { ...process.env, RUVNET_CODEX_BUDGET_MS: String(budgetMs),
225
+ ...(sessionDeadlineAt ? { RUVNET_SESSION_START_DEADLINE_AT: String(Math.min(sessionDeadlineAt, Date.now() + budgetMs)) } : {}) },
209
226
  timeout: budgetMs,
210
227
  killSignal: 'SIGKILL',
211
228
  });
229
+ if (hookId === 'session-start' && result.status !== 0) {
230
+ process.stderr.write('[RuvNet Brain — PROJECT CONTINUITY UNKNOWN] SessionStart did not complete inside its host deadline; no restoration success is claimed.\n');
231
+ }
212
232
 
213
233
  // A broken optional Brain hook must never degrade the host. The only non-zero status that carries
214
234
  // product meaning is an intentional exit-2 refusal from a hook whose contract is blocking.
@@ -392,7 +392,8 @@ function promiseBookkeeping() {
392
392
  if (!assistantCommitmentOwned(item, hookInput.session_id, projectIdentity)) continue;
393
393
  const claim = completion.claims.find((c) => claimClosesPromise(c.text, item.text));
394
394
  if (!claim) continue;
395
- Object.assign(item, { done: true, state: 'completed', doneAt: at, completionEvidence: { claim: claim.text,
395
+ const verifiedCompletion = completion.verdict === 'PASS' && Boolean(claim);
396
+ Object.assign(item, { done: verifiedCompletion, state: verifiedCompletion ? 'completed' : item.state, doneAt: at, completionEvidence: { claim: claim.text,
396
397
  checks: completion.verification.checks.slice(-5).map((c) => c.what),
397
398
  transcript: String(hookInput.transcript_path || ''), sessionId: hookInput.session_id } });
398
399
  changed = true;
@@ -32,6 +32,7 @@ import { withProgressionReader } from './project-progression-reader.mjs';
32
32
  import { readWorkLedger } from './project-progression-sources.mjs';
33
33
  import { resolveProjectStore } from './project-store-resolver.mjs';
34
34
  import { restoreProgressionForSession } from './project-progression-session-start.mjs';
35
+ import { STAGE_BUDGETS_MS } from './session-start-budget.mjs';
35
36
  import { projectDirectory } from './project-identity.mjs';
36
37
  import { CONTINUITY_NAMESPACE, EVENT_KINDS, makeEvent, userLevelAgentdbHooks } from './continuity-events.mjs';
37
38
  import { ContinuityJournal, drain, launchDrain, recordingLine, storeReady } from './continuity-journal.mjs';
@@ -50,8 +51,20 @@ const OWNED_DIR = 'continuity-owned';
50
51
  const MAX_STORE_EVENTS = 600;
51
52
  const SECTION_CAPS = Object.freeze({ commit: 6, release: 3, decision: 5, lesson: 6, gate: 4, finding: 3, open: 5 });
52
53
 
53
- function git(cwd, args) {
54
- try { return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 2000 }).trim(); } catch { return ''; }
54
+ function checkDeadline({ deadlineAt = Infinity, signal } = {}) {
55
+ if (signal?.aborted || Date.now() >= deadlineAt) throw new Error('restore deadline exceeded');
56
+ }
57
+ function git(cwd, args, options = {}) {
58
+ checkDeadline(options);
59
+ try {
60
+ const value = execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
61
+ timeout: Math.max(1, Math.floor(Math.min(2000, (options.deadlineAt ?? Infinity) - Date.now()))), killSignal: 'SIGKILL' }).trim();
62
+ checkDeadline(options); return value;
63
+ } catch (error) {
64
+ checkDeadline(options);
65
+ if (error.code === 'ETIMEDOUT' && Number.isFinite(options.deadlineAt)) throw new Error('restore deadline exceeded');
66
+ return '';
67
+ }
55
68
  }
56
69
  const hhmm = (iso) => String(iso || '').replace(/:\d\d\.\d+Z$|:\d\dZ$/, 'Z');
57
70
  /** Data-safe one line: no control or bidi characters, no fence tokens, whitespace collapsed, capped. */
@@ -93,35 +106,42 @@ function appendOwned(file, record) {
93
106
  }
94
107
 
95
108
  /** Every continuity event: committed rows (read-only) plus pending outbox rows, oldest first. */
96
- export function readEvents(journal, { maxEvents = MAX_STORE_EVENTS } = {}) {
109
+ export function readEvents(journal, { maxEvents = MAX_STORE_EVENTS, deadlineAt = Infinity, signal } = {}) {
110
+ const options = { deadlineAt, signal }; checkDeadline(options);
97
111
  const byKey = new Map();
98
112
  if (storeReady(journal.db)) {
99
113
  const read = withProgressionReader(journal.db, (reader) => {
100
114
  const keys = reader.listKeys(CONTINUITY_NAMESPACE, { maxEntries: 200_000 }).slice(-maxEvents);
101
115
  return keys.map((key) => ({ key, content: reader.readContent(CONTINUITY_NAMESPACE, key) }));
102
- });
116
+ }, options);
117
+ if (!read.ok && Number.isFinite(deadlineAt)) throw new Error('continuity brief store read unavailable');
103
118
  if (read.ok) {
104
119
  for (const { key, content } of read.value) {
120
+ checkDeadline(options);
105
121
  try { byKey.set(key, { key, event: JSON.parse(content), pending: false }); } catch { /* malformed row: not an event */ }
106
122
  }
107
123
  }
108
124
  }
109
- for (const rec of journal.pending()) if (!byKey.has(rec.key)) byKey.set(rec.key, { key: rec.key, event: rec.event, pending: true });
125
+ checkDeadline(options);
126
+ for (const rec of journal.pending()) { checkDeadline(options); if (!byKey.has(rec.key)) byKey.set(rec.key, { key: rec.key, event: rec.event, pending: true }); }
110
127
  return [...byKey.values()].sort((a, b) => (a.key < b.key ? -1 : 1));
111
128
  }
112
129
 
113
130
  /** The owner's `lesson-*` keys (project namespace, default, lessons) — read only when no user hook shows them. */
114
- function ownerLessons(db, namespaces) {
131
+ function ownerLessons(db, namespaces, options = {}) {
132
+ checkDeadline(options);
115
133
  if (!storeReady(db)) return [];
116
134
  const read = withProgressionReader(db, (reader) => {
117
135
  const out = [];
118
136
  for (const ns of namespaces) {
119
137
  for (const key of reader.listKeys(ns, { maxEntries: 200_000 })) {
138
+ checkDeadline(options);
120
139
  if (ns === 'lessons' || key.startsWith('lesson-')) out.push({ key, ns, content: reader.readContent(ns, key) });
121
140
  }
122
141
  }
123
142
  return out;
124
- });
143
+ }, options);
144
+ if (!read.ok && Number.isFinite(options.deadlineAt)) throw new Error('continuity brief lesson read unavailable');
125
145
  return read.ok ? read.value : [];
126
146
  }
127
147
 
@@ -143,25 +163,28 @@ const tag = (row) => {
143
163
  * an adopted project (no `.swarm`), where there is nothing to come up to speed on.
144
164
  */
145
165
  export function buildBrief({ projectDir, env = process.env, home = os.homedir(), now = Date.now(), limitBytes = BRIEF_LIMIT_BYTES,
146
- pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), persistState = true } = {}) {
166
+ pluginRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'), persistState = true,
167
+ deadlineAt = Infinity, signal } = {}) {
168
+ const options = { deadlineAt, signal }; checkDeadline(options);
147
169
  let resolution;
148
- try { resolution = resolveProjectStore({ projectDir }); } catch { return { context: '' }; }
149
- const journal = new ContinuityJournal({ projectRoot: resolution.projectRoot, now: () => now });
170
+ try { resolution = resolveProjectStore({ projectDir, deadlineAt }); }
171
+ catch (error) { if (Number.isFinite(deadlineAt)) throw error; return { context: '' }; }
172
+ const journal = new ContinuityJournal({ projectRoot: resolution.projectRoot, now: () => now, deadlineAt, signal });
150
173
  try { if (!fs.lstatSync(journal.swarm).isDirectory()) return { context: '' }; } catch { return { context: '' }; }
151
174
  const state = readState(journal);
152
175
  const since = Number(state.lastBriefAt) || now - 86_400_000;
153
- const rows = readEvents(journal);
176
+ const rows = readEvents(journal, options);
154
177
  const status = journal.status();
155
178
  const of = (kind) => rows.filter((r) => r.event?.kind === kind);
156
179
  const user = userLevelAgentdbHooks({ home });
157
180
 
158
181
  const root = resolution.checkoutRoot;
159
- const branch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD']);
160
- const headLine = git(root, ['log', '-1', '--format=%h %s']);
161
- const latestTag = git(root, ['describe', '--tags', '--abbrev=0']);
182
+ const branch = git(root, ['rev-parse', '--abbrev-ref', 'HEAD'], options);
183
+ const headLine = git(root, ['log', '-1', '--format=%h %s'], options);
184
+ const latestTag = git(root, ['describe', '--tags', '--abbrev=0'], options);
162
185
  let version = '';
163
186
  try { version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version || ''; } catch { /* not a node project */ }
164
- const changed = git(root, ['log', '-n20', `--since=${new Date(since).toISOString()}`, '--format=%h %s', 'HEAD']).split('\n').filter(Boolean);
187
+ const changed = git(root, ['log', '-n20', `--since=${new Date(since).toISOString()}`, '--format=%h %s', 'HEAD'], options).split('\n').filter(Boolean);
165
188
  const newTags = of('release').filter((r) => Date.parse(r.event.at) >= since);
166
189
 
167
190
  const ledger = readWorkLedger({ projectId: resolution.projectIdentity.id, env, home });
@@ -181,7 +204,7 @@ export function buildBrief({ projectDir, env = process.env, home = os.homedir(),
181
204
  if (user.ensure) {
182
205
  lessonNote = '(owner lesson-* keys and project-state-current are printed by your user-level agentdb-ensure hook; not repeated)';
183
206
  } else {
184
- for (const l of ownerLessons(journal.db, [path.basename(resolution.projectRoot), 'default', 'lessons'])) {
207
+ for (const l of ownerLessons(journal.db, [path.basename(resolution.projectRoot), 'default', 'lessons'], options)) {
185
208
  storeLessonLines.push(`• ${oneLine(l.content)} [${oneLine(`${l.ns}/${l.key}`, 80)}]`);
186
209
  }
187
210
  }
@@ -190,7 +213,7 @@ export function buildBrief({ projectDir, env = process.env, home = os.homedir(),
190
213
  { name: 'STANDING RULES (you recorded these with --record on this machine)', cap: SECTION_CAPS.lesson, items: ruleLines.reverse() },
191
214
  ];
192
215
  const data = [
193
- { name: 'SINCE LAST SESSION', cap: SECTION_CAPS.commit, intro: `(${new Date(since).toISOString().slice(0, 16)}Z → now; git, read live): ${changed.length}${changed.length === 20 ? '+' : ''} commit(s) on ${oneLine(branch, 80) || '?'}${newTags.length ? `; releases: ${newTags.map((r) => oneLine(r.event.detail?.tag, 60)).join(', ')}` : ''}`,
216
+ { name: 'SINCE LAST SESSION', cap: SECTION_CAPS.commit, intro: `(${new Date(since).toISOString().slice(0, 16)}Z → now; git, read live): ${changed.length}${changed.length === 20 ? '+' : ''} commit(s) on ${oneLine(branch, 80) || '?'}${newTags.length ? `; local tags (publication unverified): ${newTags.map((r) => oneLine(r.event.detail?.tag, 60)).join(', ')}` : ''}`,
194
217
  items: changed.map((c) => `• ${oneLine(c, 140)}`) },
195
218
  { name: 'DECISIONS', cap: SECTION_CAPS.decision, items: of('decision').reverse().map((r) => `• ${oneLine(r.event.summary)} ${tag(r)}`) },
196
219
  { name: 'LESSONS FOUND IN THE PROJECT STORE (not confirmed as yours)', cap: SECTION_CAPS.lesson, intro: lessonNote, items: storeLessonLines.reverse() },
@@ -202,7 +225,7 @@ export function buildBrief({ projectDir, env = process.env, home = os.homedir(),
202
225
  const sections = [...trusted, ...data];
203
226
 
204
227
  const head = [`${BRIEF_HEADER} — ${oneLine(path.basename(resolution.projectRoot), 80)} · AgentDB ${oneLine(journal.db, 300)}]`];
205
- const now_ = `NOW: ${oneLine(branch, 80) || 'no branch'} @ ${oneLine(headLine, 160) || 'no commits'}${version ? ` · package ${oneLine(version, 40)}` : ''}${latestTag ? ` · latest tag ${oneLine(latestTag, 60)}` : ''} (git, live)`;
228
+ const now_ = `NOW: ${oneLine(branch, 80) || 'no branch'} @ ${oneLine(headLine, 160) || 'no commits'}${version ? ` · package ${oneLine(version, 40)}` : ''}${latestTag ? ` · local tag ${oneLine(latestTag, 60)} (publication unverified)` : ''} (git, live)`;
206
229
  const tail = [
207
230
  recordingLine(status, now),
208
231
  `MORE: ${env.RUVNET_HOOK_HOST === 'codex' ? '' : '/ruvnet-brain:rnb-brief, or '}node "${path.join(pluginRoot, 'scripts', 'continuity-brief.mjs')}" --full [--kind ${EVENT_KINDS.join('|')}] [--since 7d]`,
@@ -235,6 +258,7 @@ export function buildBrief({ projectDir, env = process.env, home = os.homedir(),
235
258
  const room = Math.max(0, Buffer.byteLength(dataText, 'utf8') - (Buffer.byteLength(context, 'utf8') - limitBytes) - 80);
236
259
  context = render(caps, `${Buffer.from(dataText).subarray(0, room).toString('utf8').replace(/�$/, '')}\n[CUT to fit — the rest is in AgentDB: run the MORE command]`);
237
260
  }
261
+ checkDeadline(options);
238
262
  if (persistState) writeState(journal, { lastBriefAt: now });
239
263
  return { context, status, counts: Object.fromEntries(EVENT_KINDS.map((k) => [k, of(k).length])), journal };
240
264
  }
@@ -243,13 +267,22 @@ export function buildBrief({ projectDir, env = process.env, home = os.homedir(),
243
267
  * SessionStart's continuity stage: the brief first, then the ADR-073 progression restore. A brief
244
268
  * failure never costs the restore; pending events get a detached drainer here too.
245
269
  */
246
- export async function restoreWithBrief({ env = process.env, cwd = process.cwd(), restore = restoreProgressionForSession, launch = launchDrain } = {}) {
247
- const restored = await restore({ env, cwd });
270
+ export async function restoreWithBrief({ env = process.env, cwd = process.cwd(), restore = restoreProgressionForSession, launch = launchDrain,
271
+ deadlineAt = Infinity, signal } = {}) {
272
+ deadlineAt = Math.min(deadlineAt, Date.now() + STAGE_BUDGETS_MS.restore);
273
+ const options = { deadlineAt, signal };
274
+ const restored = await restore({ env, cwd, ...options });
248
275
  let brief = { context: '' };
249
276
  try {
250
- brief = buildBrief({ projectDir: env.CLAUDE_PROJECT_DIR || cwd, env, home: env.HOME || os.homedir() });
277
+ checkDeadline(options);
278
+ brief = buildBrief({ projectDir: env.CLAUDE_PROJECT_DIR || cwd, env, home: env.HOME || os.homedir(), ...options });
279
+ checkDeadline(options);
251
280
  if (brief.status?.pending && brief.status.applicable) launch({ projectRoot: brief.journal.projectRoot, env });
252
- } catch { /* the restore still stands on its own */ }
281
+ } catch (error) {
282
+ const reason = /deadline|timed out/i.test(error.message) ? 'deadline-exceeded' : 'brief-read-failed';
283
+ return { ...(restored || {}), degraded: true, brief: { status: 'unknown', reason },
284
+ context: `[RuvNet Brain — PROJECT CONTINUITY UNKNOWN]\nThe startup brief is incomplete (${reason}); no complete brief restoration is claimed.\n${restored?.context || ''}` };
285
+ }
253
286
  if (!brief.context) return restored;
254
287
  return { ...(restored || {}), brief: brief.status, context: restored?.context ? `${brief.context}\n${restored.context}` : brief.context };
255
288
  }