ruvnet-brain 4.5.16 → 4.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -19
- package/bin/developer-update.mjs +3 -0
- package/bin/install.mjs +15 -3
- package/bin/nightly-refresh.mjs +17 -1
- package/config/practical-rule-catalog.json +111 -0
- package/console/CONTRACT.md +47 -3
- package/console/app.js +112 -20
- package/package.json +5 -2
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/commands/brain-console.md +13 -1
- package/plugin/commands/configure.md +2 -2
- package/plugin/commands/rnb.md +13 -1
- package/plugin/commands/rnbc.md +13 -1
- package/plugin/commands/rvbc.md +13 -1
- package/plugin/commands/rvcb.md +13 -1
- package/plugin/hooks/codex-hooks.json +13 -1
- package/plugin/hooks/hook-contracts.json +20 -4
- package/plugin/scripts/adr-currency-gate.mjs +75 -31
- package/plugin/scripts/advocacy-outcomes.mjs +18 -4
- package/plugin/scripts/agentdb-recall.mjs +161 -26
- package/plugin/scripts/automatic-update.mjs +11 -2
- package/plugin/scripts/capability-inventory-receipt.mjs +16 -7
- package/plugin/scripts/capability-registry.mjs +55 -114
- package/plugin/scripts/capacity-aware-parallel-work.mjs +1 -1
- package/plugin/scripts/codex-hook-adapter.mjs +33 -6
- package/plugin/scripts/codex-hook-wrapper.mjs +23 -3
- package/plugin/scripts/continuation-gate.mjs +2 -1
- package/plugin/scripts/continuity-brief.mjs +55 -22
- package/plugin/scripts/continuity-events.mjs +93 -17
- package/plugin/scripts/continuity-hook-policy.mjs +1 -1
- package/plugin/scripts/continuity-journal.mjs +77 -29
- package/plugin/scripts/decision-gate.mjs +62 -37
- package/plugin/scripts/decision-outcomes.mjs +192 -199
- package/plugin/scripts/detach.mjs +53 -13
- package/plugin/scripts/developer-update-cleanup.mjs +38 -0
- package/plugin/scripts/developer-update-lock.mjs +30 -0
- package/plugin/scripts/developer-update-maintenance.mjs +178 -0
- package/plugin/scripts/developer-update-owner.mjs +24 -0
- package/plugin/scripts/developer-update-policy.mjs +48 -0
- package/plugin/scripts/developer-update.mjs +435 -0
- package/plugin/scripts/doc-currency.mjs +993 -0
- package/plugin/scripts/ground-ruvnet.sh +16 -3
- package/plugin/scripts/grounding-answer.mjs +18 -16
- package/plugin/scripts/grounding-stamp.sh +3 -0
- package/plugin/scripts/grounding-turn-evidence.mjs +60 -65
- package/plugin/scripts/grounding-turn-gate.mjs +34 -29
- package/plugin/scripts/grounding-turn-mark.mjs +101 -17
- package/plugin/scripts/hook-context-budget.mjs +90 -0
- package/plugin/scripts/hook-registry.mjs +129 -1
- package/plugin/scripts/hook-shim.mjs +20 -2
- package/plugin/scripts/host-update.mjs +6 -3
- package/plugin/scripts/lesson-gate.mjs +1 -1
- package/plugin/scripts/nightly-scheduler.mjs +55 -16
- package/plugin/scripts/plugin-artifact-proof.mjs +135 -0
- package/plugin/scripts/project-capture-queue.mjs +22 -15
- package/plugin/scripts/project-progression-producer.mjs +15 -8
- package/plugin/scripts/project-progression-reader.mjs +14 -4
- package/plugin/scripts/project-progression-session-start.mjs +29 -17
- package/plugin/scripts/project-progression-sources.mjs +24 -10
- package/plugin/scripts/project-progression-store.mjs +7 -2
- package/plugin/scripts/project-store-resolver.mjs +10 -7
- package/plugin/scripts/project-transition-hook.mjs +20 -12
- package/plugin/scripts/session-snapshot-hook.mjs +20 -9
- package/plugin/scripts/session-start-budget.mjs +18 -0
- package/plugin/scripts/session-start-core.mjs +32 -6
- package/plugin/scripts/session-start-trace.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -5
- package/plugin/scripts/unprompted-runtime.mjs +36 -10
- package/plugin/scripts/user-settings.mjs +7 -1
- package/plugin/skills/ruvnet-brain/SKILL.md +2 -2
- package/scripts/codex-managed-terminal.mjs +1 -1
- package/scripts/console-runtime-identity.mjs +2 -0
- package/scripts/console-suite-update.mjs +117 -0
- package/scripts/doc-currency.mjs +6 -990
- package/scripts/hook-qualify-core.mjs +30 -1
- package/scripts/model-managed-prompt.mjs +1 -1
- package/scripts/model-managed-workflow-service.mjs +113 -14
- package/scripts/model-routing-controller.mjs +112 -21
- package/scripts/onboarding-console.mjs +41 -12
- package/scripts/practical-rule-selector.mjs +51 -0
- package/scripts/product-integrity-contract.mjs +11 -4
- package/scripts/release-qualification-contract.mjs +14 -0
- package/scripts/remedy-registry.mjs +1 -0
- package/scripts/source-scope-receipt.mjs +9 -4
- package/scripts/stack-sync.mjs +19 -132
|
@@ -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
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
813
|
-
if (
|
|
814
|
-
if (
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
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
|
|
148
|
-
*
|
|
149
|
-
*
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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
|
-
|
|
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 }); }
|
|
149
|
-
|
|
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
|
-
|
|
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 ? ` ·
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
}
|