ruvnet-brain 4.7.20 → 4.7.21
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 +2 -2
- package/bin/install.mjs +494 -97
- package/kb/download-retry.mjs +93 -21
- package/kb/forge-guard-injection.mjs +277 -0
- package/kb/forge-update.mjs +166 -38
- package/kb/lifecycle-evidence-retention.mjs +151 -14
- package/kb/model-requirements.mjs +4 -1
- package/kb/refresh-run.mjs +175 -27
- package/kb/verify-citation.mjs +15 -2
- package/package.json +3 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/mcp/managed-cli-interface.mjs +16 -8
- package/plugin/scripts/adr-currency-gate.mjs +2 -1
- package/plugin/scripts/advisory-contract.mjs +21 -0
- package/plugin/scripts/advocacy-route.mjs +5 -4
- package/plugin/scripts/agentdb-recall.mjs +15 -5
- package/plugin/scripts/automatic-update.mjs +9 -1
- package/plugin/scripts/brain-confirmation.mjs +2 -1
- package/plugin/scripts/brain-footprint.mjs +72 -11
- package/plugin/scripts/capability-registry.mjs +1 -1
- package/plugin/scripts/capacity-aware-parallel-work.mjs +2 -1
- package/plugin/scripts/codex-overnight-notice.mjs +33 -0
- package/plugin/scripts/completion-claim-evidence.mjs +56 -12
- package/plugin/scripts/continuation-gate.mjs +28 -9
- package/plugin/scripts/decision-gate.mjs +13 -63
- package/plugin/scripts/decision-outcomes.mjs +36 -0
- package/plugin/scripts/decision-recall-gate.mjs +40 -5
- package/plugin/scripts/decision-record.mjs +8 -3
- package/plugin/scripts/degradation-watch.mjs +2 -1
- package/plugin/scripts/developer-update-lock.mjs +172 -33
- package/plugin/scripts/developer-update-owner.mjs +8 -5
- package/plugin/scripts/developer-update-policy.mjs +4 -1
- package/plugin/scripts/developer-update.mjs +155 -53
- package/plugin/scripts/doc-currency.mjs +2 -16
- package/plugin/scripts/duplicate-gate.mjs +2 -1
- package/plugin/scripts/entrypoint.mjs +34 -0
- package/plugin/scripts/footprint-io.mjs +135 -8
- package/plugin/scripts/grok-hook-adapter.mjs +2 -1
- package/plugin/scripts/ground-ruvnet.sh +6 -3
- package/plugin/scripts/grounding-answer.mjs +2 -1
- package/plugin/scripts/grounding-code-projection.mjs +2 -1
- package/plugin/scripts/grounding-substance.mjs +2 -1
- package/plugin/scripts/grounding-turn-gate.mjs +2 -2
- package/plugin/scripts/grounding-turn-mark.mjs +2 -2
- package/plugin/scripts/hook-context-budget.mjs +2 -1
- package/plugin/scripts/hook-input.mjs +2 -2
- package/plugin/scripts/hook-shim.mjs +9 -2
- package/plugin/scripts/host-update.mjs +29 -5
- package/plugin/scripts/identifier-preflight.mjs +2 -1
- package/plugin/scripts/kb-copy-proof.mjs +57 -9
- package/plugin/scripts/learn-capture.mjs +2 -1
- package/plugin/scripts/learning-store.mjs +62 -16
- package/plugin/scripts/lesson-bridge.mjs +2 -2
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/md-stamp.mjs +2 -1
- package/plugin/scripts/native-turn-transcript.mjs +98 -6
- package/plugin/scripts/nightly-scheduler.mjs +10 -5
- package/plugin/scripts/package-recommender.mjs +2 -2
- package/plugin/scripts/project-store-resolver.mjs +19 -0
- package/plugin/scripts/routing-outcome-capture.mjs +2 -1
- package/plugin/scripts/session-snapshot-hook.mjs +24 -4
- package/plugin/scripts/session-start-core.mjs +5 -15
- package/plugin/scripts/session-start-health.mjs +97 -10
- package/plugin/scripts/session-start-issue-alert.mjs +63 -0
- package/plugin/scripts/session-start-update-plane.mjs +15 -1
- package/plugin/scripts/signal-watch.mjs +2 -1
- package/plugin/scripts/spend-guard.mjs +2 -1
- package/plugin/scripts/substitution-wall.mjs +2 -1
- package/plugin/scripts/turn-outcome-capture.mjs +7 -3
- package/plugin/scripts/unprompted-runtime.mjs +20 -3
- package/plugin/scripts/update-apply.mjs +186 -15
- package/plugin/skills/release-proof/scripts/release-proof.mjs +2 -1
- package/plugin/skills/ruvnet-brain/PLAYBOOK.md +2 -2
- package/plugin/skills/ruvnet-brain/SKILL.md +3 -2
- package/scripts/adr-072-completion.mjs +2 -1
- package/scripts/agentdb-context.mjs +2 -1
- package/scripts/agentdb-fleet-doctor.mjs +30 -8
- package/scripts/approved-runtime.mjs +2 -1
- package/scripts/architecture-review-lock.mjs +2 -1
- package/scripts/ascii-drift.mjs +2 -1
- package/scripts/brain-latency-50.mjs +2 -1
- package/scripts/brain-novice-50.mjs +2 -16
- package/scripts/brain-score.mjs +13 -2
- package/scripts/build-bundle.mjs +2 -1
- package/scripts/build-concepts.mjs +2 -1
- package/scripts/candidate-ci-receipt.mjs +2 -1
- package/scripts/candidate-host-evidence.mjs +2 -1
- package/scripts/card-from-source.mjs +2 -1
- package/scripts/ci/warm-brain-models.mjs +2 -1
- package/scripts/claims-verify.mjs +7 -3
- package/scripts/claude-controlled-terminal.mjs +2 -1
- package/scripts/claude-terminal-mod.mjs +2 -1
- package/scripts/code-release-corpus.mjs +2 -1
- package/scripts/codex-hook-trust-reconcile.mjs +67 -1
- package/scripts/codex-hook-trust.mjs +40 -1
- package/scripts/codex-hook-wrapper-history.mjs +2 -1
- package/scripts/codex-host-execution-proof.mjs +2 -1
- package/scripts/completion-claim-replay.mjs +2 -1
- package/scripts/console-engine.mjs +8 -3
- package/scripts/corpus-canary.mjs +2 -1
- package/scripts/corpus-candidate.mjs +2 -2
- package/scripts/corpus-coverage-sidecar.mjs +2 -1
- package/scripts/corpus-dispatch-decision.mjs +2 -1
- package/scripts/corpus-dispatch-receipt.mjs +2 -1
- package/scripts/corpus-next-seed.mjs +2 -1
- package/scripts/corpus-qa.mjs +2 -1
- package/scripts/corpus-reconcile.mjs +2 -2
- package/scripts/corpus-watchdog.mjs +2 -1
- package/scripts/count-chunks.mjs +2 -1
- package/scripts/customer-state-matrix.mjs +2 -1
- package/scripts/derive-passage-content-map.mjs +2 -1
- package/scripts/development-push-check.mjs +2 -1
- package/scripts/dispatch-receipt.mjs +2 -1
- package/scripts/doc-currency.mjs +2 -1
- package/scripts/dream-issue-gate.mjs +2 -1
- package/scripts/dual-host-deliberation.mjs +2 -16
- package/scripts/dual-host-suggest.mjs +2 -16
- package/scripts/eval-brain.mjs +7 -3
- package/scripts/execution-policy.mjs +2 -1
- package/scripts/execution-preflight.mjs +2 -1
- package/scripts/falsify.mjs +2 -1
- package/scripts/fix-metaharness-memretrieve.mjs +2 -1
- package/scripts/fix-workstream.mjs +2 -1
- package/scripts/full-hints.mjs +2 -1
- package/scripts/full-suite-gate.mjs +2 -1
- package/scripts/grok-host.mjs +68 -0
- package/scripts/hook-qualify-core.mjs +3 -1
- package/scripts/hook-qualify.mjs +2 -1
- package/scripts/hook-retirement-check.mjs +2 -1
- package/scripts/host-registry.mjs +2 -1
- package/scripts/independent-review-receipt.mjs +2 -1
- package/scripts/ingest-new-repos.mjs +2 -1
- package/scripts/ingest-repo.mjs +12 -2
- package/scripts/integration-evidence.mjs +2 -1
- package/scripts/issue-fix.mjs +7 -24
- package/scripts/issue-watch.mjs +116 -40
- package/scripts/job-heartbeat.sh +54 -19
- package/scripts/key-canary.mjs +9 -3
- package/scripts/latency-to-surface.mjs +2 -1
- package/scripts/learning-replay-cli.mjs +97 -19
- package/scripts/learning-replay-execution.mjs +2 -0
- package/scripts/learning-replay-fixture.mjs +3 -0
- package/scripts/learning-replay-proof.mjs +14 -13
- package/scripts/learning-replay.mjs +3 -3
- package/scripts/learnings.mjs +2 -1
- package/scripts/lesson-ratify.mjs +88 -40
- package/scripts/loop-checkpoint.mjs +2 -1
- package/scripts/metaharness-receipts.mjs +2 -1
- package/scripts/model-catalog.mjs +2 -1
- package/scripts/model-currency-evidence.mjs +6 -4
- package/scripts/model-currency.mjs +33 -3
- package/scripts/model-native-catalog.mjs +2 -1
- package/scripts/model-router-agent-hook.mjs +2 -1
- package/scripts/model-router-dispatch.mjs +2 -1
- package/scripts/model-router-engine.mjs +2 -1
- package/scripts/model-router-outcome.mjs +2 -1
- package/scripts/model-router-setup.mjs +2 -1
- package/scripts/model-routing-eval.mjs +2 -1
- package/scripts/model-routing-gateway.mjs +2 -1
- package/scripts/model-routing-launchers.mjs +4 -1
- package/scripts/model-terminal-gateway.mjs +2 -1
- package/scripts/model-terminal-launchers.mjs +2 -1
- package/scripts/model-weekly-analyst.mjs +2 -1
- package/scripts/model-weekly-cycle.mjs +2 -1
- package/scripts/model-weekly-qualification.mjs +2 -1
- package/scripts/nightly-gists.sh +26 -20
- package/scripts/nightly-two-run-proof.mjs +2 -1
- package/scripts/nightly-watchdog.mjs +158 -47
- package/scripts/no-silent-substitution.mjs +2 -1
- package/scripts/notify.sh +11 -6
- package/scripts/ntfy-inbox.mjs +41 -18
- package/scripts/onboarding-console.mjs +47 -8
- package/scripts/oracle/abstain-threshold-sweep.mjs +2 -1
- package/scripts/oracle/abstain-trace.mjs +2 -1
- package/scripts/oracle/doc2query-generate.mjs +2 -1
- package/scripts/oracle/doc2query-reach.mjs +2 -1
- package/scripts/oracle/judge-train.mjs +2 -1
- package/scripts/oracle/need-set-split.mjs +2 -1
- package/scripts/oracle/produce-questions.mjs +2 -16
- package/scripts/oracle/repo-recall.mjs +2 -2
- package/scripts/oracle/retrieval-accuracy.mjs +2 -2
- package/scripts/oracle/sona-query-adapter-eval.mjs +2 -1
- package/scripts/oracle/source-tree.mjs +2 -15
- package/scripts/oracle/source-units.mjs +2 -16
- package/scripts/oracle/spike-run.mjs +2 -16
- package/scripts/oracle/validate-labels.mjs +2 -16
- package/scripts/org-repo-count.mjs +2 -1
- package/scripts/package-cards.mjs +2 -1
- package/scripts/prepublication-evidence.mjs +2 -1
- package/scripts/private-overlay.mjs +2 -1
- package/scripts/product-integrity-contract.mjs +2 -1
- package/scripts/protected-release-invocation.mjs +2 -1
- package/scripts/public-verification-abandon.mjs +2 -1
- package/scripts/public-verification-aggregate.mjs +2 -1
- package/scripts/public-verification-finalizer.mjs +2 -1
- package/scripts/public-verification-inputs.mjs +2 -1
- package/scripts/public-verification-lane.mjs +2 -1
- package/scripts/publication-receipt.mjs +2 -1
- package/scripts/published-surface-probe.mjs +2 -1
- package/scripts/qe/card-lane-gate.mjs +2 -16
- package/scripts/qe/session-start-gate.mjs +2 -16
- package/scripts/qe/ux-suite.mjs +2 -1
- package/scripts/qualified-candidate-check.mjs +2 -1
- package/scripts/rebuild-gists-from-receipts.mjs +2 -1
- package/scripts/recommendation-eval.mjs +2 -1
- package/scripts/recommendation-floor.mjs +2 -1
- package/scripts/recommendation-judge-score.mjs +2 -1
- package/scripts/recommendation-real-host-score.mjs +2 -1
- package/scripts/recommendation-real-host.mjs +2 -1
- package/scripts/refresh-capability-only-store.mjs +2 -1
- package/scripts/refresh-model-catalog.mjs +2 -1
- package/scripts/rehearse-corpus-pipeline.mjs +2 -1
- package/scripts/release-authority.mjs +2 -1
- package/scripts/release-evidence-aggregate.mjs +2 -1
- package/scripts/release-payload.mjs +2 -1
- package/scripts/release-projection.mjs +2 -1
- package/scripts/release-proof.mjs +2 -1
- package/scripts/release-qualification-contract.mjs +64 -28
- package/scripts/release-qualification.mjs +8 -1
- package/scripts/release-vector.mjs +2 -1
- package/scripts/restore-local-ingests.mjs +2 -1
- package/scripts/retrieval-canary.mjs +2 -1
- package/scripts/route-cheap.mjs +2 -1
- package/scripts/route-gold-rank.mjs +2 -1
- package/scripts/route-index-memory.mjs +2 -1
- package/scripts/route-latency-warm.mjs +2 -1
- package/scripts/router-utilization.mjs +2 -1
- package/scripts/routing-flywheel.mjs +2 -1
- package/scripts/run-operational-benchmark.mjs +2 -1
- package/scripts/run-operational-benchmark.v3.mjs +2 -1
- package/scripts/rvf-index-audit.mjs +2 -16
- package/scripts/seal-gist-receipt.mjs +2 -1
- package/scripts/security-guidance-codex-compat.mjs +2 -1
- package/scripts/selfcheck.mjs +2 -1
- package/scripts/signal-watch.mjs +2 -1
- package/scripts/source-coverage.mjs +2 -1
- package/scripts/stabilization-receipt.mjs +2 -1
- package/scripts/stamp-sweep.mjs +2 -1
- package/scripts/status-honesty.mjs +2 -1
- package/scripts/sync-commands.mjs +2 -2
- package/scripts/top100-benchmark.mjs +2 -1
- package/scripts/top100-corpus.mjs +2 -1
- package/scripts/user-model-hook-install.mjs +2 -1
- package/scripts/user-model-prompt-hook.mjs +2 -1
- package/scripts/verify-bundle.mjs +3 -1
- package/scripts/verify-model-catalog.mjs +2 -1
- package/scripts/version.mjs +2 -1
- package/scripts/wired-check.mjs +11 -5
- package/scripts/worktree-integrity.mjs +2 -2
package/bin/install.mjs
CHANGED
|
@@ -30,7 +30,7 @@ import { saveUpdateSource, stableNode } from '../plugin/scripts/automatic-update
|
|
|
30
30
|
import { acquireDeveloperLock } from '../plugin/scripts/developer-update-lock.mjs';
|
|
31
31
|
import { footprintRoots, inventoryFootprint, sweepFootprint } from '../plugin/scripts/brain-footprint.mjs';
|
|
32
32
|
import { kbCopyProof } from '../plugin/scripts/kb-copy-proof.mjs';
|
|
33
|
-
import { assessMoveLeftovers, isVolumeMetadata } from '../plugin/scripts/footprint-io.mjs';
|
|
33
|
+
import { assessMoveLeftovers, isVolumeMetadata, rescueOperationalFiles } from '../plugin/scripts/footprint-io.mjs';
|
|
34
34
|
import { confirm, doctorVerdict, formatBytes, formatConfirmation, signatureEvidenceFromReceipts, signatureRecordValid, writeSignatureRecord } from '../plugin/scripts/brain-confirmation.mjs';
|
|
35
35
|
import {
|
|
36
36
|
requiredEmbedderModels,
|
|
@@ -83,11 +83,14 @@ import {
|
|
|
83
83
|
CONSOLE_RUNTIME_SURFACE, CONSOLE_RUNTIME_IDENTITY_FILE, consoleRuntimeDigest,
|
|
84
84
|
} from '../scripts/console-runtime-identity.mjs';
|
|
85
85
|
import { shellDiff as pluginShellDiff } from '../plugin/scripts/host-shell-boundary.mjs';
|
|
86
|
-
import { codexTrustChanges, CODEX_TRUST_ACTION } from '../scripts/codex-hook-trust.mjs';
|
|
86
|
+
import { codexPluginContentChanged, codexTrustChanges, codexUsesContentTrust, CODEX_TRUST_ACTION } from '../scripts/codex-hook-trust.mjs';
|
|
87
87
|
import { probeFreshCodexDeclarations } from '../scripts/codex-fresh-host-proof.mjs';
|
|
88
|
+
import { refreshSharedCodexAppServer } from '../scripts/codex-hook-trust-reconcile.mjs';
|
|
89
|
+
import { recordCodexOvernightNotice } from '../plugin/scripts/codex-overnight-notice.mjs';
|
|
88
90
|
import { repairSecurityGuidance } from '../scripts/security-guidance-codex-compat.mjs';
|
|
89
91
|
import { installNativeLaunchers, installRoutingRuntime, runtimeSnapshot } from '../scripts/model-routing-launchers.mjs';
|
|
90
92
|
import { installTerminalLaunchers } from '../scripts/model-terminal-launchers.mjs';
|
|
93
|
+
import { addGrokAgentsLine, grokPaths, grokStatus, grokStatusLine, GROK_AGENTS_LINE } from '../scripts/grok-host.mjs';
|
|
91
94
|
import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
|
|
92
95
|
import { moveBrain, MoveRefused } from '../scripts/move-brain.mjs';
|
|
93
96
|
import { brainLocation } from '../plugin/scripts/brain-location.mjs';
|
|
@@ -96,6 +99,7 @@ import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
|
|
|
96
99
|
import {
|
|
97
100
|
writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
|
|
98
101
|
} from '../kb/corpus-release-identity.mjs';
|
|
102
|
+
import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
|
|
99
103
|
|
|
100
104
|
// SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
|
|
101
105
|
// trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
|
|
@@ -166,6 +170,7 @@ const FLAG_MOVE_BRAIN = argv.includes('--move-brain');
|
|
|
166
170
|
const MOVE_BRAIN_TO = (() => { const i = argv.indexOf('--move-brain'); const v = i === -1 ? null : argv[i + 1]; return v && !v.startsWith('-') ? v : null; })();
|
|
167
171
|
const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
|
|
168
172
|
const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
|
|
173
|
+
const FLAG_ARCHIVE = argv.includes('--archive'); // with --clean only (#335): archive files unique to old KB copies, then release them
|
|
169
174
|
// ── onboarding-experience flags (all optional; every offer is safe to decline) ──
|
|
170
175
|
const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
|
|
171
176
|
const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
|
|
@@ -173,6 +178,7 @@ const FLAG_WITH_STACK = argv.includes('--with-stack'); // add missing Ruflo/RuVe
|
|
|
173
178
|
const FLAG_NO_STACK = argv.includes('--no-stack'); // skip the toolkit offer entirely
|
|
174
179
|
const FLAG_ENHANCE_CLAUDE_MD = argv.includes('--enhance-claude-md'); // add the CLAUDE.md section without prompting
|
|
175
180
|
const FLAG_NO_ENHANCE = argv.includes('--no-enhance'); // skip the CLAUDE.md offer entirely
|
|
181
|
+
const FLAG_GROK_AGENTS_MD = argv.includes('--grok-agents-md'); // opt in: add the search-first line to ~/.grok/AGENTS.md (#301)
|
|
176
182
|
const FLAG_STATUSLINE = argv.includes('--statusline'); // opt in to the status-bar version segment, non-interactively
|
|
177
183
|
const FLAG_NO_STATUSLINE = argv.includes('--no-statusline'); // decline the status-bar offer without prompting
|
|
178
184
|
// --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev); --pin <tag> is the same.
|
|
@@ -286,7 +292,8 @@ function download(url, dest) {
|
|
|
286
292
|
let lastShown = -1;
|
|
287
293
|
return downloadFileWithRetry(url, dest, {
|
|
288
294
|
headers: { 'User-Agent': 'ruvnet-brain-installer', Accept: 'application/octet-stream' },
|
|
289
|
-
|
|
295
|
+
// The retry continues the staged bytes (HTTP Range) when the server proves the same asset; otherwise it restarts.
|
|
296
|
+
onRetry: ({ attempt, error }) => warn(`download interrupted (${error.message}); retry ${attempt + 1}/3, resuming where it stopped when the server allows`),
|
|
290
297
|
onProgress: ({ received, total }) => {
|
|
291
298
|
const mb = Math.floor(received / 1e6);
|
|
292
299
|
const pct = total ? Math.floor(received / total * 100) : mb;
|
|
@@ -550,7 +557,7 @@ function placeFileAtomic(target, bytes) {
|
|
|
550
557
|
// one. The signed npm package carries the updater and the sibling modules it imports; the `--update`
|
|
551
558
|
// preflight places them (same trust as the validator) so a stale updater upgrades itself before it runs.
|
|
552
559
|
const UPDATER_FILES = ['forge-update.mjs', 'zip-extract.mjs', 'brain-profile.mjs', 'refresh-run.mjs',
|
|
553
|
-
'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'download-retry.mjs'];
|
|
560
|
+
'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'download-retry.mjs', 'forge-guard-injection.mjs'];
|
|
554
561
|
export function placeUpdater(kbDir, { sourceDir = path.join(REPO_ROOT, 'kb') } = {}) {
|
|
555
562
|
const placed = {};
|
|
556
563
|
for (const name of UPDATER_FILES) {
|
|
@@ -804,6 +811,10 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
|
|
|
804
811
|
const proof = kbCopyProof({ copyDir: preservedDir, liveDir: cacheDir });
|
|
805
812
|
if (proof.disposable) {
|
|
806
813
|
try {
|
|
814
|
+
// Its logs and ruflo scratch are copied out and verified first; a failed rescue keeps the copy. The
|
|
815
|
+
// rescue root is the sweep's (footprintRoots: links resolved, as after --move-brain), so the inventory
|
|
816
|
+
// finds and bounds kb-copy-rescued/ wherever the installer put it.
|
|
817
|
+
rescueOperationalFiles(preservedDir, proof.rescue, footprintRoots().brainHome);
|
|
807
818
|
fs.rmSync(preservedDir, { recursive: true, force: true });
|
|
808
819
|
ok(`released the prior generation (${proof.reason})`);
|
|
809
820
|
priorGeneration = { status: 'RELEASED', path: preservedDir, reason: proof.reason };
|
|
@@ -1380,6 +1391,40 @@ export function claudePluginStatus({ home = os.homedir() } = {}) {
|
|
|
1380
1391
|
}
|
|
1381
1392
|
}
|
|
1382
1393
|
|
|
1394
|
+
// #420 item 4: Claude Code also records per-project installs (scope "local"/"project", keyed by projectPath), and --update
|
|
1395
|
+
// moves only the user row above, so a project row keeps running its old generation. `claude plugin update --scope local`
|
|
1396
|
+
// acts on the project in its cwd, i.e. on someone's repo: REPORTED with the exact command, never run for them. A row whose
|
|
1397
|
+
// projectPath is gone cannot be running anything; it is listed, never counted as behind and never given a `cd` that fails.
|
|
1398
|
+
export function claudeProjectInstalls({ home = os.homedir(), referenceVersion = null } = {}) {
|
|
1399
|
+
let rows;
|
|
1400
|
+
try { rows = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8'))?.plugins?.['ruvnet-brain@ruvnet-brain']; } catch { return []; }
|
|
1401
|
+
if (!Array.isArray(rows)) return [];
|
|
1402
|
+
return rows.filter((row) => (row?.scope === 'local' || row?.scope === 'project') && typeof row.projectPath === 'string' && row.projectPath).map((row) => {
|
|
1403
|
+
let version = null;
|
|
1404
|
+
try { version = JSON.parse(fs.readFileSync(path.join(row.installPath, '.claude-plugin', 'plugin.json'), 'utf8'))?.version || null; } catch { /* registry metadata below */ }
|
|
1405
|
+
version ||= typeof row.version === 'string' ? row.version : null;
|
|
1406
|
+
let exists = false;
|
|
1407
|
+
try { exists = fs.statSync(row.projectPath).isDirectory(); } catch { /* gone */ }
|
|
1408
|
+
return { scope: row.scope, projectPath: row.projectPath, version, exists,
|
|
1409
|
+
behind: exists && Boolean(referenceVersion) && !versionSatisfies(version, referenceVersion),
|
|
1410
|
+
command: exists ? `cd '${row.projectPath.replace(/'/g, `'\\''`)}' && claude plugin update ruvnet-brain@ruvnet-brain --scope ${row.scope}` : null };
|
|
1411
|
+
});
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** The doctor's advisory line for claudeProjectInstalls(): '!' while any existing project lags, never a ✗ (it is the user's project). */
|
|
1415
|
+
export function projectInstallsCheck(projects, referenceVersion) {
|
|
1416
|
+
if (!projects.length) return null;
|
|
1417
|
+
const behind = projects.filter((p) => p.behind);
|
|
1418
|
+
const gone = projects.filter((p) => !p.exists);
|
|
1419
|
+
const named = (p) => `${p.projectPath} (${p.scope}, ${p.version || 'unknown'})`;
|
|
1420
|
+
const detail = behind.length
|
|
1421
|
+
? `${behind.length} per-project Claude Code install(s) behind ${referenceVersion}: ${behind.map(named).join(', ')}`
|
|
1422
|
+
: `${projects.length - gone.length} per-project Claude Code install(s) at ${referenceVersion} or newer`;
|
|
1423
|
+
return { id: 'project-installs', label: 'Projects', state: behind.length ? 'warn' : 'ok',
|
|
1424
|
+
detail: `${detail}${gone.length ? `; ${gone.length} registered for a folder that no longer exists: ${gone.map(named).join(', ')}` : ''}`,
|
|
1425
|
+
fix: behind.length ? behind.map((p) => p.command).join(' ; ') : null };
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1383
1428
|
// A plugin version can change without changing anything a running host freezes at boot. Compare
|
|
1384
1429
|
// the actual installed payload with this candidate before updating it so body-only releases do not
|
|
1385
1430
|
// manufacture a restart requirement. Unknown paths fail closed: if we cannot prove the boundary,
|
|
@@ -1432,7 +1477,7 @@ function codexInstalledPluginRoot({ codexHome = codexHomeDir(), status } = {}) {
|
|
|
1432
1477
|
function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false } = {}) {
|
|
1433
1478
|
step(
|
|
1434
1479
|
'Wiring the Claude Code plugin',
|
|
1435
|
-
|
|
1480
|
+
`this registers search_ruvnet, native skills, slash commands, and the allowlisted lifecycle hooks (${shippedHookHandlerCounts().claude ?? 'unknown number of'} handlers)`,
|
|
1436
1481
|
);
|
|
1437
1482
|
const marketplaceSource = process.env.RUVNET_CLAUDE_MARKETPLACE_SOURCE || 'stuinfla/ruvnet-brain';
|
|
1438
1483
|
const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
|
|
@@ -1484,6 +1529,11 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1484
1529
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1485
1530
|
}
|
|
1486
1531
|
info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rnbc')} (also /rnb, /rvbc), ${c.bold('/ruvnet-brain:configure')}`);
|
|
1532
|
+
// #420 item 4: the user row moved; per-project rows did not. Name each lagging one with its own command (never run here).
|
|
1533
|
+
const projectsBehind = claudeProjectInstalls({ referenceVersion: installed.version }).filter((p) => p.behind);
|
|
1534
|
+
for (const p of projectsBehind) {
|
|
1535
|
+
warn(`per-project install still on ${p.version || 'an unknown version'} (${p.scope} scope, ${p.projectPath}); --update moves only the user install. To update it: ${c.bold(p.command)}`);
|
|
1536
|
+
}
|
|
1487
1537
|
return {
|
|
1488
1538
|
host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
|
|
1489
1539
|
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
@@ -1973,9 +2023,20 @@ const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain';
|
|
|
1973
2023
|
* [{ key, status: 'modified'|'untrusted' }]. No installed root = a fresh install = every hook untrusted.
|
|
1974
2024
|
* An unreadable file is treated as absent (fail toward telling the user to review, never toward silence).
|
|
1975
2025
|
*/
|
|
1976
|
-
export function codexHooksNeedingReview(installedRoot, candidateRoot) {
|
|
2026
|
+
export function codexHooksNeedingReview(installedRoot, candidateRoot, { contentTrust = false } = {}) {
|
|
1977
2027
|
const read = (root) => { try { return root ? JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'codex-hooks.json'), 'utf8')) : {}; } catch { return {}; } };
|
|
1978
|
-
|
|
2028
|
+
// Codex 0.164+ (or a version we could not read) also holds back every hook whose plugin bytes changed (#492).
|
|
2029
|
+
const contentChanged = contentTrust !== false && codexPluginContentChanged(installedRoot, candidateRoot);
|
|
2030
|
+
return codexTrustChanges(read(installedRoot), read(candidateRoot), undefined, { contentChanged });
|
|
2031
|
+
}
|
|
2032
|
+
|
|
2033
|
+
/** codex --version → whether it approves plugin hooks by content too (true/false), or null when unknown. */
|
|
2034
|
+
export function codexHostContentTrust({ codexBin = process.env.CODEX_BIN || 'codex', codexHome = codexHomeDir(), run = runHostCli } = {}) {
|
|
2035
|
+
try {
|
|
2036
|
+
const r = run(codexBin, ['--version'], { stdio: 'pipe', shell: false, encoding: 'utf8', timeout: 10_000,
|
|
2037
|
+
env: { ...process.env, CODEX_HOME: codexHome } });
|
|
2038
|
+
return r?.status === 0 ? codexUsesContentTrust(r.stdout) : null;
|
|
2039
|
+
} catch { return null; }
|
|
1979
2040
|
}
|
|
1980
2041
|
|
|
1981
2042
|
function codexMarketplaceTarget() {
|
|
@@ -2064,7 +2125,7 @@ export function runCodexJson(args, {
|
|
|
2064
2125
|
}
|
|
2065
2126
|
if (repaired.length) r = run(codexBin, args, options);
|
|
2066
2127
|
}
|
|
2067
|
-
if (r.missingBinary) return { ok: false, error: r.message };
|
|
2128
|
+
if (r.missingBinary) return { ok: false, error: r.message, missingBinary: true };
|
|
2068
2129
|
if (r.error || r.status !== 0) {
|
|
2069
2130
|
let detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
|
|
2070
2131
|
if (snapshotFailure(r)) {
|
|
@@ -2082,7 +2143,8 @@ export function runCodexJson(args, {
|
|
|
2082
2143
|
export function codexPluginStatus(options = {}) {
|
|
2083
2144
|
const { runJson = runCodexJson, ...commandOptions } = options;
|
|
2084
2145
|
const listed = runJson(['plugin', 'list', '--json'], commandOptions);
|
|
2085
|
-
if (!listed.ok) return { available: false, installed: false, enabled: false, error: listed.error
|
|
2146
|
+
if (!listed.ok) return { available: false, installed: false, enabled: false, error: listed.error,
|
|
2147
|
+
...(listed.missingBinary ? { missingBinary: true } : {}) };
|
|
2086
2148
|
const rows = Array.isArray(listed.value?.installed) ? listed.value.installed : [];
|
|
2087
2149
|
const row = rows.find((candidate) => candidate?.pluginId === CODEX_PLUGIN_ID);
|
|
2088
2150
|
return {
|
|
@@ -2131,6 +2193,8 @@ export function wireCodexPlugin({
|
|
|
2131
2193
|
announce = true,
|
|
2132
2194
|
runJson = runCodexJson,
|
|
2133
2195
|
aliasHome,
|
|
2196
|
+
// true/false/null (unknown): does this Codex pin plugin contents in hook approval (0.164+)? Probed for a real host.
|
|
2197
|
+
contentTrust,
|
|
2134
2198
|
} = {}) {
|
|
2135
2199
|
if (!fs.existsSync(codexDir)) return { host: false, action: 'no-host' };
|
|
2136
2200
|
const options = { codexBin, codexHome, cwd, repair: true };
|
|
@@ -2169,11 +2233,6 @@ export function wireCodexPlugin({
|
|
|
2169
2233
|
path.join(REPO_ROOT, 'plugin'),
|
|
2170
2234
|
)
|
|
2171
2235
|
: { known: true, changed: false, paths: [], restartRequired: false, reason: 'new host installation' };
|
|
2172
|
-
// Which Brain hooks will Codex hold back after this update until the user re-reviews them? Codex keys
|
|
2173
|
-
// trust to a hash of each hook's definition (scripts/codex-hook-trust.mjs), and codex-hooks.json is not
|
|
2174
|
-
// a boot-surface path, so the shell boundary above cannot see it. Computed from the two files, offline.
|
|
2175
|
-
const hooksNeedingReview = codexHooksNeedingReview(before.installed
|
|
2176
|
-
? codexInstalledPluginRoot({ codexHome, status: before }) : null, path.join(REPO_ROOT, 'plugin'));
|
|
2177
2236
|
if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
|
|
2178
2237
|
const hookTrust = runJson === runCodexJson ? repairReleasedCodexHookTrust(before, options) : null;
|
|
2179
2238
|
if (announce) {
|
|
@@ -2187,6 +2246,13 @@ export function wireCodexPlugin({
|
|
|
2187
2246
|
};
|
|
2188
2247
|
}
|
|
2189
2248
|
|
|
2249
|
+
// Which Brain hooks will Codex hold back after this update until the user re-reviews them? Codex keys
|
|
2250
|
+
// trust to a hash of each hook's definition (scripts/codex-hook-trust.mjs), and codex-hooks.json is not
|
|
2251
|
+
// a boot-surface path, so the shell boundary above cannot see it. Computed offline, before the install
|
|
2252
|
+
// replaces the installed folder; on Codex 0.164+ a change to any plugin byte holds back every hook too (#492).
|
|
2253
|
+
const hooksNeedingReview = codexHooksNeedingReview(before.installed
|
|
2254
|
+
? codexInstalledPluginRoot({ codexHome, status: before }) : null, path.join(REPO_ROOT, 'plugin'),
|
|
2255
|
+
{ contentTrust: contentTrust ?? (runJson === runCodexJson ? codexHostContentTrust({ codexBin, codexHome }) : false) });
|
|
2190
2256
|
if (runJson === runCodexJson && !localMarketplace) {
|
|
2191
2257
|
try { localMarketplace = prepareCodexMarketplace(); }
|
|
2192
2258
|
catch (error) {
|
|
@@ -2236,7 +2302,9 @@ export function wireCodexPlugin({
|
|
|
2236
2302
|
}
|
|
2237
2303
|
if (hooksNeedingReview.length && !trustVerified) {
|
|
2238
2304
|
warn(`Codex will NOT run ${hooksNeedingReview.length} Brain hook${hooksNeedingReview.length === 1 ? '' : 's'} until you review`
|
|
2239
|
-
+ ` ${hooksNeedingReview.length === 1 ? 'it' : 'them'} (${hooksNeedingReview.map((h) => `${h.key.split(':').slice(-3).join(':')} ${h.status}`).join(', ')}).`
|
|
2305
|
+
+ ` ${hooksNeedingReview.length === 1 ? 'it' : 'them'} (${hooksNeedingReview.map((h) => `${h.key.split(':').slice(-3).join(':')} ${h.status}`).join(', ')}).`
|
|
2306
|
+
+ (hooksNeedingReview.some((h) => h.reason === 'plugin-content')
|
|
2307
|
+
? ' This update changed the plugin\'s files, and Codex 0.164+ approves those bytes as well as each hook\'s definition.' : ''));
|
|
2240
2308
|
info(` ${CODEX_TRUST_ACTION}`);
|
|
2241
2309
|
}
|
|
2242
2310
|
}
|
|
@@ -2260,6 +2328,38 @@ export function wireCodexPlugin({
|
|
|
2260
2328
|
};
|
|
2261
2329
|
}
|
|
2262
2330
|
|
|
2331
|
+
/**
|
|
2332
|
+
* After the Brain changed Codex's view of its plugin (new bytes, or hook trust written from another process), tell an
|
|
2333
|
+
* already-running shared Codex app-server to reload, so its open sessions stop reporting the previous hooks (#492).
|
|
2334
|
+
* Never starts, stops or restarts a server, and never grants an approval (scripts/codex-hook-trust-reconcile.mjs).
|
|
2335
|
+
*/
|
|
2336
|
+
export const codexHostViewChanged = (codexPlugin) => ['installed', 'updated'].includes(codexPlugin?.action)
|
|
2337
|
+
|| codexPlugin?.hookTrust?.changed === true || codexPlugin?.hookTrust?.changed === 'unknown';
|
|
2338
|
+
export async function refreshCodexSharedHost(codexPlugin, { codexHome = codexHomeDir(), refresh = refreshSharedCodexAppServer } = {}) {
|
|
2339
|
+
if (!codexHostViewChanged(codexPlugin)) return { state: 'not-needed', changed: false };
|
|
2340
|
+
return refresh({ codexHome });
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
/**
|
|
2344
|
+
* What the owner is told after refreshCodexSharedHost. The reload reaches ALREADY-OPEN Codex sessions at once, and
|
|
2345
|
+
* changed Brain hook definitions are pending until reviewed, so those sessions stop running Brain hooks until the
|
|
2346
|
+
* owner re-reviews them in /hooks. Said plainly, so a pause in a session he had open is never a surprise (#492).
|
|
2347
|
+
*/
|
|
2348
|
+
export function codexHostRefreshMessage(hostRefresh, codexPlugin = null) {
|
|
2349
|
+
if (hostRefresh?.state === 'refreshed') {
|
|
2350
|
+
// Review r2 (4.7.21): the authorized trust reconciliation may already have re-trusted the released hooks
|
|
2351
|
+
// (registry-verified, or nothing pending); then nothing pauses and saying so would be false.
|
|
2352
|
+
const retrusted = ['registry-verified', 'unchanged'].includes(codexPlugin?.hookTrust?.state);
|
|
2353
|
+
return { level: 'info', text: ' Running Codex app-server reloaded: Codex sessions that are already open pick up the change immediately; '
|
|
2354
|
+
+ (retrusted ? 'Brain hooks were already re-trusted, so they keep running there (nothing restarted).'
|
|
2355
|
+
: 'Brain hooks pause in them until you re-review them in /hooks (no approval granted, nothing restarted).') };
|
|
2356
|
+
}
|
|
2357
|
+
if (hostRefresh?.state === 'failed') {
|
|
2358
|
+
return { level: 'warn', text: `Running Codex app-server not reloaded (${hostRefresh.reason}); its open sessions may report the previous Brain hooks until Codex restarts.` };
|
|
2359
|
+
}
|
|
2360
|
+
return null;
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2263
2363
|
export function codexSessionSafety(status) {
|
|
2264
2364
|
if (status?.restartRequired === true) return status;
|
|
2265
2365
|
return {
|
|
@@ -2547,6 +2647,61 @@ export function retireManagedHookRegistrations({ home = os.homedir(), codexDir =
|
|
|
2547
2647
|
return { removed, changedFiles, conflicts };
|
|
2548
2648
|
}
|
|
2549
2649
|
|
|
2650
|
+
/** Grok's state for --doctor and the install summary (#301): reported, never wired automatically. */
|
|
2651
|
+
function currentGrokStatus() {
|
|
2652
|
+
return grokStatus({ pluginInstalled: Boolean(pluginCommandsDir()), hookCount: shippedHookHandlerCounts().grok });
|
|
2653
|
+
}
|
|
2654
|
+
function grokCheckLines(check) {
|
|
2655
|
+
const status = currentGrokStatus();
|
|
2656
|
+
const line = grokStatusLine(status);
|
|
2657
|
+
if (!line) return [];
|
|
2658
|
+
// Advisory only when the Brain cannot reach Grok at all; the AGENTS.md line is the user's choice.
|
|
2659
|
+
return [check('grok', 'Grok', !status.pluginInstalled, line, 'npx ruvnet-brain', { advisory: true })];
|
|
2660
|
+
}
|
|
2661
|
+
|
|
2662
|
+
/** Opt-in, never automatic: offer the search-first line when Grok is here and the line is not. */
|
|
2663
|
+
async function offerGrokAgentsMd() {
|
|
2664
|
+
const status = currentGrokStatus();
|
|
2665
|
+
if (!status.present || status.groundingLine || !status.pluginInstalled) return;
|
|
2666
|
+
step('Optional — a search-first line for Grok', 'Grok drops prompt-time context, so this line is what makes it ground itself');
|
|
2667
|
+
info(`Adds ${c.bold('one line')} (between ruvnet-brain markers) to the bottom of ${c.bold(status.agentsFile)}:`);
|
|
2668
|
+
info(c.dim(` ${GROK_AGENTS_LINE}`));
|
|
2669
|
+
info('Backed up first; --uninstall removes it.');
|
|
2670
|
+
const yes = FLAG_GROK_AGENTS_MD || (await ask('Add it?', false, { blanketYes: false }));
|
|
2671
|
+
if (!yes) { info(`Skipped — add it any time with ${c.bold('npx ruvnet-brain --grok-agents-md')}`); return; }
|
|
2672
|
+
reportGrokAgentsLine(addGrokAgentsLine());
|
|
2673
|
+
}
|
|
2674
|
+
function reportGrokAgentsLine(result) {
|
|
2675
|
+
if (result === 'added') ok(`added the search-first line to ${grokPaths().agents}`);
|
|
2676
|
+
else if (result === 'present') ok('~/.grok/AGENTS.md already has a search_ruvnet line — left unchanged');
|
|
2677
|
+
else if (result === 'no-grok') info('Grok CLI not detected (~/.grok/bin/grok) — nothing changed');
|
|
2678
|
+
else warn('could not update ~/.grok/AGENTS.md — your file is untouched');
|
|
2679
|
+
}
|
|
2680
|
+
|
|
2681
|
+
/**
|
|
2682
|
+
* How many lifecycle handlers each SHIPPED host manifest registers, counted from the manifests
|
|
2683
|
+
* themselves so help and progress text can never again claim "zero" while they carry handlers
|
|
2684
|
+
* (#400). A missing or unreadable manifest is null ("unknown"), never 0.
|
|
2685
|
+
*/
|
|
2686
|
+
export function shippedHookHandlerCounts(root = REPO_ROOT) {
|
|
2687
|
+
const count = (relative) => {
|
|
2688
|
+
try {
|
|
2689
|
+
const doc = JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
|
|
2690
|
+
return Object.values(doc?.hooks || {}).flat().reduce((n, group) => n + (Array.isArray(group?.hooks) ? group.hooks.length : 0), 0);
|
|
2691
|
+
} catch { return null; }
|
|
2692
|
+
};
|
|
2693
|
+
return {
|
|
2694
|
+
claude: count('plugin/hooks/hooks.json'),
|
|
2695
|
+
codex: count('plugin/hooks/codex-hooks.json'),
|
|
2696
|
+
grok: count('plugin/hooks/grok-hooks.json'),
|
|
2697
|
+
};
|
|
2698
|
+
}
|
|
2699
|
+
|
|
2700
|
+
function hookCountPhrase({ claude, codex } = shippedHookHandlerCounts()) {
|
|
2701
|
+
const n = (v) => (Number.isInteger(v) ? String(v) : 'an unknown number of');
|
|
2702
|
+
return `${n(claude)} Claude Code, ${n(codex)} Codex handlers`;
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2550
2705
|
export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'source' } = {}) {
|
|
2551
2706
|
if (!['source', 'installed'].includes(scope)) throw new Error('invalid hook retirement scope');
|
|
2552
2707
|
const files = [
|
|
@@ -3397,8 +3552,9 @@ async function doctorRun({ json }) {
|
|
|
3397
3552
|
const turnLine = { id: 'turn-recording', label: 'Turn recording', state: turnStatus.state, detail: turnStatus.line, fix: null };
|
|
3398
3553
|
|
|
3399
3554
|
// ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
|
|
3400
|
-
// `--hooks` is
|
|
3401
|
-
// never
|
|
3555
|
+
// `--hooks` is a read-only proof that every registration in the shipped manifests is on the
|
|
3556
|
+
// continuity allowlist (#400: it is not, and never was, a zero-registration proof). It must never
|
|
3557
|
+
// execute hook bodies.
|
|
3402
3558
|
let hookResult = null;
|
|
3403
3559
|
if (FLAG_HOOKS) {
|
|
3404
3560
|
const retirement = automaticHookRetirementStatus(REPO_ROOT, { scope: 'installed' });
|
|
@@ -3460,6 +3616,9 @@ async function doctorRun({ json }) {
|
|
|
3460
3616
|
const nightlyFailed = nightlyHealth.state === 'degraded'
|
|
3461
3617
|
|| (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state));
|
|
3462
3618
|
const codexFailed = codexWiringFailed || codexReadinessFailed || (codexLifecycleFailed && !codexTrustBypassed);
|
|
3619
|
+
// #420 item 4: per-project Claude installs lag silently behind the user one; advisory, with each project's own command.
|
|
3620
|
+
const projectReference = claudePluginStatus().version || PACKAGE_VERSION;
|
|
3621
|
+
const projectLine = projectInstallsCheck(claudeProjectInstalls({ referenceVersion: projectReference }), projectReference);
|
|
3463
3622
|
const checks = [
|
|
3464
3623
|
// With --hooks the install reading was never part of the verdict (release install verification); keep that.
|
|
3465
3624
|
check('install', 'Install', !allGreen, `${v.repos} store(s), reader ${v.reader ? 'present' : 'MISSING'}, search server ${v.mcp ? 'present' : 'MISSING'}`,
|
|
@@ -3471,13 +3630,15 @@ async function doctorRun({ json }) {
|
|
|
3471
3630
|
...(cx.host ? [check('codex', 'Codex', codexFailed, codexFailed ? (codexWiringFailed ? 'host detected but NOT wired'
|
|
3472
3631
|
: codexReadinessFailed ? 'MCP readiness blocked' : 'lifecycle hooks unhealthy') : 'wired', 'npx ruvnet-brain')] : []),
|
|
3473
3632
|
check('nightly', 'Nightly', nightlyFailed, `${nightlyHealth.state}${nightlyHealth.runHealth?.state ? `, last run ${nightlyHealth.runHealth.state}` : ''}`,
|
|
3474
|
-
nightlyHealth
|
|
3633
|
+
nightlyFix(nightlyHealth)),
|
|
3475
3634
|
// Scheduler ON but running yesterday's code: an advisory naming the exact fix, never a failing verdict.
|
|
3476
3635
|
...(nightlyHealth.state === 'on' && nightlyCode.state !== 'unregistered' ? [check('nightly-code', 'Nightly code', nightlyCode.state !== 'current',
|
|
3477
3636
|
nightlyCode.state === 'current' ? 'registered update modules match the installed set' : `${nightlyCode.state}: registered ${String(nightlyCode.registered || '?').slice(0, 12)}, installed ${String(nightlyCode.current || '?').slice(0, 12)}`,
|
|
3478
3637
|
nightlyCode.fix || 'npx ruvnet-brain@latest --update', { advisory: true })] : []),
|
|
3479
3638
|
check('host-convergence', 'Hosts sync', !hostConvergence.healthy, hostConvergence.state, 'npx ruvnet-brain --update'),
|
|
3639
|
+
...grokCheckLines(check),
|
|
3480
3640
|
...(rufloOperational ? [rufloCheckLine(rufloOperational)] : []),
|
|
3641
|
+
...(projectLine ? [projectLine] : []),
|
|
3481
3642
|
...(agentdbLine ? [agentdbLine] : []),
|
|
3482
3643
|
turnLine,
|
|
3483
3644
|
];
|
|
@@ -3754,6 +3915,17 @@ const TEST_MODE = process.env.RUVNET_BRAIN_TEST === '1';
|
|
|
3754
3915
|
// failure mode for a stranger's first contact. With the variable unset, behavior is unchanged.
|
|
3755
3916
|
const IMPORT_ONLY = process.env.RUVNET_BRAIN_IMPORT_ONLY === '1';
|
|
3756
3917
|
|
|
3918
|
+
/**
|
|
3919
|
+
* The doctor's fix for the Nightly line. Re-enabling repairs a job that cannot fire (not loaded, command drift);
|
|
3920
|
+
* a job that is loaded, verified, FIRED and exited non-zero (`lastExitCode`) is a failed update run, and
|
|
3921
|
+
* re-enabling it changes nothing — the update itself is the fix (4.7.21).
|
|
3922
|
+
*/
|
|
3923
|
+
export function nightlyFix(nightlyHealth) {
|
|
3924
|
+
const fired = Number.isInteger(nightlyHealth?.lastExitCode) && nightlyHealth.lastExitCode !== 0;
|
|
3925
|
+
if (fired) return 'npx ruvnet-brain@latest --update';
|
|
3926
|
+
return nightlyHealth?.state === 'degraded' ? 'npx ruvnet-brain --enable-nightly' : 'npx ruvnet-brain --update';
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3757
3929
|
export function classifyUpdaterExit(status, { fallbackAllowed = true, result = null, requireResult = false } = {}) {
|
|
3758
3930
|
if (status === 12 && result?.terminalVerdict === 'cleanup-pending') {
|
|
3759
3931
|
return { verdict: 'cleanup-pending', fallback: false, exitCode: 12 };
|
|
@@ -3783,8 +3955,26 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
|
|
|
3783
3955
|
// Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
|
|
3784
3956
|
// bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
|
|
3785
3957
|
// reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
|
|
3786
|
-
|
|
3787
|
-
|
|
3958
|
+
// Exit 3 is the same transport failure one request later: the bundle downloaded, then its 64-byte .sig hit a
|
|
3959
|
+
// 5xx or a timeout. Nothing was touched, and a fallback would re-download the whole ~745 MB bundle. A 404 on
|
|
3960
|
+
// the .sig (a release published without one) is not transient and keeps the fallback.
|
|
3961
|
+
if ((status === 2 || status === 3) && /returned HTTP (?:403|408|429|5\d\d)\b|network failure/.test(String(result?.reason || ''))) {
|
|
3962
|
+
return { verdict: 'transient-network', fallback: false, exitCode: status };
|
|
3963
|
+
}
|
|
3964
|
+
// Neither of these is a broken updater, so a fresh reinstall cannot fix them; it only re-downloads ~745 MB
|
|
3965
|
+
// into the same wall, every night (4.7.21, audit C: a `.prune-*` left by a killed run made retention
|
|
3966
|
+
// `unsafe`, which forge-update reported as recovery-required, and the fallback reinstall hit it again).
|
|
3967
|
+
// - recovery-required: the updater stopped on evidence it must not discard; it names the recovery.
|
|
3968
|
+
// - exit 5: the offered corpus is incompatible with (or was already refused by) the runtime this run has
|
|
3969
|
+
// just re-stamped (ensureUpdaterPrerequisites), and a reinstall of the same package stamps the same one.
|
|
3970
|
+
if (result?.terminalVerdict === 'recovery-required') {
|
|
3971
|
+
return { verdict: 'recovery-required', fallback: false, exitCode: status || 1 };
|
|
3972
|
+
}
|
|
3973
|
+
if (status === 5) return { verdict: 'refused-incompatible', fallback: false, exitCode: 5 };
|
|
3974
|
+
// The updater was stopped by a signal (its own TTL, a logout, Ctrl-C) and settled itself as `interrupted`.
|
|
3975
|
+
// Nothing is broken, so a fresh reinstall would only re-download ~745 MB into the same interruption.
|
|
3976
|
+
if (result?.terminalVerdict === 'interrupted' || status === 143 || status === 130) {
|
|
3977
|
+
return { verdict: 'interrupted', fallback: false, exitCode: status || 143 };
|
|
3788
3978
|
}
|
|
3789
3979
|
return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
|
|
3790
3980
|
}
|
|
@@ -3919,7 +4109,12 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3919
4109
|
return fail({ error: `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}` });
|
|
3920
4110
|
}
|
|
3921
4111
|
results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
|
|
3922
|
-
|
|
4112
|
+
// ~/.codex exists but no `codex` binary is on PATH (an uninstalled CLI, or a desktop-only setup whose shell
|
|
4113
|
+
// cannot see it): there is no Codex host to converge here, which is absence, not a failed update (4.7.21,
|
|
4114
|
+
// audit C-opus #6). Any other unavailability (the binary ran and failed) still fails.
|
|
4115
|
+
if (results.codex.action === 'codex-unavailable' && results.codex.missingBinary === true) {
|
|
4116
|
+
results.codexAbsent = true;
|
|
4117
|
+
} else if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
|
|
3923
4118
|
return fail();
|
|
3924
4119
|
}
|
|
3925
4120
|
}
|
|
@@ -3933,7 +4128,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3933
4128
|
const applied = runStableSpine(apply);
|
|
3934
4129
|
const okApplied = !applied.error && applied.status === 0;
|
|
3935
4130
|
const codexReceipt = {
|
|
3936
|
-
state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host ? 'ready' : 'absent'),
|
|
4131
|
+
state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host && !results.codexAbsent ? 'ready' : 'absent'),
|
|
3937
4132
|
version: results.codex?.version || null,
|
|
3938
4133
|
};
|
|
3939
4134
|
if (results.codex?.restartRequired === true) {
|
|
@@ -4019,20 +4214,66 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
4019
4214
|
}
|
|
4020
4215
|
}
|
|
4021
4216
|
}
|
|
4022
|
-
|
|
4023
|
-
|
|
4217
|
+
// update-apply exits 75 when another live run holds the spine lock: nothing was applied, so it is not success.
|
|
4218
|
+
if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || (applied.status === 75
|
|
4219
|
+
? 'Stable Spine is busy (another update holds its lock); nothing was applied — re-run when it finishes'
|
|
4220
|
+
: 'Stable Spine activation failed') });
|
|
4221
|
+
const convergedReceipt = {
|
|
4024
4222
|
desiredVersion: PACKAGE_VERSION,
|
|
4025
4223
|
hosts: {
|
|
4026
4224
|
claude: claudeReceipt,
|
|
4027
4225
|
codex: codexReceipt,
|
|
4028
4226
|
},
|
|
4029
4227
|
consoleRuntime: results.consoleRuntime,
|
|
4030
|
-
}
|
|
4228
|
+
};
|
|
4229
|
+
const convergence = classifyHostConvergence(convergedReceipt);
|
|
4031
4230
|
hostLock.release();
|
|
4032
4231
|
return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
|
|
4232
|
+
...(convergence.healthy !== true && hostRestartIsOnlyGap(convergedReceipt) ? { restartOnly: true } : {}),
|
|
4033
4233
|
error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
|
|
4034
4234
|
}
|
|
4035
4235
|
|
|
4236
|
+
/**
|
|
4237
|
+
* The ONE host-convergence entry for `--update` and `--host-sync-only`: syncHostsAfterUpdate, then the #492 reload of a
|
|
4238
|
+
* running shared Codex app-server when the Codex plugin or its hook trust changed (fresh installs reload it in the
|
|
4239
|
+
* main flow; before this, every existing user's `--update` skipped it — cross-vendor review E). The reload never
|
|
4240
|
+
* grants an approval and never starts, stops or restarts a server (refreshSharedCodexAppServer).
|
|
4241
|
+
*/
|
|
4242
|
+
export async function convergeHostsAfterUpdate(cacheDir = resolvedKbDir(), { refreshHost = refreshCodexSharedHost,
|
|
4243
|
+
unattended = process.env.RUVNET_NIGHTLY === '1', recordNotice = recordCodexOvernightNotice,
|
|
4244
|
+
noticeHome = () => footprintRoots().brainHome, ...options } = {}) {
|
|
4245
|
+
const convergence = syncHostsAfterUpdate(cacheDir, options);
|
|
4246
|
+
if (!convergence.results?.codex) return convergence;
|
|
4247
|
+
// Owner decision (4.7.21 review r2): the unattended nightly never reloads the shared Codex app-server; a reload
|
|
4248
|
+
// pauses Brain hooks in sessions already open until someone re-reviews them in /hooks. It leaves a notice the next
|
|
4249
|
+
// SessionStart shows once instead. An interactive --update still reloads.
|
|
4250
|
+
if (unattended) {
|
|
4251
|
+
if (!codexHostViewChanged(convergence.results.codex)) return { ...convergence, hostRefresh: { state: 'not-needed', changed: false } };
|
|
4252
|
+
const recorded = recordNotice(noticeHome());
|
|
4253
|
+
return { ...convergence, hostRefresh: { state: 'deferred-unattended', changed: false, noticeRecorded: recorded } };
|
|
4254
|
+
}
|
|
4255
|
+
let hostRefresh;
|
|
4256
|
+
try { hostRefresh = await refreshHost(convergence.results.codex); }
|
|
4257
|
+
catch (error) { hostRefresh = { state: 'failed', changed: false, reason: error?.message || String(error) }; }
|
|
4258
|
+
const note = codexHostRefreshMessage(hostRefresh, convergence.results.codex);
|
|
4259
|
+
if (note) (note.level === 'warn' ? warn : info)(note.text);
|
|
4260
|
+
return { ...convergence, hostRefresh };
|
|
4261
|
+
}
|
|
4262
|
+
|
|
4263
|
+
/**
|
|
4264
|
+
* True when a host restart is the ONLY gap in a convergence receipt: every restart-flagged host is ready at the
|
|
4265
|
+
* expected version, and with those hosts read as "already-open windows only" everything else (other hosts, the
|
|
4266
|
+
* Console) is converged. `--update` records that as an advisory and keeps the knowledge update's success; any
|
|
4267
|
+
* other gap behind the restart keeps the failure (4.7.21, audit C: Codex's restartScope is always 'unproven').
|
|
4268
|
+
*/
|
|
4269
|
+
export function hostRestartIsOnlyGap(receipt, expectedVersion = PACKAGE_VERSION) {
|
|
4270
|
+
const flagged = Object.values(receipt?.hosts || {}).filter((host) => host?.restartRequired === true);
|
|
4271
|
+
if (!flagged.length || flagged.some((host) => host.state !== 'ready' || !versionSatisfies(host.version, expectedVersion))) return false;
|
|
4272
|
+
const hosts = Object.fromEntries(Object.entries(receipt.hosts).map(([name, host]) => [name,
|
|
4273
|
+
host?.restartRequired === true ? { ...host, restartScope: 'open-sessions' } : host]));
|
|
4274
|
+
return classifyHostConvergence({ ...receipt, hosts }, expectedVersion).healthy === true;
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4036
4277
|
export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSION) {
|
|
4037
4278
|
// Same 'ahead is not behind' rule as host convergence above (#123). A receipt written by a
|
|
4038
4279
|
// NEWER generation than the installer currently running is not a version mismatch — it is a
|
|
@@ -4167,7 +4408,7 @@ async function runUpdate() {
|
|
|
4167
4408
|
}
|
|
4168
4409
|
if (FLAG_HOST_SYNC_ONLY) {
|
|
4169
4410
|
info(c.dim('repairing host shells and managed model catalog without mutating KB bytes…\n'));
|
|
4170
|
-
const convergence =
|
|
4411
|
+
const convergence = await convergeHostsAfterUpdate(kbDir);
|
|
4171
4412
|
if (!convergence.ok) {
|
|
4172
4413
|
warn(`host synchronization is incomplete${convergence.error ? ` (${convergence.error})` : ''}`);
|
|
4173
4414
|
process.exitCode = 1;
|
|
@@ -4236,6 +4477,36 @@ async function runUpdate() {
|
|
|
4236
4477
|
detail: { reason: 'process exited before terminal settlement' } }); } catch { /* retained lock and receipt are recovery evidence */ }
|
|
4237
4478
|
};
|
|
4238
4479
|
process.once('exit', exitGuard);
|
|
4480
|
+
// SIGTERM/SIGINT (4.7.21): the SessionStart self-heal kills this process group at its TTL (detach.mjs), and the
|
|
4481
|
+
// default disposition ends Node without an 'exit' event, so the refresh receipt stayed RUNNING and its lock
|
|
4482
|
+
// stayed held until a later run proved the owner dead. Settle FAILED with the signal, release the lock, exit
|
|
4483
|
+
// 128+n. While a child runs under spawnSync the handler cannot run, so the same settlement is applied as soon
|
|
4484
|
+
// as a child is seen to have died of one of these signals (a group kill reaches both).
|
|
4485
|
+
const signalGuard = (signal) => {
|
|
4486
|
+
if (!refreshSettled) {
|
|
4487
|
+
try {
|
|
4488
|
+
settleRefreshRun({ handle: refreshReceipt, lock: refreshLock, status: 'FAILED',
|
|
4489
|
+
detail: { reason: 'signal', signal, phase: 'interrupted', terminalVerdict: 'failed' } });
|
|
4490
|
+
refreshSettled = true;
|
|
4491
|
+
} catch { /* retained lock and receipt are recovery evidence */ }
|
|
4492
|
+
}
|
|
4493
|
+
console.error(`\n${c.red('✗ update interrupted by')} ${signal} — the refresh run was recorded FAILED; nothing is left locked.`);
|
|
4494
|
+
process.exit(128 + (os.constants.signals[signal] || 15));
|
|
4495
|
+
};
|
|
4496
|
+
process.once('SIGTERM', signalGuard);
|
|
4497
|
+
process.once('SIGINT', signalGuard);
|
|
4498
|
+
// The updater now settles its own SIGTERM/SIGINT (kb/forge-update.mjs exits 143/130 with an `interrupted`
|
|
4499
|
+
// result) instead of dying of the signal, so a group kill shows up here as that exit status too.
|
|
4500
|
+
const settleIfSignalled = (child) => {
|
|
4501
|
+
if (['SIGTERM', 'SIGINT'].includes(child?.signal)) signalGuard(child.signal);
|
|
4502
|
+
else if (child?.status === 143) signalGuard('SIGTERM');
|
|
4503
|
+
else if (child?.status === 130) signalGuard('SIGINT');
|
|
4504
|
+
};
|
|
4505
|
+
const detachGuards = () => {
|
|
4506
|
+
process.removeListener('exit', exitGuard);
|
|
4507
|
+
process.removeListener('SIGTERM', signalGuard);
|
|
4508
|
+
process.removeListener('SIGINT', signalGuard);
|
|
4509
|
+
};
|
|
4239
4510
|
info(`brain dir: ${c.bold(kbDir)}`);
|
|
4240
4511
|
// RECOVER AN INTERRUPTED UPDATE FIRST, before anything below writes into the brain. Two measured
|
|
4241
4512
|
// reasons: (1) a kill between the two directory renames leaves no usable kb/ — the brain sits in a
|
|
@@ -4301,7 +4572,9 @@ async function runUpdate() {
|
|
|
4301
4572
|
let updaterResult = null;
|
|
4302
4573
|
try { if (updaterResultFile && fs.existsSync(updaterResultFile)) updaterResult = JSON.parse(fs.readFileSync(updaterResultFile, 'utf8')); }
|
|
4303
4574
|
catch { updaterResult = null; }
|
|
4575
|
+
// Before settleIfSignalled, which exits: an interrupted run used to leak one ruvnet-update-result-* temp dir.
|
|
4304
4576
|
if (updaterResultDir) fs.rmSync(updaterResultDir, { recursive: true, force: true });
|
|
4577
|
+
settleIfSignalled(r);
|
|
4305
4578
|
// FALLBACK (2026-07-17), scoped 2026-07-18 to exists-but-FAILED only. An OLDER bundle whose
|
|
4306
4579
|
// canonicalManifestUrl points at the dead main/kb/.last-built.json path and 404s (the exact break a
|
|
4307
4580
|
// real user, Jan Lafko, hit). NEVER leave the user stranded at a 404: re-run THIS installer as a
|
|
@@ -4318,7 +4591,7 @@ async function runUpdate() {
|
|
|
4318
4591
|
});
|
|
4319
4592
|
if (outcome.verdict === 'refused-retained-copies') {
|
|
4320
4593
|
warn('nothing was changed: full copies of earlier brain generations already sit beside this one (listed above).');
|
|
4321
|
-
info('
|
|
4594
|
+
info('Run npx ruvnet-brain --clean --archive (keeps only the files they alone hold, in kb.archive/), then re-run npx ruvnet-brain --update');
|
|
4322
4595
|
}
|
|
4323
4596
|
if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
|
|
4324
4597
|
// Host synchronization has a narrower contract than a full update: it must converge the
|
|
@@ -4370,6 +4643,7 @@ async function runUpdate() {
|
|
|
4370
4643
|
fr = spawnSync(process.execPath, [self, '--force'], { stdio: 'inherit',
|
|
4371
4644
|
env: { ...refreshEnv, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
|
|
4372
4645
|
}
|
|
4646
|
+
settleIfSignalled(fr);
|
|
4373
4647
|
updateStatus = fr.error ? 1 : (fr.status === null ? 1 : fr.status);
|
|
4374
4648
|
} else {
|
|
4375
4649
|
updateStatus = outcome.exitCode;
|
|
@@ -4380,8 +4654,9 @@ async function runUpdate() {
|
|
|
4380
4654
|
updaterExit, finalExit: updateStatus, fallback: outcome.fallback, verdict: outcome.verdict,
|
|
4381
4655
|
updateResult: updaterResult,
|
|
4382
4656
|
});
|
|
4383
|
-
settleRefresh(updateStatus, { phase: 'source-enumeration',
|
|
4384
|
-
|
|
4657
|
+
settleRefresh(updateStatus, { phase: 'source-enumeration',
|
|
4658
|
+
terminalVerdict: outcome.verdict === 'recovery-required' ? 'recovery-required' : 'failed' });
|
|
4659
|
+
detachGuards();
|
|
4385
4660
|
return;
|
|
4386
4661
|
}
|
|
4387
4662
|
if (cleanupPending) updateStatus = 0;
|
|
@@ -4419,7 +4694,7 @@ async function runUpdate() {
|
|
|
4419
4694
|
recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', { failures: installed.failures,
|
|
4420
4695
|
reason: 'legacy updater returned success without a valid installed ReleaseCoverage projection' });
|
|
4421
4696
|
settleRefresh(1, { phase: 'source-enumeration', terminalVerdict: 'failed' });
|
|
4422
|
-
|
|
4697
|
+
detachGuards();
|
|
4423
4698
|
return;
|
|
4424
4699
|
}
|
|
4425
4700
|
phaseEvidence = Object.fromEntries(UPDATE_REFRESH_PHASES.map((phase) => [phase, {
|
|
@@ -4433,26 +4708,76 @@ async function runUpdate() {
|
|
|
4433
4708
|
if (!phaseEvidence[phase]) {
|
|
4434
4709
|
recordRefreshPhase(refreshReceipt, phase, 'FAIL', { reason: `updater omitted ${phase} evidence` });
|
|
4435
4710
|
settleRefresh(1, { phase, terminalVerdict: 'failed' });
|
|
4436
|
-
|
|
4711
|
+
detachGuards();
|
|
4437
4712
|
return;
|
|
4438
4713
|
}
|
|
4439
4714
|
recordRefreshPhase(refreshReceipt, phase, 'PASS', phaseEvidence[phase]);
|
|
4440
4715
|
}
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4716
|
+
const finished = await finishUpdate({ kbDir, brainHome, refreshReceipt, outcome, updaterResult, cleanupPending,
|
|
4717
|
+
settle: settleRefresh, converge: async () => await convergeHostsAfterUpdate(kbDir) });
|
|
4718
|
+
detachGuards();
|
|
4719
|
+
await printConfirmation({ footprint: finished.footprint?.after || null });
|
|
4720
|
+
}
|
|
4721
|
+
|
|
4722
|
+
/**
|
|
4723
|
+
* What host convergence means for an `--update` whose knowledge phases all PASSED (the shared 4.7.21 exit
|
|
4724
|
+
* contract). Converged: PASS. A host restart as the ONLY gap (Codex reports restartScope 'unproven' on every
|
|
4725
|
+
* boot-level change): the knowledge update stands, exit 0, and the phase is recorded required:false with
|
|
4726
|
+
* restartPending, so the nightly no longer reports a good update as FAILED and still reconciles its modules
|
|
4727
|
+
* (audit C, real receipt on the owner's Mac, Oct 24). Anything else: a required FAIL, exit 1.
|
|
4728
|
+
*/
|
|
4729
|
+
export function hostConvergenceUpdateVerdict(convergence) {
|
|
4730
|
+
if (convergence?.ok && convergence.convergence?.healthy === true) return { status: 'PASS', required: true, exitCode: 0 };
|
|
4731
|
+
if (convergence?.restartOnly === true && convergence.convergence?.state === 'host-restart-required') {
|
|
4732
|
+
return { status: 'PASS', required: false, restartPending: true, exitCode: 0 };
|
|
4455
4733
|
}
|
|
4734
|
+
return { status: 'FAIL', required: true, exitCode: 1 };
|
|
4735
|
+
}
|
|
4736
|
+
|
|
4737
|
+
/**
|
|
4738
|
+
* The part of `--update` after every knowledge phase has PASSED: host convergence, managed router additions,
|
|
4739
|
+
* nightly module reconciliation, lifecycle retention, footprint, terminal settlement. Exported with its effects
|
|
4740
|
+
* injectable so the exit contract is testable without a network or a real host:
|
|
4741
|
+
* 0 knowledge updated (a host restart as the only gap is an advisory); 12 cleanup pending; 1 a real failure.
|
|
4742
|
+
* Lifecycle-evidence retention is an ADVISORY here, never the terminal verdict: it used to turn every good
|
|
4743
|
+
* update into recovery-required exit 1 and a ~745 MB reinstall, nightly (audit C). The updater's own
|
|
4744
|
+
* `retentionAdvisory` (forge-update, 4.7.21) is recorded beside the installer's measurement.
|
|
4745
|
+
*/
|
|
4746
|
+
export async function finishUpdate({ kbDir, brainHome, refreshReceipt, outcome, updaterResult = null, cleanupPending = false,
|
|
4747
|
+
settle,
|
|
4748
|
+
converge = convergeHostsAfterUpdate,
|
|
4749
|
+
reconcile = reconcileNightlyModules,
|
|
4750
|
+
routerDefault = refreshManagedRouterDefault,
|
|
4751
|
+
managedCatalog = () => applyManagedCatalogUpdate({ routerDir: path.join(os.homedir(), '.claude', 'model-router'), packageRoot: REPO_ROOT }),
|
|
4752
|
+
prune = pruneLifecycleEvidence,
|
|
4753
|
+
enforce = enforceFootprint,
|
|
4754
|
+
enroll = () => {
|
|
4755
|
+
// `--update --auto` = update now AND enroll in Evergreen, so this is the LAST time it's ever run by
|
|
4756
|
+
// hand. enableNightly() prints its own real verification (plist path + launchctl result).
|
|
4757
|
+
if (!FLAG_AUTO) return;
|
|
4758
|
+
info(c.dim("\n--auto set: enrolling in Evergreen auto-update so you never run this again…\n"));
|
|
4759
|
+
enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
|
|
4760
|
+
},
|
|
4761
|
+
} = {}) {
|
|
4762
|
+
let updateStatus = 0;
|
|
4763
|
+
info(c.dim('\nsynchronizing every detected host to this exact published version…\n'));
|
|
4764
|
+
const convergence = await converge(kbDir);
|
|
4765
|
+
const host = hostConvergenceUpdateVerdict(convergence);
|
|
4766
|
+
if (host.exitCode !== 0) {
|
|
4767
|
+
let active;
|
|
4768
|
+
try { active = JSON.parse(fs.readFileSync(path.join(footprintRoots().brainHome, 'active.json'), 'utf8')); } catch { /* report unknown activation */ }
|
|
4769
|
+
warn(hostSynchronizationFailureMessage(convergence, active));
|
|
4770
|
+
updateStatus = 1;
|
|
4771
|
+
} else if (host.restartPending) {
|
|
4772
|
+
warn(`the knowledge update succeeded; a host restart is still pending — ${convergence.convergence.action}`);
|
|
4773
|
+
} else if (convergence.convergence?.notice) info(convergence.convergence.notice);
|
|
4774
|
+
recordRefreshPhase(refreshReceipt, 'host-convergence', host.status, {
|
|
4775
|
+
state: convergence?.convergence?.state || null, error: convergence?.error || null,
|
|
4776
|
+
...(host.restartPending ? { restartPending: true, advisory: 'host restart pending; knowledge update succeeded' } : {}),
|
|
4777
|
+
...(convergence?.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
|
|
4778
|
+
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
4779
|
+
required: host.required,
|
|
4780
|
+
});
|
|
4456
4781
|
// Issue #87: a normal lifecycle update must also carry MANAGED MODEL additions to an existing
|
|
4457
4782
|
// user. This call used to live only in offerRouterProfile() — the fresh-install path — so someone
|
|
4458
4783
|
// who already had ~/.claude/model-router/catalog.json (i.e. every existing user, the only ones who
|
|
@@ -4460,13 +4785,10 @@ async function runUpdate() {
|
|
|
4460
4785
|
// updated. Additive and non-interactive; their overrides win, metered rows are never auto-enabled,
|
|
4461
4786
|
// and a failure here is reported but never fails an otherwise-good update.
|
|
4462
4787
|
if (updateStatus === 0) {
|
|
4463
|
-
const defaultReceipt =
|
|
4788
|
+
const defaultReceipt = routerDefault();
|
|
4464
4789
|
recordRefreshAdvisory(refreshReceipt, 'managed-router-default', defaultReceipt.action === 'failed' ? 'SKIP' : 'PASS', defaultReceipt);
|
|
4465
4790
|
try {
|
|
4466
|
-
const managed =
|
|
4467
|
-
routerDir: path.join(os.homedir(), '.claude', 'model-router'),
|
|
4468
|
-
packageRoot: REPO_ROOT,
|
|
4469
|
-
});
|
|
4791
|
+
const managed = managedCatalog();
|
|
4470
4792
|
if (managed.action === 'merged' && managed.added.length) {
|
|
4471
4793
|
ok(`model router: added ${managed.added.length} managed model(s) — ${managed.added.join(', ')} (your catalog edits were preserved)`);
|
|
4472
4794
|
}
|
|
@@ -4476,37 +4798,41 @@ async function runUpdate() {
|
|
|
4476
4798
|
recordRefreshAdvisory(refreshReceipt, 'managed-catalog', 'SKIP', { reason: e.message });
|
|
4477
4799
|
}
|
|
4478
4800
|
}
|
|
4479
|
-
//
|
|
4480
|
-
|
|
4481
|
-
//
|
|
4482
|
-
|
|
4483
|
-
|
|
4484
|
-
enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
|
|
4485
|
-
}
|
|
4486
|
-
if (updateStatus === 0) reconcileNightlyModules({ brainHome, refreshReceipt });
|
|
4801
|
+
// Only enroll if the update itself succeeded — never promise "you're set forever" on a failed update.
|
|
4802
|
+
if (updateStatus === 0) enroll();
|
|
4803
|
+
// The knowledge phases passed, so the nightly must run the installed modules even when a host did not
|
|
4804
|
+
// converge; otherwise a host problem froze the 03:30 job on yesterday's coordinator (audit C).
|
|
4805
|
+
reconcile({ brainHome, refreshReceipt });
|
|
4487
4806
|
let retention;
|
|
4488
4807
|
try {
|
|
4489
|
-
retention =
|
|
4808
|
+
retention = prune({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
|
|
4490
4809
|
preserveTransactionPaths: updaterResult?.transactionReceipts ? [updaterResult.transactionReceipts] : [] });
|
|
4491
4810
|
} catch (error) {
|
|
4492
4811
|
retention = { schemaVersion: 1, kind: 'ruvnet-brain-lifecycle-evidence-retention',
|
|
4493
4812
|
withinBudget: false, unsafe: [{ path: brainHome, reason: error.message }] };
|
|
4494
4813
|
}
|
|
4495
|
-
const retentionFailed = retention
|
|
4496
|
-
const
|
|
4497
|
-
recordRefreshPhase(refreshReceipt, 'cleanup',
|
|
4814
|
+
const retentionFailed = retention?.withinBudget !== true;
|
|
4815
|
+
const updaterRetention = updaterResult?.retentionAdvisory || null;
|
|
4816
|
+
recordRefreshPhase(refreshReceipt, 'cleanup', cleanupPending ? 'FAIL' : (updateStatus === 0 ? 'PASS' : 'SKIP'), {
|
|
4498
4817
|
reason: cleanupPending ? 'verified live generation has redundant rollback cleanup pending'
|
|
4499
|
-
:
|
|
4500
|
-
: (updateStatus === 0 ? 'transaction settled' : 'upstream required phase failed'),
|
|
4818
|
+
: (updateStatus === 0 ? 'transaction settled' : 'upstream required phase failed'),
|
|
4501
4819
|
storageDelta: updaterResult?.storageDelta || null,
|
|
4502
4820
|
lifecycleRetention: retention,
|
|
4503
4821
|
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
4504
|
-
required:
|
|
4822
|
+
required: cleanupPending || updateStatus === 0,
|
|
4505
4823
|
});
|
|
4824
|
+
if (retentionFailed || updaterRetention) {
|
|
4825
|
+
const unsafe = (retentionFailed ? retention?.unsafe || [] : []).map((entry) => entry?.path).filter(Boolean);
|
|
4826
|
+
warn(`lifecycle evidence could not be fully pruned (advisory; the update itself stands)${unsafe.length ? `: ${unsafe.slice(0, 3).join(', ')}` : ''}`);
|
|
4827
|
+
try {
|
|
4828
|
+
recordRefreshAdvisory(refreshReceipt, 'lifecycle-retention', 'FAIL', {
|
|
4829
|
+
installer: retentionFailed ? retention : null, updater: updaterRetention });
|
|
4830
|
+
} catch { /* the advisory never blocks settlement */ }
|
|
4831
|
+
}
|
|
4506
4832
|
// ADR-0098: after the swap, enforce the footprint (the updater just released its own rollback; this
|
|
4507
4833
|
// releases everything else that must not exist) and record the result on the receipt as an advisory —
|
|
4508
4834
|
// a footprint problem is reported, it never fails an otherwise-good update.
|
|
4509
|
-
const footprint =
|
|
4835
|
+
const footprint = enforce({ holdingRefreshLock: true });
|
|
4510
4836
|
try {
|
|
4511
4837
|
const after = footprint?.after;
|
|
4512
4838
|
recordRefreshAdvisory(refreshReceipt, 'footprint', after && after.kbCopies === 1 && !after.cruft.length && after.withinBudget ? 'PASS' : 'FAIL', {
|
|
@@ -4514,13 +4840,15 @@ async function runUpdate() {
|
|
|
4514
4840
|
budgetBytes: after?.budgetBytes ?? null, removed: footprint?.removed.length ?? 0, freedBytes: footprint?.freedBytes ?? 0,
|
|
4515
4841
|
});
|
|
4516
4842
|
} catch { /* the advisory never blocks settlement */ }
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4843
|
+
// A genuine failure (a required host convergence) outranks cleanup-pending: 12 tells the coordinator and
|
|
4844
|
+
// host-update the knowledge applied and only redundant cleanup is owed, so it must not mask an exit 1.
|
|
4845
|
+
const failed = updateStatus !== 0;
|
|
4846
|
+
const exitCode = failed ? updateStatus : (cleanupPending ? 12 : 0);
|
|
4847
|
+
settle(exitCode, {
|
|
4848
|
+
phase: failed ? 'failed' : (cleanupPending ? 'cleanup' : 'complete'),
|
|
4849
|
+
terminalVerdict: failed ? 'failed' : cleanupPending ? 'cleanup-pending' : (outcome?.verdict === 'noop' ? 'noop' : 'applied'),
|
|
4521
4850
|
storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
|
|
4522
|
-
|
|
4523
|
-
await printConfirmation({ footprint: footprint?.after || null });
|
|
4851
|
+
return { exitCode, footprint, hostConvergence: host };
|
|
4524
4852
|
}
|
|
4525
4853
|
|
|
4526
4854
|
/**
|
|
@@ -4625,15 +4953,56 @@ const SPEND_GUARD_LABEL = 'com.ruvnet.spend-watchdog';
|
|
|
4625
4953
|
const spendGuardScriptPath = () => path.join(os.homedir(), '.claude', 'scripts', 'api-spend-watchdog.mjs');
|
|
4626
4954
|
const spendGuardPlistPath = () => path.join(os.homedir(), 'Library', 'LaunchAgents', `${SPEND_GUARD_LABEL}.plist`);
|
|
4627
4955
|
|
|
4628
|
-
|
|
4956
|
+
/**
|
|
4957
|
+
* Every LaunchAgent that already runs an api-spend-watchdog.mjs, under ANY label (the owner's runs as
|
|
4958
|
+
* com.stuartkerr.api-spend-watchdog). Matched on ProgramArguments, so a second hourly job is never added beside
|
|
4959
|
+
* an existing one. Read-only; a binary plist is converted with plutil, an unreadable one is skipped.
|
|
4960
|
+
*/
|
|
4961
|
+
export function spendGuardAgents({ agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents') } = {}) {
|
|
4962
|
+
let names = [];
|
|
4963
|
+
try { names = fs.readdirSync(agentsDir).filter((name) => name.endsWith('.plist')); } catch { return []; }
|
|
4964
|
+
const found = [];
|
|
4965
|
+
for (const name of names) {
|
|
4966
|
+
const file = path.join(agentsDir, name);
|
|
4967
|
+
let text;
|
|
4968
|
+
try {
|
|
4969
|
+
const bytes = fs.readFileSync(file);
|
|
4970
|
+
text = bytes.subarray(0, 6).toString('latin1') === 'bplist'
|
|
4971
|
+
? spawnSync('plutil', ['-convert', 'xml1', '-o', '-', file], { encoding: 'utf8' }).stdout || ''
|
|
4972
|
+
: bytes.toString('utf8');
|
|
4973
|
+
} catch { continue; }
|
|
4974
|
+
const args = /<key>\s*ProgramArguments\s*<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1] || '';
|
|
4975
|
+
if (!/api-spend-watchdog\.mjs/.test(args)) continue;
|
|
4976
|
+
const label = /<key>\s*Label\s*<\/key>\s*<string>([^<]*)<\/string>/.exec(text)?.[1]?.trim() || name.replace(/\.plist$/, '');
|
|
4977
|
+
found.push({ label, plist: file });
|
|
4978
|
+
}
|
|
4979
|
+
return found;
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4982
|
+
export function enableSpendGuard() {
|
|
4983
|
+
// A spend watchdog already scheduled under another label (the owner's own job, or an earlier hand install)
|
|
4984
|
+
// is the same safety net: say where it is and add nothing. Two hourly jobs mean two alerts per event.
|
|
4985
|
+
const foreign = spendGuardAgents().filter((agent) => agent.label !== SPEND_GUARD_LABEL);
|
|
4986
|
+
if (foreign.length) {
|
|
4987
|
+
ok(`spend watchdog already installed as ${c.bold(foreign[0].label)} (${foreign[0].plist}) — not adding a second hourly job`);
|
|
4988
|
+
return 'already-on';
|
|
4989
|
+
}
|
|
4629
4990
|
// The npx checkout is ephemeral, so copy the bundled watchdog to a persistent home the launchd
|
|
4630
|
-
// job can point at for good
|
|
4991
|
+
// job can point at for good — but NEVER over a different file already there: it may be the user's own
|
|
4992
|
+
// fixed copy (the owner's 2026-10-25 flood fix was one interactive install away from being reverted).
|
|
4631
4993
|
const src = path.join(__dirname, 'api-spend-watchdog.mjs');
|
|
4632
4994
|
const dst = spendGuardScriptPath();
|
|
4633
4995
|
if (!fs.existsSync(src)) { warn('spend-watchdog source missing from this bundle — skipping (non-fatal)'); return 'no-source'; }
|
|
4634
4996
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
4635
|
-
|
|
4636
|
-
|
|
4997
|
+
let existing = null;
|
|
4998
|
+
try { existing = fs.readFileSync(dst); } catch { /* absent */ }
|
|
4999
|
+
if (existing && !existing.equals(fs.readFileSync(src))) {
|
|
5000
|
+
warn(`kept your existing ${c.bold(dst)} — it differs from the bundled watchdog, so it was not overwritten`);
|
|
5001
|
+
info(`to use the bundled one instead: ${c.bold(`rm ${dst}`)} then ${c.bold('npx ruvnet-brain --enable-spend-guard')}`);
|
|
5002
|
+
} else if (!existing) {
|
|
5003
|
+
fs.copyFileSync(src, dst);
|
|
5004
|
+
ok(`installed the spend watchdog → ${c.bold(dst)}`);
|
|
5005
|
+
} else ok(`spend watchdog script already current → ${c.bold(dst)}`);
|
|
4637
5006
|
|
|
4638
5007
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4639
5008
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -4686,6 +5055,8 @@ function disableSpendGuard() {
|
|
|
4686
5055
|
const plistPath = spendGuardPlistPath();
|
|
4687
5056
|
const scriptPath = spendGuardScriptPath();
|
|
4688
5057
|
const existed = fs.existsSync(plistPath);
|
|
5058
|
+
// The script stays when another LaunchAgent runs it: that job is not ours to break.
|
|
5059
|
+
const sharedWith = spendGuardAgents().filter((agent) => agent.label !== SPEND_GUARD_LABEL);
|
|
4689
5060
|
if (TEST_MODE) {
|
|
4690
5061
|
warn('RUVNET_BRAIN_TEST=1 — skipping launchctl bootout (plist removal only)');
|
|
4691
5062
|
} else {
|
|
@@ -4693,7 +5064,8 @@ function disableSpendGuard() {
|
|
|
4693
5064
|
spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${SPEND_GUARD_LABEL}`], { stdio: 'ignore' });
|
|
4694
5065
|
}
|
|
4695
5066
|
let failed = false;
|
|
4696
|
-
|
|
5067
|
+
if (sharedWith.length && fs.existsSync(scriptPath)) info(`kept ${scriptPath} — ${sharedWith[0].label} runs it`);
|
|
5068
|
+
for (const p of sharedWith.length ? [plistPath] : [plistPath, scriptPath]) {
|
|
4697
5069
|
if (!fs.existsSync(p)) continue;
|
|
4698
5070
|
try { fs.rmSync(p); } catch (e) {
|
|
4699
5071
|
failed = true;
|
|
@@ -4748,10 +5120,10 @@ function footprintEvidence() {
|
|
|
4748
5120
|
|
|
4749
5121
|
/** Enforce the footprint now: remove what must not exist (proof-gated), rotate over-cap logs, collect
|
|
4750
5122
|
* lease-free plugin generations. Narrates every removal and every refusal; never throws. */
|
|
4751
|
-
export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false } = {}) {
|
|
5123
|
+
export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false, archive = false } = {}) {
|
|
4752
5124
|
let result;
|
|
4753
5125
|
try {
|
|
4754
|
-
result = sweepFootprint({ apply: true, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
|
|
5126
|
+
result = sweepFootprint({ apply: true, archive, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
|
|
4755
5127
|
collectPluginGenerations: ({ registryPath, apply }) => prunePluginGenerations({ registryPath, apply }),
|
|
4756
5128
|
pruneEvidence: pruneEvidence ? (args) => pruneLifecycleEvidence(args) : null });
|
|
4757
5129
|
} catch (error) {
|
|
@@ -4810,7 +5182,10 @@ export function machineFootprint() {
|
|
|
4810
5182
|
}
|
|
4811
5183
|
if (process.platform === 'darwin') {
|
|
4812
5184
|
add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
|
|
4813
|
-
|
|
5185
|
+
// A script another LaunchAgent runs is not ours to list for removal (disableSpendGuard keeps it).
|
|
5186
|
+
if (!spendGuardAgents().some((agent) => agent.label !== SPEND_GUARD_LABEL)) {
|
|
5187
|
+
add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
|
|
5188
|
+
}
|
|
4814
5189
|
}
|
|
4815
5190
|
const cmds = pluginCommandsDir();
|
|
4816
5191
|
if (cmds) items.push({
|
|
@@ -4831,6 +5206,12 @@ export function machineFootprint() {
|
|
|
4831
5206
|
items.push({ label: 'CLAUDE.md block (6 lines, between markers)', path: cmdPath, undo: 'npx ruvnet-brain --uninstall' });
|
|
4832
5207
|
}
|
|
4833
5208
|
} catch { /* unreadable */ }
|
|
5209
|
+
try {
|
|
5210
|
+
const grokAgents = grokPaths().agents;
|
|
5211
|
+
if (fs.existsSync(grokAgents) && fs.readFileSync(grokAgents, 'utf8').includes(CLAUDE_MD_START)) {
|
|
5212
|
+
items.push({ label: 'Grok AGENTS.md search-first line (between markers)', path: grokAgents, undo: 'npx ruvnet-brain --uninstall' });
|
|
5213
|
+
}
|
|
5214
|
+
} catch { /* unreadable */ }
|
|
4834
5215
|
add('Usage-counts preference', telemetryConsentPath(), 'delete this file');
|
|
4835
5216
|
|
|
4836
5217
|
// EVERYTHING ELSE THIS INSTALLER WRITES. The first version of this listed the KB bundle and
|
|
@@ -4935,8 +5316,7 @@ function showWhatsNew() {
|
|
|
4935
5316
|
* Backed up and written atomically, same as when it was added — taking something away is at least
|
|
4936
5317
|
* as sensitive as putting it there.
|
|
4937
5318
|
*/
|
|
4938
|
-
function removeClaudeMdBlock() {
|
|
4939
|
-
const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
5319
|
+
function removeClaudeMdBlock(p = path.join(os.homedir(), '.claude', 'CLAUDE.md')) {
|
|
4940
5320
|
let src = '';
|
|
4941
5321
|
try { src = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { return 'unreadable'; }
|
|
4942
5322
|
// PAIR THE MARKERS PROPERLY. The first version took the FIRST start and the FIRST end, unpaired.
|
|
@@ -5015,6 +5395,7 @@ function uninstallAll() {
|
|
|
5015
5395
|
|
|
5016
5396
|
const claudeMd = removeClaudeMdBlock();
|
|
5017
5397
|
if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
|
|
5398
|
+
if (removeClaudeMdBlock(grokPaths().agents) === 'removed') ok('removed our line from ~/.grok/AGENTS.md (your content untouched, backup saved)');
|
|
5018
5399
|
|
|
5019
5400
|
// GAP FIX: this used to be permanently "manual" — machineFootprint() treated ANY settings.json edit
|
|
5020
5401
|
// as something only the user could safely touch. That blanket rule was right for edits we cannot
|
|
@@ -5085,9 +5466,16 @@ function uninstallAll() {
|
|
|
5085
5466
|
// Exported (testable under RUVNET_BRAIN_IMPORT_ONLY=1, like offerNightly). Never throws — the caller
|
|
5086
5467
|
// also guards, because a finished install must never be broken by an optional safety offer.
|
|
5087
5468
|
export async function offerSpendGuard() {
|
|
5469
|
+
// Read-only detection first, so an existing guard under ANY label is reported as on (and never offered twice).
|
|
5470
|
+
if (process.platform === 'darwin') {
|
|
5471
|
+
const existing = spendGuardAgents();
|
|
5472
|
+
if (existing.length || fs.existsSync(spendGuardPlistPath())) {
|
|
5473
|
+
ok(`spend watchdog already installed${existing.length ? ` as ${existing[0].label}` : ''} — runaway API spend will alert you`);
|
|
5474
|
+
return 'already-on';
|
|
5475
|
+
}
|
|
5476
|
+
}
|
|
5088
5477
|
if (FLAG_NO_NIGHTLY_PROMPT || TEST_MODE) return 'suppressed';
|
|
5089
5478
|
if (process.platform !== 'darwin') return 'unsupported';
|
|
5090
|
-
if (fs.existsSync(spendGuardPlistPath())) { ok('spend watchdog already installed — runaway API spend will alert you'); return 'already-on'; }
|
|
5091
5479
|
|
|
5092
5480
|
step(
|
|
5093
5481
|
'One more safety net — a spend watchdog',
|
|
@@ -6262,6 +6650,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
6262
6650
|
` • the Codex host ${hostReady && pluginReady ? c.green('wired at user scope') : c.yellow('needs attention')} — search_ruvnet + native Brain skills`,
|
|
6263
6651
|
);
|
|
6264
6652
|
}
|
|
6653
|
+
const grokLine = grokStatusLine(currentGrokStatus());
|
|
6654
|
+
if (grokLine) console.log(` • ${grokLine}`);
|
|
6265
6655
|
if (env) {
|
|
6266
6656
|
const t = (okv) => (okv ? c.green('✓ present') : c.yellow('not added (optional)'));
|
|
6267
6657
|
console.log(` • rUv build toolkit — Ruflo: ${t(env.ruflo)} · RuVector: ${t(env.ruvector)} ${c.dim('answers work without them')}`);
|
|
@@ -6276,8 +6666,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
6276
6666
|
console.log(`\n ${c.bold('Where it runs:')} ${c.bold(`everywhere you use ${hostLabel}`)} — CLI and supported editor/desktop surfaces.`);
|
|
6277
6667
|
console.log(` It's ${c.bold('user-level (global)')}: open ANY repo or folder and it's already there. Nothing per project,`);
|
|
6278
6668
|
console.log(` nothing to copy in, nothing to git-ignore. ${c.dim('(Runs locally — it is not active in the claude.ai web app.)')}`);
|
|
6279
|
-
console.log(`\n ${c.bold('How it runs:')}
|
|
6280
|
-
console.log(`
|
|
6669
|
+
console.log(`\n ${c.bold('How it runs:')} search_ruvnet, Brain skills and proof commands when you ask, plus the allowlisted`);
|
|
6670
|
+
console.log(` lifecycle hooks (${hookCountPhrase()}): grounding reminders, a write gate, and continuity snapshots.`);
|
|
6281
6671
|
console.log(`\n ${c.bold('Keep it fresh:')} re-run ${c.bold('npx ruvnet-brain')} any time — the brain itself always pulls the latest`);
|
|
6282
6672
|
console.log(` Release regardless (that part isn't cached). For the bleeding-edge installer too, use ${c.bold('npx github:stuinfla/ruvnet-brain')}.`);
|
|
6283
6673
|
|
|
@@ -6294,12 +6684,12 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
6294
6684
|
console.log(`\n ${c.bold('How you\'ll know it\'s working:')}`);
|
|
6295
6685
|
console.log(` ${c.green('✓')} ${c.bold('search_ruvnet')} returns cited source paths when invoked.`);
|
|
6296
6686
|
console.log(` ${c.green('✓')} Brain skills and slash commands remain available.`);
|
|
6297
|
-
console.log(` ${c.green('✓')}
|
|
6687
|
+
console.log(` ${c.green('✓')} Writing rUv-product code the Brain has not looked up is refused until search_ruvnet grounds it.`);
|
|
6298
6688
|
|
|
6299
6689
|
// ── honest expectations ──
|
|
6300
6690
|
console.log(`\n ${c.bold('What to expect (honestly):')}`);
|
|
6301
|
-
console.log(` • Grounding
|
|
6302
|
-
console.log(` •
|
|
6691
|
+
console.log(` • Grounding reminders are added only to prompts that touch the rUv stack.`);
|
|
6692
|
+
console.log(` • Other work proceeds normally; the write gate is the only hook that refuses by default.`);
|
|
6303
6693
|
console.log(` • Not sure it's on? Run ${c.bold('npx ruvnet-brain --doctor')} any time for a health check.
|
|
6304
6694
|
• Want to see it answer, live, right now? ${c.bold('npx ruvnet-brain --demo')} — 2 real questions, real cited answers.`);
|
|
6305
6695
|
|
|
@@ -6325,7 +6715,7 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
6325
6715
|
console.log(`\n ${c.bold('If it earns it:')} a GitHub star helps other people find the brain —`);
|
|
6326
6716
|
console.log(` ${c.bold('https://github.com/stuinfla/ruvnet-brain')} ${c.dim('· feedback in one command: npx ruvnet-brain --feedback')}`);
|
|
6327
6717
|
|
|
6328
|
-
console.log(`\n ${c.dim(
|
|
6718
|
+
console.log(`\n ${c.dim(`The plugin is disable-able. It registers lifecycle hooks (${hookCountPhrase()}), only the allowlisted set in continuity-hook-policy.mjs; Codex runs its set once trusted in /hooks.`)}`);
|
|
6329
6719
|
console.log('');
|
|
6330
6720
|
}
|
|
6331
6721
|
|
|
@@ -6345,11 +6735,15 @@ Usage:
|
|
|
6345
6735
|
footprint within budget, no cruft. --doctor --json prints only that, as JSON.
|
|
6346
6736
|
npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
|
|
6347
6737
|
private files the live brain lacks — old installers, over-cap logs), then confirm.
|
|
6738
|
+
npx ruvnet-brain --clean --archive
|
|
6739
|
+
Same, and for a kept copy: copy only the files it alone holds into
|
|
6740
|
+
kb.archive/<stamp>/ (verified byte-for-byte), then release that copy.
|
|
6348
6741
|
EXITS NON-ZERO when the install is genuinely broken, so it can gate a
|
|
6349
6742
|
script: npx ruvnet-brain --doctor && ./deploy.sh
|
|
6350
6743
|
npx ruvnet-brain --doctor --hooks
|
|
6351
|
-
|
|
6352
|
-
|
|
6744
|
+
Read-only proof that the shipped hook manifests register only the
|
|
6745
|
+
allowlisted continuity handlers (${hookCountPhrase()}).
|
|
6746
|
+
Executes no hook body.
|
|
6353
6747
|
npx ruvnet-brain --demo Guided walkthrough — 2 real questions, real cited answers
|
|
6354
6748
|
npx ruvnet-brain --feedback Tell us how it went — prefills a GitHub Discussion with your brain
|
|
6355
6749
|
version, platform, and a 3-line health summary (you see exactly
|
|
@@ -6366,6 +6760,8 @@ Usage:
|
|
|
6366
6760
|
block only — your own CLAUDE.md content is preserved and backed up)
|
|
6367
6761
|
npx ruvnet-brain --enable-spend-guard Install the hourly runaway-agent spend alarm (alert-only)
|
|
6368
6762
|
npx ruvnet-brain --disable-spend-guard Remove it (safe to run any time)
|
|
6763
|
+
npx ruvnet-brain --grok-agents-md Grok CLI only, opt-in: add one search-first line to ~/.grok/AGENTS.md
|
|
6764
|
+
(only if no search_ruvnet line is there; backed up; --uninstall removes it)
|
|
6369
6765
|
npx ruvnet-brain --enhance-claude-md Add the 6-line RuvNet-Brain block to ~/.claude/CLAUDE.md
|
|
6370
6766
|
(appended at the bottom between markers; your file is backed up first)
|
|
6371
6767
|
(a default install RECOMMENDS nightly and asks, defaulting to yes)
|
|
@@ -6412,11 +6808,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6412
6808
|
// in a unit test ran a real install against the maintainer's HOME. Direct argv identity is the
|
|
6413
6809
|
// authority boundary. The old IMPORT_ONLY flag may still be present in a long-lived test worker;
|
|
6414
6810
|
// it must never suppress a genuinely direct CLI execution.
|
|
6415
|
-
const
|
|
6416
|
-
try { return fs.realpathSync(value); } catch { return path.resolve(value); }
|
|
6417
|
-
};
|
|
6418
|
-
const invokedDirectly = Boolean(process.argv[1])
|
|
6419
|
-
&& canonical(process.argv[1]) === canonical(fileURLToPath(import.meta.url));
|
|
6811
|
+
const invokedDirectly = isDirectInvocation(import.meta.url);
|
|
6420
6812
|
if (!invokedDirectly) return;
|
|
6421
6813
|
if (FLAG_HELP) return showHelp();
|
|
6422
6814
|
if (NAMED_RELEASE?.error) {
|
|
@@ -6461,7 +6853,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6461
6853
|
// remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
|
|
6462
6854
|
if (FLAG_CLEAN) {
|
|
6463
6855
|
if (!FLAG_JSON) printBanner('clean');
|
|
6464
|
-
const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON });
|
|
6856
|
+
const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON, archive: FLAG_ARCHIVE });
|
|
6465
6857
|
const result = await printConfirmation({ footprint: swept?.after || null, json: FLAG_JSON });
|
|
6466
6858
|
process.exitCode = swept && result.footprint.kbCopies === 1 && result.footprint.cruft.length === 0 ? 0 : 1;
|
|
6467
6859
|
return;
|
|
@@ -6489,6 +6881,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6489
6881
|
if (FLAG_ENABLE_SPEND_GUARD) { enableSpendGuard(); return; }
|
|
6490
6882
|
if (FLAG_DISABLE_SPEND_GUARD) { disableSpendGuard(); return; }
|
|
6491
6883
|
if (FLAG_UNINSTALL) { uninstallAll(); return; }
|
|
6884
|
+
if (FLAG_GROK_AGENTS_MD) { reportGrokAgentsLine(addGrokAgentsLine()); return; }
|
|
6492
6885
|
if (FLAG_WHAT_CHANGED) { printBanner('what RuvNet Brain put on this machine'); printFootprint(); return; }
|
|
6493
6886
|
if (FLAG_WHATS_NEW) { showWhatsNew(); return; }
|
|
6494
6887
|
|
|
@@ -6686,6 +7079,9 @@ the installer reports that boot-level declarations changed.
|
|
|
6686
7079
|
codexHost = wireCodexHost();
|
|
6687
7080
|
if (codexHost.host) {
|
|
6688
7081
|
codexPlugin = wireCodexPlugin();
|
|
7082
|
+
const hostRefresh = await refreshCodexSharedHost(codexPlugin);
|
|
7083
|
+
const refreshNote = codexHostRefreshMessage(hostRefresh, codexPlugin);
|
|
7084
|
+
if (refreshNote) (refreshNote.level === 'warn' ? warn : info)(refreshNote.text);
|
|
6689
7085
|
if (codexPlugin.available) printCodexLifecycle(await codexLifecycleStatus());
|
|
6690
7086
|
}
|
|
6691
7087
|
} catch (e) {
|
|
@@ -6711,6 +7107,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6711
7107
|
const env = detectEnvironment();
|
|
6712
7108
|
try { await offerStack(env); } catch (e) { warn(`(toolkit check skipped: ${e && e.message})`); }
|
|
6713
7109
|
try { await offerClaudeMd(); } catch { /* non-fatal — never let an offer break the install */ }
|
|
7110
|
+
try { await offerGrokAgentsMd(); } catch { /* non-fatal — never let an offer break the install */ }
|
|
6714
7111
|
// Stuart's requirement: a default install must end by clearly recommending nightly auto-updates
|
|
6715
7112
|
// and asking, DEFAULTING TO YES (TTY + macOS + not already on). Non-fatal like every other offer.
|
|
6716
7113
|
let nightly = 'skipped';
|