ruvnet-brain 4.7.20 → 4.7.22
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 +618 -104
- package/console/activity.js +122 -38
- 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-process.mjs +115 -5
- package/plugin/scripts/agentdb-recall-reader.mjs +44 -2
- package/plugin/scripts/agentdb-recall.mjs +49 -17
- 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 +241 -23
- package/plugin/scripts/continuation-gate.mjs +75 -11
- 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-gate.mjs +7 -39
- package/plugin/scripts/lesson-promote.mjs +83 -22
- package/plugin/scripts/lesson-scope.mjs +51 -0
- package/plugin/scripts/lesson-store.mjs +1 -1
- 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/ruflo-bin.mjs +14 -6
- package/plugin/scripts/ruflo-memory-hooks.mjs +157 -19
- package/plugin/scripts/session-snapshot-hook.mjs +25 -5
- package/plugin/scripts/session-start-core.mjs +7 -16
- package/plugin/scripts/session-start-health.mjs +98 -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-boot-surface-proof.mjs +208 -0
- 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 +123 -23
- 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/recall-latency-probe.mjs +48 -0
- 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 +82 -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 +13 -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,15 @@ 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 { proveCodexBootSurface, parseChangedPaths } from '../scripts/codex-boot-surface-proof.mjs';
|
|
89
|
+
import { refreshSharedCodexAppServer } from '../scripts/codex-hook-trust-reconcile.mjs';
|
|
90
|
+
import { recordCodexOvernightNotice } from '../plugin/scripts/codex-overnight-notice.mjs';
|
|
88
91
|
import { repairSecurityGuidance } from '../scripts/security-guidance-codex-compat.mjs';
|
|
89
92
|
import { installNativeLaunchers, installRoutingRuntime, runtimeSnapshot } from '../scripts/model-routing-launchers.mjs';
|
|
90
93
|
import { installTerminalLaunchers } from '../scripts/model-terminal-launchers.mjs';
|
|
94
|
+
import { addGrokAgentsLine, grokPaths, grokStatus, grokStatusLine, GROK_AGENTS_LINE } from '../scripts/grok-host.mjs';
|
|
91
95
|
import { readConsoleReceipts, replaceStaleConsoles } from '../scripts/console-instances.mjs';
|
|
92
96
|
import { moveBrain, MoveRefused } from '../scripts/move-brain.mjs';
|
|
93
97
|
import { brainLocation } from '../plugin/scripts/brain-location.mjs';
|
|
@@ -96,6 +100,7 @@ import { runHostCli, waitForHostCli } from '../scripts/host-cli.mjs';
|
|
|
96
100
|
import {
|
|
97
101
|
writeInstalledRuntimeIdentity, recordCorpusTransportIdentity, isCorpusReleaseTag, rejectedReleasePath,
|
|
98
102
|
} from '../kb/corpus-release-identity.mjs';
|
|
103
|
+
import { isDirectInvocation } from '../plugin/scripts/entrypoint.mjs';
|
|
99
104
|
|
|
100
105
|
// SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's
|
|
101
106
|
// trust root travels with the installer code itself: an attacker who swaps the downloaded bundle
|
|
@@ -166,6 +171,7 @@ const FLAG_MOVE_BRAIN = argv.includes('--move-brain');
|
|
|
166
171
|
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
172
|
const FLAG_CLEAN = argv.includes('--clean'); // enforce the footprint guarantee now (ADR-0098), then confirm
|
|
168
173
|
const FLAG_JSON = argv.includes('--json'); // with --doctor / --clean: machine-readable confirmation only
|
|
174
|
+
const FLAG_ARCHIVE = argv.includes('--archive'); // with --clean only (#335): archive files unique to old KB copies, then release them
|
|
169
175
|
// ── onboarding-experience flags (all optional; every offer is safe to decline) ──
|
|
170
176
|
const FLAG_YES = argv.includes('--yes') || argv.includes('-y'); // accept every optional offer non-interactively
|
|
171
177
|
const FLAG_PLAN = argv.includes('--plan') || argv.includes('--dry-run'); // show the interactive checklist, then exit — install NOTHING
|
|
@@ -173,6 +179,7 @@ const FLAG_WITH_STACK = argv.includes('--with-stack'); // add missing Ruflo/RuVe
|
|
|
173
179
|
const FLAG_NO_STACK = argv.includes('--no-stack'); // skip the toolkit offer entirely
|
|
174
180
|
const FLAG_ENHANCE_CLAUDE_MD = argv.includes('--enhance-claude-md'); // add the CLAUDE.md section without prompting
|
|
175
181
|
const FLAG_NO_ENHANCE = argv.includes('--no-enhance'); // skip the CLAUDE.md offer entirely
|
|
182
|
+
const FLAG_GROK_AGENTS_MD = argv.includes('--grok-agents-md'); // opt in: add the search-first line to ~/.grok/AGENTS.md (#301)
|
|
176
183
|
const FLAG_STATUSLINE = argv.includes('--statusline'); // opt in to the status-bar version segment, non-interactively
|
|
177
184
|
const FLAG_NO_STATUSLINE = argv.includes('--no-statusline'); // decline the status-bar offer without prompting
|
|
178
185
|
// --version <tag> forces a specific Release tag (e.g. --version v0.5.0-dev); --pin <tag> is the same.
|
|
@@ -286,7 +293,8 @@ function download(url, dest) {
|
|
|
286
293
|
let lastShown = -1;
|
|
287
294
|
return downloadFileWithRetry(url, dest, {
|
|
288
295
|
headers: { 'User-Agent': 'ruvnet-brain-installer', Accept: 'application/octet-stream' },
|
|
289
|
-
|
|
296
|
+
// The retry continues the staged bytes (HTTP Range) when the server proves the same asset; otherwise it restarts.
|
|
297
|
+
onRetry: ({ attempt, error }) => warn(`download interrupted (${error.message}); retry ${attempt + 1}/3, resuming where it stopped when the server allows`),
|
|
290
298
|
onProgress: ({ received, total }) => {
|
|
291
299
|
const mb = Math.floor(received / 1e6);
|
|
292
300
|
const pct = total ? Math.floor(received / total * 100) : mb;
|
|
@@ -550,7 +558,7 @@ function placeFileAtomic(target, bytes) {
|
|
|
550
558
|
// one. The signed npm package carries the updater and the sibling modules it imports; the `--update`
|
|
551
559
|
// preflight places them (same trust as the validator) so a stale updater upgrades itself before it runs.
|
|
552
560
|
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'];
|
|
561
|
+
'update-storage-transaction.mjs', 'lifecycle-evidence-retention.mjs', 'corpus-release-identity.mjs', 'download-retry.mjs', 'forge-guard-injection.mjs'];
|
|
554
562
|
export function placeUpdater(kbDir, { sourceDir = path.join(REPO_ROOT, 'kb') } = {}) {
|
|
555
563
|
const placed = {};
|
|
556
564
|
for (const name of UPDATER_FILES) {
|
|
@@ -804,6 +812,10 @@ export async function unzipInto(zipPath, cacheDir, sourceDir = null, { releaseTa
|
|
|
804
812
|
const proof = kbCopyProof({ copyDir: preservedDir, liveDir: cacheDir });
|
|
805
813
|
if (proof.disposable) {
|
|
806
814
|
try {
|
|
815
|
+
// Its logs and ruflo scratch are copied out and verified first; a failed rescue keeps the copy. The
|
|
816
|
+
// rescue root is the sweep's (footprintRoots: links resolved, as after --move-brain), so the inventory
|
|
817
|
+
// finds and bounds kb-copy-rescued/ wherever the installer put it.
|
|
818
|
+
rescueOperationalFiles(preservedDir, proof.rescue, footprintRoots().brainHome);
|
|
807
819
|
fs.rmSync(preservedDir, { recursive: true, force: true });
|
|
808
820
|
ok(`released the prior generation (${proof.reason})`);
|
|
809
821
|
priorGeneration = { status: 'RELEASED', path: preservedDir, reason: proof.reason };
|
|
@@ -1380,6 +1392,40 @@ export function claudePluginStatus({ home = os.homedir() } = {}) {
|
|
|
1380
1392
|
}
|
|
1381
1393
|
}
|
|
1382
1394
|
|
|
1395
|
+
// #420 item 4: Claude Code also records per-project installs (scope "local"/"project", keyed by projectPath), and --update
|
|
1396
|
+
// moves only the user row above, so a project row keeps running its old generation. `claude plugin update --scope local`
|
|
1397
|
+
// 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
|
|
1398
|
+
// projectPath is gone cannot be running anything; it is listed, never counted as behind and never given a `cd` that fails.
|
|
1399
|
+
export function claudeProjectInstalls({ home = os.homedir(), referenceVersion = null } = {}) {
|
|
1400
|
+
let rows;
|
|
1401
|
+
try { rows = JSON.parse(fs.readFileSync(path.join(home, '.claude', 'plugins', 'installed_plugins.json'), 'utf8'))?.plugins?.['ruvnet-brain@ruvnet-brain']; } catch { return []; }
|
|
1402
|
+
if (!Array.isArray(rows)) return [];
|
|
1403
|
+
return rows.filter((row) => (row?.scope === 'local' || row?.scope === 'project') && typeof row.projectPath === 'string' && row.projectPath).map((row) => {
|
|
1404
|
+
let version = null;
|
|
1405
|
+
try { version = JSON.parse(fs.readFileSync(path.join(row.installPath, '.claude-plugin', 'plugin.json'), 'utf8'))?.version || null; } catch { /* registry metadata below */ }
|
|
1406
|
+
version ||= typeof row.version === 'string' ? row.version : null;
|
|
1407
|
+
let exists = false;
|
|
1408
|
+
try { exists = fs.statSync(row.projectPath).isDirectory(); } catch { /* gone */ }
|
|
1409
|
+
return { scope: row.scope, projectPath: row.projectPath, version, exists,
|
|
1410
|
+
behind: exists && Boolean(referenceVersion) && !versionSatisfies(version, referenceVersion),
|
|
1411
|
+
command: exists ? `cd '${row.projectPath.replace(/'/g, `'\\''`)}' && claude plugin update ruvnet-brain@ruvnet-brain --scope ${row.scope}` : null };
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/** The doctor's advisory line for claudeProjectInstalls(): '!' while any existing project lags, never a ✗ (it is the user's project). */
|
|
1416
|
+
export function projectInstallsCheck(projects, referenceVersion) {
|
|
1417
|
+
if (!projects.length) return null;
|
|
1418
|
+
const behind = projects.filter((p) => p.behind);
|
|
1419
|
+
const gone = projects.filter((p) => !p.exists);
|
|
1420
|
+
const named = (p) => `${p.projectPath} (${p.scope}, ${p.version || 'unknown'})`;
|
|
1421
|
+
const detail = behind.length
|
|
1422
|
+
? `${behind.length} per-project Claude Code install(s) behind ${referenceVersion}: ${behind.map(named).join(', ')}`
|
|
1423
|
+
: `${projects.length - gone.length} per-project Claude Code install(s) at ${referenceVersion} or newer`;
|
|
1424
|
+
return { id: 'project-installs', label: 'Projects', state: behind.length ? 'warn' : 'ok',
|
|
1425
|
+
detail: `${detail}${gone.length ? `; ${gone.length} registered for a folder that no longer exists: ${gone.map(named).join(', ')}` : ''}`,
|
|
1426
|
+
fix: behind.length ? behind.map((p) => p.command).join(' ; ') : null };
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1383
1429
|
// A plugin version can change without changing anything a running host freezes at boot. Compare
|
|
1384
1430
|
// the actual installed payload with this candidate before updating it so body-only releases do not
|
|
1385
1431
|
// manufacture a restart requirement. Unknown paths fail closed: if we cannot prove the boundary,
|
|
@@ -1432,7 +1478,7 @@ function codexInstalledPluginRoot({ codexHome = codexHomeDir(), status } = {}) {
|
|
|
1432
1478
|
function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false } = {}) {
|
|
1433
1479
|
step(
|
|
1434
1480
|
'Wiring the Claude Code plugin',
|
|
1435
|
-
|
|
1481
|
+
`this registers search_ruvnet, native skills, slash commands, and the allowlisted lifecycle hooks (${shippedHookHandlerCounts().claude ?? 'unknown number of'} handlers)`,
|
|
1436
1482
|
);
|
|
1437
1483
|
const marketplaceSource = process.env.RUVNET_CLAUDE_MARKETPLACE_SOURCE || 'stuinfla/ruvnet-brain';
|
|
1438
1484
|
const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`;
|
|
@@ -1484,6 +1530,11 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false
|
|
|
1484
1530
|
info(' body-only update: the Stable Spine is live on the next hook/MCP call; no restart is required.');
|
|
1485
1531
|
}
|
|
1486
1532
|
info(` commands available${shellBoundary.restartRequired ? ' in new sessions' : ' immediately'}: ${c.bold('/rnbc')} (also /rnb, /rvbc), ${c.bold('/ruvnet-brain:configure')}`);
|
|
1533
|
+
// #420 item 4: the user row moved; per-project rows did not. Name each lagging one with its own command (never run here).
|
|
1534
|
+
const projectsBehind = claudeProjectInstalls({ referenceVersion: installed.version }).filter((p) => p.behind);
|
|
1535
|
+
for (const p of projectsBehind) {
|
|
1536
|
+
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)}`);
|
|
1537
|
+
}
|
|
1487
1538
|
return {
|
|
1488
1539
|
host: true, wired: true, version: installed.version, manualMarketplace, manualInstall,
|
|
1489
1540
|
shellChanged: shellBoundary.changed, shellChangedPaths: shellBoundary.paths,
|
|
@@ -1973,9 +2024,20 @@ const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain';
|
|
|
1973
2024
|
* [{ key, status: 'modified'|'untrusted' }]. No installed root = a fresh install = every hook untrusted.
|
|
1974
2025
|
* An unreadable file is treated as absent (fail toward telling the user to review, never toward silence).
|
|
1975
2026
|
*/
|
|
1976
|
-
export function codexHooksNeedingReview(installedRoot, candidateRoot) {
|
|
2027
|
+
export function codexHooksNeedingReview(installedRoot, candidateRoot, { contentTrust = false } = {}) {
|
|
1977
2028
|
const read = (root) => { try { return root ? JSON.parse(fs.readFileSync(path.join(root, 'hooks', 'codex-hooks.json'), 'utf8')) : {}; } catch { return {}; } };
|
|
1978
|
-
|
|
2029
|
+
// Codex 0.164+ (or a version we could not read) also holds back every hook whose plugin bytes changed (#492).
|
|
2030
|
+
const contentChanged = contentTrust !== false && codexPluginContentChanged(installedRoot, candidateRoot);
|
|
2031
|
+
return codexTrustChanges(read(installedRoot), read(candidateRoot), undefined, { contentChanged });
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** codex --version → whether it approves plugin hooks by content too (true/false), or null when unknown. */
|
|
2035
|
+
export function codexHostContentTrust({ codexBin = process.env.CODEX_BIN || 'codex', codexHome = codexHomeDir(), run = runHostCli } = {}) {
|
|
2036
|
+
try {
|
|
2037
|
+
const r = run(codexBin, ['--version'], { stdio: 'pipe', shell: false, encoding: 'utf8', timeout: 10_000,
|
|
2038
|
+
env: { ...process.env, CODEX_HOME: codexHome } });
|
|
2039
|
+
return r?.status === 0 ? codexUsesContentTrust(r.stdout) : null;
|
|
2040
|
+
} catch { return null; }
|
|
1979
2041
|
}
|
|
1980
2042
|
|
|
1981
2043
|
function codexMarketplaceTarget() {
|
|
@@ -2064,7 +2126,7 @@ export function runCodexJson(args, {
|
|
|
2064
2126
|
}
|
|
2065
2127
|
if (repaired.length) r = run(codexBin, args, options);
|
|
2066
2128
|
}
|
|
2067
|
-
if (r.missingBinary) return { ok: false, error: r.message };
|
|
2129
|
+
if (r.missingBinary) return { ok: false, error: r.message, missingBinary: true };
|
|
2068
2130
|
if (r.error || r.status !== 0) {
|
|
2069
2131
|
let detail = String(r.stderr || r.stdout || r.error?.message || `exit ${r.status}`).trim();
|
|
2070
2132
|
if (snapshotFailure(r)) {
|
|
@@ -2082,7 +2144,8 @@ export function runCodexJson(args, {
|
|
|
2082
2144
|
export function codexPluginStatus(options = {}) {
|
|
2083
2145
|
const { runJson = runCodexJson, ...commandOptions } = options;
|
|
2084
2146
|
const listed = runJson(['plugin', 'list', '--json'], commandOptions);
|
|
2085
|
-
if (!listed.ok) return { available: false, installed: false, enabled: false, error: listed.error
|
|
2147
|
+
if (!listed.ok) return { available: false, installed: false, enabled: false, error: listed.error,
|
|
2148
|
+
...(listed.missingBinary ? { missingBinary: true } : {}) };
|
|
2086
2149
|
const rows = Array.isArray(listed.value?.installed) ? listed.value.installed : [];
|
|
2087
2150
|
const row = rows.find((candidate) => candidate?.pluginId === CODEX_PLUGIN_ID);
|
|
2088
2151
|
return {
|
|
@@ -2131,6 +2194,8 @@ export function wireCodexPlugin({
|
|
|
2131
2194
|
announce = true,
|
|
2132
2195
|
runJson = runCodexJson,
|
|
2133
2196
|
aliasHome,
|
|
2197
|
+
// true/false/null (unknown): does this Codex pin plugin contents in hook approval (0.164+)? Probed for a real host.
|
|
2198
|
+
contentTrust,
|
|
2134
2199
|
} = {}) {
|
|
2135
2200
|
if (!fs.existsSync(codexDir)) return { host: false, action: 'no-host' };
|
|
2136
2201
|
const options = { codexBin, codexHome, cwd, repair: true };
|
|
@@ -2169,11 +2234,6 @@ export function wireCodexPlugin({
|
|
|
2169
2234
|
path.join(REPO_ROOT, 'plugin'),
|
|
2170
2235
|
)
|
|
2171
2236
|
: { 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
2237
|
if (before.installed && before.enabled && versionSatisfies(before.version, expectedVersion)) {
|
|
2178
2238
|
const hookTrust = runJson === runCodexJson ? repairReleasedCodexHookTrust(before, options) : null;
|
|
2179
2239
|
if (announce) {
|
|
@@ -2187,6 +2247,13 @@ export function wireCodexPlugin({
|
|
|
2187
2247
|
};
|
|
2188
2248
|
}
|
|
2189
2249
|
|
|
2250
|
+
// Which Brain hooks will Codex hold back after this update until the user re-reviews them? Codex keys
|
|
2251
|
+
// trust to a hash of each hook's definition (scripts/codex-hook-trust.mjs), and codex-hooks.json is not
|
|
2252
|
+
// a boot-surface path, so the shell boundary above cannot see it. Computed offline, before the install
|
|
2253
|
+
// replaces the installed folder; on Codex 0.164+ a change to any plugin byte holds back every hook too (#492).
|
|
2254
|
+
const hooksNeedingReview = codexHooksNeedingReview(before.installed
|
|
2255
|
+
? codexInstalledPluginRoot({ codexHome, status: before }) : null, path.join(REPO_ROOT, 'plugin'),
|
|
2256
|
+
{ contentTrust: contentTrust ?? (runJson === runCodexJson ? codexHostContentTrust({ codexBin, codexHome }) : false) });
|
|
2190
2257
|
if (runJson === runCodexJson && !localMarketplace) {
|
|
2191
2258
|
try { localMarketplace = prepareCodexMarketplace(); }
|
|
2192
2259
|
catch (error) {
|
|
@@ -2236,7 +2303,9 @@ export function wireCodexPlugin({
|
|
|
2236
2303
|
}
|
|
2237
2304
|
if (hooksNeedingReview.length && !trustVerified) {
|
|
2238
2305
|
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(', ')}).`
|
|
2306
|
+
+ ` ${hooksNeedingReview.length === 1 ? 'it' : 'them'} (${hooksNeedingReview.map((h) => `${h.key.split(':').slice(-3).join(':')} ${h.status}`).join(', ')}).`
|
|
2307
|
+
+ (hooksNeedingReview.some((h) => h.reason === 'plugin-content')
|
|
2308
|
+
? ' This update changed the plugin\'s files, and Codex 0.164+ approves those bytes as well as each hook\'s definition.' : ''));
|
|
2240
2309
|
info(` ${CODEX_TRUST_ACTION}`);
|
|
2241
2310
|
}
|
|
2242
2311
|
}
|
|
@@ -2260,6 +2329,38 @@ export function wireCodexPlugin({
|
|
|
2260
2329
|
};
|
|
2261
2330
|
}
|
|
2262
2331
|
|
|
2332
|
+
/**
|
|
2333
|
+
* After the Brain changed Codex's view of its plugin (new bytes, or hook trust written from another process), tell an
|
|
2334
|
+
* already-running shared Codex app-server to reload, so its open sessions stop reporting the previous hooks (#492).
|
|
2335
|
+
* Never starts, stops or restarts a server, and never grants an approval (scripts/codex-hook-trust-reconcile.mjs).
|
|
2336
|
+
*/
|
|
2337
|
+
export const codexHostViewChanged = (codexPlugin) => ['installed', 'updated'].includes(codexPlugin?.action)
|
|
2338
|
+
|| codexPlugin?.hookTrust?.changed === true || codexPlugin?.hookTrust?.changed === 'unknown';
|
|
2339
|
+
export async function refreshCodexSharedHost(codexPlugin, { codexHome = codexHomeDir(), refresh = refreshSharedCodexAppServer } = {}) {
|
|
2340
|
+
if (!codexHostViewChanged(codexPlugin)) return { state: 'not-needed', changed: false };
|
|
2341
|
+
return refresh({ codexHome });
|
|
2342
|
+
}
|
|
2343
|
+
|
|
2344
|
+
/**
|
|
2345
|
+
* What the owner is told after refreshCodexSharedHost. The reload reaches ALREADY-OPEN Codex sessions at once, and
|
|
2346
|
+
* changed Brain hook definitions are pending until reviewed, so those sessions stop running Brain hooks until the
|
|
2347
|
+
* owner re-reviews them in /hooks. Said plainly, so a pause in a session he had open is never a surprise (#492).
|
|
2348
|
+
*/
|
|
2349
|
+
export function codexHostRefreshMessage(hostRefresh, codexPlugin = null) {
|
|
2350
|
+
if (hostRefresh?.state === 'refreshed') {
|
|
2351
|
+
// Review r2 (4.7.21): the authorized trust reconciliation may already have re-trusted the released hooks
|
|
2352
|
+
// (registry-verified, or nothing pending); then nothing pauses and saying so would be false.
|
|
2353
|
+
const retrusted = ['registry-verified', 'unchanged'].includes(codexPlugin?.hookTrust?.state);
|
|
2354
|
+
return { level: 'info', text: ' Running Codex app-server reloaded: Codex sessions that are already open pick up the change immediately; '
|
|
2355
|
+
+ (retrusted ? 'Brain hooks were already re-trusted, so they keep running there (nothing restarted).'
|
|
2356
|
+
: 'Brain hooks pause in them until you re-review them in /hooks (no approval granted, nothing restarted).') };
|
|
2357
|
+
}
|
|
2358
|
+
if (hostRefresh?.state === 'failed') {
|
|
2359
|
+
return { level: 'warn', text: `Running Codex app-server not reloaded (${hostRefresh.reason}); its open sessions may report the previous Brain hooks until Codex restarts.` };
|
|
2360
|
+
}
|
|
2361
|
+
return null;
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2263
2364
|
export function codexSessionSafety(status) {
|
|
2264
2365
|
if (status?.restartRequired === true) return status;
|
|
2265
2366
|
return {
|
|
@@ -2547,6 +2648,61 @@ export function retireManagedHookRegistrations({ home = os.homedir(), codexDir =
|
|
|
2547
2648
|
return { removed, changedFiles, conflicts };
|
|
2548
2649
|
}
|
|
2549
2650
|
|
|
2651
|
+
/** Grok's state for --doctor and the install summary (#301): reported, never wired automatically. */
|
|
2652
|
+
function currentGrokStatus() {
|
|
2653
|
+
return grokStatus({ pluginInstalled: Boolean(pluginCommandsDir()), hookCount: shippedHookHandlerCounts().grok });
|
|
2654
|
+
}
|
|
2655
|
+
function grokCheckLines(check) {
|
|
2656
|
+
const status = currentGrokStatus();
|
|
2657
|
+
const line = grokStatusLine(status);
|
|
2658
|
+
if (!line) return [];
|
|
2659
|
+
// Advisory only when the Brain cannot reach Grok at all; the AGENTS.md line is the user's choice.
|
|
2660
|
+
return [check('grok', 'Grok', !status.pluginInstalled, line, 'npx ruvnet-brain', { advisory: true })];
|
|
2661
|
+
}
|
|
2662
|
+
|
|
2663
|
+
/** Opt-in, never automatic: offer the search-first line when Grok is here and the line is not. */
|
|
2664
|
+
async function offerGrokAgentsMd() {
|
|
2665
|
+
const status = currentGrokStatus();
|
|
2666
|
+
if (!status.present || status.groundingLine || !status.pluginInstalled) return;
|
|
2667
|
+
step('Optional — a search-first line for Grok', 'Grok drops prompt-time context, so this line is what makes it ground itself');
|
|
2668
|
+
info(`Adds ${c.bold('one line')} (between ruvnet-brain markers) to the bottom of ${c.bold(status.agentsFile)}:`);
|
|
2669
|
+
info(c.dim(` ${GROK_AGENTS_LINE}`));
|
|
2670
|
+
info('Backed up first; --uninstall removes it.');
|
|
2671
|
+
const yes = FLAG_GROK_AGENTS_MD || (await ask('Add it?', false, { blanketYes: false }));
|
|
2672
|
+
if (!yes) { info(`Skipped — add it any time with ${c.bold('npx ruvnet-brain --grok-agents-md')}`); return; }
|
|
2673
|
+
reportGrokAgentsLine(addGrokAgentsLine());
|
|
2674
|
+
}
|
|
2675
|
+
function reportGrokAgentsLine(result) {
|
|
2676
|
+
if (result === 'added') ok(`added the search-first line to ${grokPaths().agents}`);
|
|
2677
|
+
else if (result === 'present') ok('~/.grok/AGENTS.md already has a search_ruvnet line — left unchanged');
|
|
2678
|
+
else if (result === 'no-grok') info('Grok CLI not detected (~/.grok/bin/grok) — nothing changed');
|
|
2679
|
+
else warn('could not update ~/.grok/AGENTS.md — your file is untouched');
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
/**
|
|
2683
|
+
* How many lifecycle handlers each SHIPPED host manifest registers, counted from the manifests
|
|
2684
|
+
* themselves so help and progress text can never again claim "zero" while they carry handlers
|
|
2685
|
+
* (#400). A missing or unreadable manifest is null ("unknown"), never 0.
|
|
2686
|
+
*/
|
|
2687
|
+
export function shippedHookHandlerCounts(root = REPO_ROOT) {
|
|
2688
|
+
const count = (relative) => {
|
|
2689
|
+
try {
|
|
2690
|
+
const doc = JSON.parse(fs.readFileSync(path.join(root, relative), 'utf8'));
|
|
2691
|
+
return Object.values(doc?.hooks || {}).flat().reduce((n, group) => n + (Array.isArray(group?.hooks) ? group.hooks.length : 0), 0);
|
|
2692
|
+
} catch { return null; }
|
|
2693
|
+
};
|
|
2694
|
+
return {
|
|
2695
|
+
claude: count('plugin/hooks/hooks.json'),
|
|
2696
|
+
codex: count('plugin/hooks/codex-hooks.json'),
|
|
2697
|
+
grok: count('plugin/hooks/grok-hooks.json'),
|
|
2698
|
+
};
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
function hookCountPhrase({ claude, codex } = shippedHookHandlerCounts()) {
|
|
2702
|
+
const n = (v) => (Number.isInteger(v) ? String(v) : 'an unknown number of');
|
|
2703
|
+
return `${n(claude)} Claude Code, ${n(codex)} Codex handlers`;
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2550
2706
|
export function automaticHookRetirementStatus(root = REPO_ROOT, { scope = 'source' } = {}) {
|
|
2551
2707
|
if (!['source', 'installed'].includes(scope)) throw new Error('invalid hook retirement scope');
|
|
2552
2708
|
const files = [
|
|
@@ -3189,8 +3345,15 @@ async function doctorRun({ json }) {
|
|
|
3189
3345
|
const binary = codexAdministrativeProbeBinary();
|
|
3190
3346
|
const fresh = await probeFreshCodexDeclarations({ binary, codexHome: codexHomeDir(), cwd: process.cwd(),
|
|
3191
3347
|
releasedPluginRoot: path.join(REPO_ROOT, 'plugin'), expectedVersion: PACKAGE_VERSION });
|
|
3192
|
-
|
|
3193
|
-
|
|
3348
|
+
// Issue #391: a receipt that names the hook shim, MCP server or skills/ as changed is proven path by path
|
|
3349
|
+
// (scripts/codex-boot-surface-proof.mjs). Doctor only READS: the receipt on disk is never rewritten here.
|
|
3350
|
+
const changedPaths = parseChangedPaths(codex.sessionSafetyReason);
|
|
3351
|
+
const needsBootProof = changedPaths.some((file) => !['hooks/codex-hooks.json', 'hooks/hooks.json'].includes(file));
|
|
3352
|
+
const bootSurface = needsBootProof ? await proveCodexBootSurfaceHere(changedPaths, fresh) : null;
|
|
3353
|
+
hostConvergence = reconcileFreshCodexDeclarations(recorded, fresh, PACKAGE_VERSION, bootSurface);
|
|
3354
|
+
if (bootSurface?.ok) info(`Codex boot-surface-ready: ${bootSurface.proven.map((p) => p.path).join(', ')} proven for NEW sessions (real shim run, real MCP initialize + tools/list). Existing open windows remain unproven; a host relaunch was not observed.`);
|
|
3355
|
+
else if (bootSurface) warn(`Codex boot surface remains unproven: ${bootSurface.reason}`);
|
|
3356
|
+
else if (fresh.ok) info('Codex fresh-declarations-ready: current trusted hook declarations only; hook bodies and MCP execution were not tested. Existing open windows remain unproven.');
|
|
3194
3357
|
else warn(`Codex fresh declarations remain unproven: ${fresh.reason}`);
|
|
3195
3358
|
}
|
|
3196
3359
|
if (hostConvergence.healthy) {
|
|
@@ -3397,8 +3560,9 @@ async function doctorRun({ json }) {
|
|
|
3397
3560
|
const turnLine = { id: 'turn-recording', label: 'Turn recording', state: turnStatus.state, detail: turnStatus.line, fix: null };
|
|
3398
3561
|
|
|
3399
3562
|
// ── THE MECHANICAL VERDICT ────────────────────────────────────────────────────────────────────
|
|
3400
|
-
// `--hooks` is
|
|
3401
|
-
// never
|
|
3563
|
+
// `--hooks` is a read-only proof that every registration in the shipped manifests is on the
|
|
3564
|
+
// continuity allowlist (#400: it is not, and never was, a zero-registration proof). It must never
|
|
3565
|
+
// execute hook bodies.
|
|
3402
3566
|
let hookResult = null;
|
|
3403
3567
|
if (FLAG_HOOKS) {
|
|
3404
3568
|
const retirement = automaticHookRetirementStatus(REPO_ROOT, { scope: 'installed' });
|
|
@@ -3460,6 +3624,9 @@ async function doctorRun({ json }) {
|
|
|
3460
3624
|
const nightlyFailed = nightlyHealth.state === 'degraded'
|
|
3461
3625
|
|| (nightlyHealth.state === 'on' && !['ok', 'running'].includes(nightlyHealth.runHealth?.state));
|
|
3462
3626
|
const codexFailed = codexWiringFailed || codexReadinessFailed || (codexLifecycleFailed && !codexTrustBypassed);
|
|
3627
|
+
// #420 item 4: per-project Claude installs lag silently behind the user one; advisory, with each project's own command.
|
|
3628
|
+
const projectReference = claudePluginStatus().version || PACKAGE_VERSION;
|
|
3629
|
+
const projectLine = projectInstallsCheck(claudeProjectInstalls({ referenceVersion: projectReference }), projectReference);
|
|
3463
3630
|
const checks = [
|
|
3464
3631
|
// With --hooks the install reading was never part of the verdict (release install verification); keep that.
|
|
3465
3632
|
check('install', 'Install', !allGreen, `${v.repos} store(s), reader ${v.reader ? 'present' : 'MISSING'}, search server ${v.mcp ? 'present' : 'MISSING'}`,
|
|
@@ -3471,13 +3638,15 @@ async function doctorRun({ json }) {
|
|
|
3471
3638
|
...(cx.host ? [check('codex', 'Codex', codexFailed, codexFailed ? (codexWiringFailed ? 'host detected but NOT wired'
|
|
3472
3639
|
: codexReadinessFailed ? 'MCP readiness blocked' : 'lifecycle hooks unhealthy') : 'wired', 'npx ruvnet-brain')] : []),
|
|
3473
3640
|
check('nightly', 'Nightly', nightlyFailed, `${nightlyHealth.state}${nightlyHealth.runHealth?.state ? `, last run ${nightlyHealth.runHealth.state}` : ''}`,
|
|
3474
|
-
nightlyHealth
|
|
3641
|
+
nightlyFix(nightlyHealth)),
|
|
3475
3642
|
// Scheduler ON but running yesterday's code: an advisory naming the exact fix, never a failing verdict.
|
|
3476
3643
|
...(nightlyHealth.state === 'on' && nightlyCode.state !== 'unregistered' ? [check('nightly-code', 'Nightly code', nightlyCode.state !== 'current',
|
|
3477
3644
|
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
3645
|
nightlyCode.fix || 'npx ruvnet-brain@latest --update', { advisory: true })] : []),
|
|
3479
3646
|
check('host-convergence', 'Hosts sync', !hostConvergence.healthy, hostConvergence.state, 'npx ruvnet-brain --update'),
|
|
3647
|
+
...grokCheckLines(check),
|
|
3480
3648
|
...(rufloOperational ? [rufloCheckLine(rufloOperational)] : []),
|
|
3649
|
+
...(projectLine ? [projectLine] : []),
|
|
3481
3650
|
...(agentdbLine ? [agentdbLine] : []),
|
|
3482
3651
|
turnLine,
|
|
3483
3652
|
];
|
|
@@ -3754,6 +3923,17 @@ const TEST_MODE = process.env.RUVNET_BRAIN_TEST === '1';
|
|
|
3754
3923
|
// failure mode for a stranger's first contact. With the variable unset, behavior is unchanged.
|
|
3755
3924
|
const IMPORT_ONLY = process.env.RUVNET_BRAIN_IMPORT_ONLY === '1';
|
|
3756
3925
|
|
|
3926
|
+
/**
|
|
3927
|
+
* The doctor's fix for the Nightly line. Re-enabling repairs a job that cannot fire (not loaded, command drift);
|
|
3928
|
+
* a job that is loaded, verified, FIRED and exited non-zero (`lastExitCode`) is a failed update run, and
|
|
3929
|
+
* re-enabling it changes nothing — the update itself is the fix (4.7.21).
|
|
3930
|
+
*/
|
|
3931
|
+
export function nightlyFix(nightlyHealth) {
|
|
3932
|
+
const fired = Number.isInteger(nightlyHealth?.lastExitCode) && nightlyHealth.lastExitCode !== 0;
|
|
3933
|
+
if (fired) return 'npx ruvnet-brain@latest --update';
|
|
3934
|
+
return nightlyHealth?.state === 'degraded' ? 'npx ruvnet-brain --enable-nightly' : 'npx ruvnet-brain --update';
|
|
3935
|
+
}
|
|
3936
|
+
|
|
3757
3937
|
export function classifyUpdaterExit(status, { fallbackAllowed = true, result = null, requireResult = false } = {}) {
|
|
3758
3938
|
if (status === 12 && result?.terminalVerdict === 'cleanup-pending') {
|
|
3759
3939
|
return { verdict: 'cleanup-pending', fallback: false, exitCode: 12 };
|
|
@@ -3783,8 +3963,26 @@ export function classifyUpdaterExit(status, { fallbackAllowed = true, result = n
|
|
|
3783
3963
|
// Exit 2 is "manifest unreachable, nothing touched". The fallback exists for a DEAD manifest URL (an old
|
|
3784
3964
|
// bundle polling a path that 404s); a rate limit, a 5xx or no network is transient, and a full fresh
|
|
3785
3965
|
// reinstall over it re-downloads the brain and preserves another full copy each time. Retry later instead.
|
|
3786
|
-
|
|
3787
|
-
|
|
3966
|
+
// Exit 3 is the same transport failure one request later: the bundle downloaded, then its 64-byte .sig hit a
|
|
3967
|
+
// 5xx or a timeout. Nothing was touched, and a fallback would re-download the whole ~745 MB bundle. A 404 on
|
|
3968
|
+
// the .sig (a release published without one) is not transient and keeps the fallback.
|
|
3969
|
+
if ((status === 2 || status === 3) && /returned HTTP (?:403|408|429|5\d\d)\b|network failure/.test(String(result?.reason || ''))) {
|
|
3970
|
+
return { verdict: 'transient-network', fallback: false, exitCode: status };
|
|
3971
|
+
}
|
|
3972
|
+
// Neither of these is a broken updater, so a fresh reinstall cannot fix them; it only re-downloads ~745 MB
|
|
3973
|
+
// into the same wall, every night (4.7.21, audit C: a `.prune-*` left by a killed run made retention
|
|
3974
|
+
// `unsafe`, which forge-update reported as recovery-required, and the fallback reinstall hit it again).
|
|
3975
|
+
// - recovery-required: the updater stopped on evidence it must not discard; it names the recovery.
|
|
3976
|
+
// - exit 5: the offered corpus is incompatible with (or was already refused by) the runtime this run has
|
|
3977
|
+
// just re-stamped (ensureUpdaterPrerequisites), and a reinstall of the same package stamps the same one.
|
|
3978
|
+
if (result?.terminalVerdict === 'recovery-required') {
|
|
3979
|
+
return { verdict: 'recovery-required', fallback: false, exitCode: status || 1 };
|
|
3980
|
+
}
|
|
3981
|
+
if (status === 5) return { verdict: 'refused-incompatible', fallback: false, exitCode: 5 };
|
|
3982
|
+
// The updater was stopped by a signal (its own TTL, a logout, Ctrl-C) and settled itself as `interrupted`.
|
|
3983
|
+
// Nothing is broken, so a fresh reinstall would only re-download ~745 MB into the same interruption.
|
|
3984
|
+
if (result?.terminalVerdict === 'interrupted' || status === 143 || status === 130) {
|
|
3985
|
+
return { verdict: 'interrupted', fallback: false, exitCode: status || 143 };
|
|
3788
3986
|
}
|
|
3789
3987
|
return { verdict: 'failed', fallback: fallbackAllowed, exitCode: status || 1 };
|
|
3790
3988
|
}
|
|
@@ -3919,7 +4117,12 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3919
4117
|
return fail({ error: `Codex host convergence failed: ${results.codexHost.action || 'unknown action'}` });
|
|
3920
4118
|
}
|
|
3921
4119
|
results.codex = installCodexPlugin({ expectedVersion: PACKAGE_VERSION });
|
|
3922
|
-
|
|
4120
|
+
// ~/.codex exists but no `codex` binary is on PATH (an uninstalled CLI, or a desktop-only setup whose shell
|
|
4121
|
+
// cannot see it): there is no Codex host to converge here, which is absence, not a failed update (4.7.21,
|
|
4122
|
+
// audit C-opus #6). Any other unavailability (the binary ran and failed) still fails.
|
|
4123
|
+
if (results.codex.action === 'codex-unavailable' && results.codex.missingBinary === true) {
|
|
4124
|
+
results.codexAbsent = true;
|
|
4125
|
+
} else if (!['unchanged', 'installed', 'updated', 'disabled'].includes(results.codex.action)) {
|
|
3923
4126
|
return fail();
|
|
3924
4127
|
}
|
|
3925
4128
|
}
|
|
@@ -3933,7 +4136,7 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
3933
4136
|
const applied = runStableSpine(apply);
|
|
3934
4137
|
const okApplied = !applied.error && applied.status === 0;
|
|
3935
4138
|
const codexReceipt = {
|
|
3936
|
-
state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host ? 'ready' : 'absent'),
|
|
4139
|
+
state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host && !results.codexAbsent ? 'ready' : 'absent'),
|
|
3937
4140
|
version: results.codex?.version || null,
|
|
3938
4141
|
};
|
|
3939
4142
|
if (results.codex?.restartRequired === true) {
|
|
@@ -4019,20 +4222,123 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), {
|
|
|
4019
4222
|
}
|
|
4020
4223
|
}
|
|
4021
4224
|
}
|
|
4022
|
-
|
|
4023
|
-
|
|
4225
|
+
// update-apply exits 75 when another live run holds the spine lock: nothing was applied, so it is not success.
|
|
4226
|
+
if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || (applied.status === 75
|
|
4227
|
+
? 'Stable Spine is busy (another update holds its lock); nothing was applied — re-run when it finishes'
|
|
4228
|
+
: 'Stable Spine activation failed') });
|
|
4229
|
+
const convergedReceipt = {
|
|
4024
4230
|
desiredVersion: PACKAGE_VERSION,
|
|
4025
4231
|
hosts: {
|
|
4026
4232
|
claude: claudeReceipt,
|
|
4027
4233
|
codex: codexReceipt,
|
|
4028
4234
|
},
|
|
4029
4235
|
consoleRuntime: results.consoleRuntime,
|
|
4030
|
-
}
|
|
4236
|
+
};
|
|
4237
|
+
const convergence = classifyHostConvergence(convergedReceipt);
|
|
4031
4238
|
hostLock.release();
|
|
4032
4239
|
return { ok: convergence.healthy === true, convergence, results, applyStatus: applied.status,
|
|
4240
|
+
...(convergence.healthy !== true && hostRestartIsOnlyGap(convergedReceipt) ? { restartOnly: true } : {}),
|
|
4033
4241
|
error: convergence.healthy === true ? null : `host convergence is ${convergence.state}: ${convergence.action}` };
|
|
4034
4242
|
}
|
|
4035
4243
|
|
|
4244
|
+
/**
|
|
4245
|
+
* The ONE host-convergence entry for `--update` and `--host-sync-only`: syncHostsAfterUpdate, then the #492 reload of a
|
|
4246
|
+
* running shared Codex app-server when the Codex plugin or its hook trust changed (fresh installs reload it in the
|
|
4247
|
+
* main flow; before this, every existing user's `--update` skipped it — cross-vendor review E). The reload never
|
|
4248
|
+
* grants an approval and never starts, stops or restarts a server (refreshSharedCodexAppServer).
|
|
4249
|
+
*/
|
|
4250
|
+
export async function convergeHostsAfterUpdate(cacheDir = resolvedKbDir(), { refreshHost = refreshCodexSharedHost,
|
|
4251
|
+
unattended = process.env.RUVNET_NIGHTLY === '1', recordNotice = recordCodexOvernightNotice,
|
|
4252
|
+
noticeHome = () => footprintRoots().brainHome, proveBootSurface = proveCodexBootSurfaceDefault, ...options } = {}) {
|
|
4253
|
+
const brainHome = options.brainHome || process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain');
|
|
4254
|
+
const receiptPath = path.join(brainHome, 'host-convergence.json');
|
|
4255
|
+
const readReceipt = () => { try { return JSON.parse(fs.readFileSync(receiptPath, 'utf8')); } catch { return null; } };
|
|
4256
|
+
const prior = readReceipt();
|
|
4257
|
+
let convergence = syncHostsAfterUpdate(cacheDir, options);
|
|
4258
|
+
if (!convergence.results?.codex) return convergence;
|
|
4259
|
+
convergence = await recordCodexBootSurface(convergence, { prior, receiptPath, readReceipt, proveBootSurface,
|
|
4260
|
+
hostLockPath: options.hostLockPath || path.join(brainHome, 'host-convergence.lock') });
|
|
4261
|
+
// Owner decision (4.7.21 review r2): the unattended nightly never reloads the shared Codex app-server; a reload
|
|
4262
|
+
// pauses Brain hooks in sessions already open until someone re-reviews them in /hooks. It leaves a notice the next
|
|
4263
|
+
// SessionStart shows once instead. An interactive --update still reloads.
|
|
4264
|
+
if (unattended) {
|
|
4265
|
+
if (!codexHostViewChanged(convergence.results.codex)) return { ...convergence, hostRefresh: { state: 'not-needed', changed: false } };
|
|
4266
|
+
const recorded = recordNotice(noticeHome());
|
|
4267
|
+
return { ...convergence, hostRefresh: { state: 'deferred-unattended', changed: false, noticeRecorded: recorded } };
|
|
4268
|
+
}
|
|
4269
|
+
let hostRefresh;
|
|
4270
|
+
try { hostRefresh = await refreshHost(convergence.results.codex); }
|
|
4271
|
+
catch (error) { hostRefresh = { state: 'failed', changed: false, reason: error?.message || String(error) }; }
|
|
4272
|
+
const note = codexHostRefreshMessage(hostRefresh, convergence.results.codex);
|
|
4273
|
+
if (note) (note.level === 'warn' ? warn : info)(note.text);
|
|
4274
|
+
return { ...convergence, hostRefresh };
|
|
4275
|
+
}
|
|
4276
|
+
|
|
4277
|
+
/**
|
|
4278
|
+
* Issue #391: when Codex still carries a boot-level restart request (a fresh one from this sync, or the previous
|
|
4279
|
+
* receipt's that a repeat sync would otherwise silently drop), prove each changed path and write a NEW receipt:
|
|
4280
|
+
* restartScope 'open-sessions' only when every path is proven, otherwise the request stays 'unproven' with the reason.
|
|
4281
|
+
* A historical receipt is never edited in place; the new one is the atomic replacement the sync itself writes.
|
|
4282
|
+
*/
|
|
4283
|
+
async function recordCodexBootSurface(convergence, { prior, receiptPath, readReceipt, proveBootSurface, hostLockPath }) {
|
|
4284
|
+
if (!convergence.ok && convergence.restartOnly !== true) return convergence;
|
|
4285
|
+
const current = readReceipt();
|
|
4286
|
+
const codex = current?.hosts?.codex;
|
|
4287
|
+
if (!current || current.desiredVersion !== PACKAGE_VERSION || codex?.state !== 'ready') return convergence;
|
|
4288
|
+
const priorCodex = prior?.desiredVersion === PACKAGE_VERSION ? prior.hosts?.codex : null;
|
|
4289
|
+
const fresh = codex.restartRequired === true && codex.restartScope === 'unproven';
|
|
4290
|
+
const carried = codex.restartRequired !== true && priorCodex?.state === 'ready' && priorCodex.restartRequired === true
|
|
4291
|
+
&& priorCodex.restartScope === 'unproven' && convergence.results.codex.action === 'unchanged';
|
|
4292
|
+
if (!fresh && !carried) return convergence;
|
|
4293
|
+
const reason = fresh ? codex.sessionSafetyReason : priorCodex.sessionSafetyReason;
|
|
4294
|
+
const changedPaths = parseChangedPaths(reason);
|
|
4295
|
+
let proof;
|
|
4296
|
+
try { proof = await proveBootSurface({ changedPaths }); }
|
|
4297
|
+
catch (error) { proof = { ok: false, state: 'boot-surface-unproven', changed: changedPaths, proven: [], unproven: [], reason: error?.message || String(error),
|
|
4298
|
+
existingWindows: 'unproven', hostRelaunch: 'not-observed' }; }
|
|
4299
|
+
const ready = proof?.ok === true && proof.state === 'boot-surface-ready';
|
|
4300
|
+
const next = { ...current, verifiedAt: new Date().toISOString(), hosts: { ...current.hosts, codex: { ...codex,
|
|
4301
|
+
restartRequired: true, sessionSafety: 'restart-required', sessionSafetyReason: reason,
|
|
4302
|
+
restartScope: ready ? 'open-sessions' : 'unproven',
|
|
4303
|
+
bootSurface: { state: proof?.state || 'boot-surface-unproven', changed: proof?.changed || changedPaths,
|
|
4304
|
+
proven: (proof?.proven || []).map((p) => p.path), unproven: proof?.unproven || [], reason: proof?.reason || null,
|
|
4305
|
+
existingWindows: 'unproven', hostRelaunch: 'not-observed' } } } };
|
|
4306
|
+
// The receipt is the sync's own file, so this read-modify-write takes the same lock the sync holds (the proof
|
|
4307
|
+
// above can be slow, so the lock is held only for the write). A receipt that changed while we proved, or a lock
|
|
4308
|
+
// we cannot take, means a newer sync owns it: record nothing and leave the prior, still-truthful receipt.
|
|
4309
|
+
const writeLock = acquireHostConvergenceLock(hostLockPath);
|
|
4310
|
+
if (!writeLock.acquired) return { ...convergence, bootSurface: proof, bootSurfaceNotRecorded: `host synchronization lock unavailable: ${writeLock.error}` };
|
|
4311
|
+
try {
|
|
4312
|
+
const latest = readReceipt();
|
|
4313
|
+
if (latest?.verifiedAt !== current.verifiedAt || latest?.desiredVersion !== current.desiredVersion) {
|
|
4314
|
+
return { ...convergence, bootSurface: proof, bootSurfaceNotRecorded: 'host-convergence receipt changed while the boot surface was proven' };
|
|
4315
|
+
}
|
|
4316
|
+
const tmp = `${receiptPath}.tmp-${process.pid}`;
|
|
4317
|
+
fs.writeFileSync(tmp, JSON.stringify(next, null, ' ')); // two-space indent, same bytes as before
|
|
4318
|
+
fs.renameSync(tmp, receiptPath);
|
|
4319
|
+
} finally { writeLock.release(); }
|
|
4320
|
+
const classified = classifyHostConvergence(next);
|
|
4321
|
+
if (classified.healthy) classified.state = 'boot-surface-ready';
|
|
4322
|
+
const { restartOnly, ...rest } = convergence;
|
|
4323
|
+
return { ...rest, ok: classified.healthy === true, convergence: classified, bootSurface: proof,
|
|
4324
|
+
...(!classified.healthy && hostRestartIsOnlyGap(next) ? { restartOnly: true } : {}),
|
|
4325
|
+
error: classified.healthy ? null : `host convergence is ${classified.state}: ${classified.action}` };
|
|
4326
|
+
}
|
|
4327
|
+
|
|
4328
|
+
/**
|
|
4329
|
+
* True when a host restart is the ONLY gap in a convergence receipt: every restart-flagged host is ready at the
|
|
4330
|
+
* expected version, and with those hosts read as "already-open windows only" everything else (other hosts, the
|
|
4331
|
+
* Console) is converged. `--update` records that as an advisory and keeps the knowledge update's success; any
|
|
4332
|
+
* other gap behind the restart keeps the failure (4.7.21, audit C: Codex's restartScope is always 'unproven').
|
|
4333
|
+
*/
|
|
4334
|
+
export function hostRestartIsOnlyGap(receipt, expectedVersion = PACKAGE_VERSION) {
|
|
4335
|
+
const flagged = Object.values(receipt?.hosts || {}).filter((host) => host?.restartRequired === true);
|
|
4336
|
+
if (!flagged.length || flagged.some((host) => host.state !== 'ready' || !versionSatisfies(host.version, expectedVersion))) return false;
|
|
4337
|
+
const hosts = Object.fromEntries(Object.entries(receipt.hosts).map(([name, host]) => [name,
|
|
4338
|
+
host?.restartRequired === true ? { ...host, restartScope: 'open-sessions' } : host]));
|
|
4339
|
+
return classifyHostConvergence({ ...receipt, hosts }, expectedVersion).healthy === true;
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4036
4342
|
export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSION) {
|
|
4037
4343
|
// Same 'ahead is not behind' rule as host convergence above (#123). A receipt written by a
|
|
4038
4344
|
// NEWER generation than the installer currently running is not a version mismatch — it is a
|
|
@@ -4086,7 +4392,7 @@ export function hostSynchronizationFailureMessage(convergence, active, expectedV
|
|
|
4086
4392
|
}
|
|
4087
4393
|
|
|
4088
4394
|
/** Doctor projection only. Fresh hook metadata cannot clear an executable or MCP boot gap. */
|
|
4089
|
-
export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion = PACKAGE_VERSION) {
|
|
4395
|
+
export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion = PACKAGE_VERSION, bootSurface = null) {
|
|
4090
4396
|
const original = classifyHostConvergence(recorded, expectedVersion);
|
|
4091
4397
|
const codex = recorded?.hosts?.codex;
|
|
4092
4398
|
if (!proof?.ok || proof.state !== 'fresh-declarations-ready' || proof.expectedVersion !== expectedVersion
|
|
@@ -4097,15 +4403,44 @@ export function reconcileFreshCodexDeclarations(recorded, proof, expectedVersion
|
|
|
4097
4403
|
const prefix = 'boot-level declarations changed: ';
|
|
4098
4404
|
const reason = codex.sessionSafetyReason;
|
|
4099
4405
|
const changed = typeof reason === 'string' && reason.startsWith(prefix) ? reason.slice(prefix.length).split(', ') : [];
|
|
4100
|
-
|
|
4101
|
-
|
|
4406
|
+
const hookFilesOnly = changed.length > 0 && changed.every((file) => ['hooks/codex-hooks.json', 'hooks/hooks.json'].includes(file));
|
|
4407
|
+
// #391: every changed path proven by the boot-surface proof (same list as the receipt names) clears the unproven
|
|
4408
|
+
// scope. The proof's own existingWindows:'unproven' and hostRelaunch:'not-observed' stay on the result.
|
|
4409
|
+
const bootProven = !hookFilesOnly && bootSurface?.ok === true && bootSurface.state === 'boot-surface-ready'
|
|
4410
|
+
&& bootSurface.changed?.length === changed.length && changed.every((file) => bootSurface.changed.includes(file));
|
|
4411
|
+
if (!hookFilesOnly && !bootProven) {
|
|
4412
|
+
return { ...original, freshDeclarations: proof, ...(bootSurface ? { bootSurface } : {}),
|
|
4413
|
+
notice: bootSurface?.reason ? `Fresh Codex hook declarations are ready; boot surface unproven — ${bootSurface.reason}. Existing open windows remain unproven.`
|
|
4414
|
+
: 'Fresh Codex hook declarations are ready; executable/MCP boot readiness and existing open windows remain unproven.' };
|
|
4102
4415
|
}
|
|
4103
4416
|
const projected = classifyHostConvergence({ ...recorded, hosts: { ...recorded.hosts,
|
|
4104
4417
|
codex: { ...codex, restartScope: 'open-sessions' } } }, expectedVersion);
|
|
4418
|
+
if (bootProven) {
|
|
4419
|
+
return { ...projected, ...(projected.healthy ? { state: 'boot-surface-ready' } : {}), freshDeclarations: proof, bootSurface,
|
|
4420
|
+
notice: 'Fresh Codex boot surface is proven for new sessions (hook shim run, MCP initialize + tools/list); existing open windows remain unproven and a host relaunch was not observed.' };
|
|
4421
|
+
}
|
|
4105
4422
|
return { ...projected, ...(projected.healthy ? { state: 'fresh-declarations-ready' } : {}), freshDeclarations: proof,
|
|
4106
4423
|
notice: 'Fresh Codex hook declarations are ready; hook bodies and MCP execution were not tested. Existing open windows remain unproven.' };
|
|
4107
4424
|
}
|
|
4108
4425
|
|
|
4426
|
+
/** Prove the changed Codex boot paths against the plugin tree Codex loads. Read-only; `declarations` is a fresh-declarations probe. */
|
|
4427
|
+
async function proveCodexBootSurfaceHere(changedPaths, declarations) {
|
|
4428
|
+
const codexHome = codexHomeDir();
|
|
4429
|
+
let installedPluginRoot = declarations?.pluginRoot || null;
|
|
4430
|
+
if (!installedPluginRoot) { try { installedPluginRoot = codexInstalledPluginRoot({ codexHome }); } catch { installedPluginRoot = null; } }
|
|
4431
|
+
return await proveCodexBootSurface({ changedPaths, declarations, installedPluginRoot,
|
|
4432
|
+
releasedPluginRoot: path.join(REPO_ROOT, 'plugin') });
|
|
4433
|
+
}
|
|
4434
|
+
|
|
4435
|
+
async function proveCodexBootSurfaceDefault({ changedPaths }) {
|
|
4436
|
+
let declarations;
|
|
4437
|
+
try {
|
|
4438
|
+
declarations = await probeFreshCodexDeclarations({ binary: codexAdministrativeProbeBinary(), codexHome: codexHomeDir(), cwd: process.cwd(),
|
|
4439
|
+
releasedPluginRoot: path.join(REPO_ROOT, 'plugin'), expectedVersion: PACKAGE_VERSION });
|
|
4440
|
+
} catch (error) { declarations = { ok: false, reason: error?.message || String(error) }; }
|
|
4441
|
+
return await proveCodexBootSurfaceHere(changedPaths, declarations);
|
|
4442
|
+
}
|
|
4443
|
+
|
|
4109
4444
|
const HOST_LABELS = { claude: 'Claude Code', codex: 'Codex' };
|
|
4110
4445
|
/** The one accurate line for converged hosts whose already-open windows booted the old declarations. */
|
|
4111
4446
|
export function openSessionsNotice(hosts, version) {
|
|
@@ -4167,7 +4502,7 @@ async function runUpdate() {
|
|
|
4167
4502
|
}
|
|
4168
4503
|
if (FLAG_HOST_SYNC_ONLY) {
|
|
4169
4504
|
info(c.dim('repairing host shells and managed model catalog without mutating KB bytes…\n'));
|
|
4170
|
-
const convergence =
|
|
4505
|
+
const convergence = await convergeHostsAfterUpdate(kbDir);
|
|
4171
4506
|
if (!convergence.ok) {
|
|
4172
4507
|
warn(`host synchronization is incomplete${convergence.error ? ` (${convergence.error})` : ''}`);
|
|
4173
4508
|
process.exitCode = 1;
|
|
@@ -4236,6 +4571,36 @@ async function runUpdate() {
|
|
|
4236
4571
|
detail: { reason: 'process exited before terminal settlement' } }); } catch { /* retained lock and receipt are recovery evidence */ }
|
|
4237
4572
|
};
|
|
4238
4573
|
process.once('exit', exitGuard);
|
|
4574
|
+
// SIGTERM/SIGINT (4.7.21): the SessionStart self-heal kills this process group at its TTL (detach.mjs), and the
|
|
4575
|
+
// default disposition ends Node without an 'exit' event, so the refresh receipt stayed RUNNING and its lock
|
|
4576
|
+
// stayed held until a later run proved the owner dead. Settle FAILED with the signal, release the lock, exit
|
|
4577
|
+
// 128+n. While a child runs under spawnSync the handler cannot run, so the same settlement is applied as soon
|
|
4578
|
+
// as a child is seen to have died of one of these signals (a group kill reaches both).
|
|
4579
|
+
const signalGuard = (signal) => {
|
|
4580
|
+
if (!refreshSettled) {
|
|
4581
|
+
try {
|
|
4582
|
+
settleRefreshRun({ handle: refreshReceipt, lock: refreshLock, status: 'FAILED',
|
|
4583
|
+
detail: { reason: 'signal', signal, phase: 'interrupted', terminalVerdict: 'failed' } });
|
|
4584
|
+
refreshSettled = true;
|
|
4585
|
+
} catch { /* retained lock and receipt are recovery evidence */ }
|
|
4586
|
+
}
|
|
4587
|
+
console.error(`\n${c.red('✗ update interrupted by')} ${signal} — the refresh run was recorded FAILED; nothing is left locked.`);
|
|
4588
|
+
process.exit(128 + (os.constants.signals[signal] || 15));
|
|
4589
|
+
};
|
|
4590
|
+
process.once('SIGTERM', signalGuard);
|
|
4591
|
+
process.once('SIGINT', signalGuard);
|
|
4592
|
+
// The updater now settles its own SIGTERM/SIGINT (kb/forge-update.mjs exits 143/130 with an `interrupted`
|
|
4593
|
+
// result) instead of dying of the signal, so a group kill shows up here as that exit status too.
|
|
4594
|
+
const settleIfSignalled = (child) => {
|
|
4595
|
+
if (['SIGTERM', 'SIGINT'].includes(child?.signal)) signalGuard(child.signal);
|
|
4596
|
+
else if (child?.status === 143) signalGuard('SIGTERM');
|
|
4597
|
+
else if (child?.status === 130) signalGuard('SIGINT');
|
|
4598
|
+
};
|
|
4599
|
+
const detachGuards = () => {
|
|
4600
|
+
process.removeListener('exit', exitGuard);
|
|
4601
|
+
process.removeListener('SIGTERM', signalGuard);
|
|
4602
|
+
process.removeListener('SIGINT', signalGuard);
|
|
4603
|
+
};
|
|
4239
4604
|
info(`brain dir: ${c.bold(kbDir)}`);
|
|
4240
4605
|
// RECOVER AN INTERRUPTED UPDATE FIRST, before anything below writes into the brain. Two measured
|
|
4241
4606
|
// reasons: (1) a kill between the two directory renames leaves no usable kb/ — the brain sits in a
|
|
@@ -4301,7 +4666,9 @@ async function runUpdate() {
|
|
|
4301
4666
|
let updaterResult = null;
|
|
4302
4667
|
try { if (updaterResultFile && fs.existsSync(updaterResultFile)) updaterResult = JSON.parse(fs.readFileSync(updaterResultFile, 'utf8')); }
|
|
4303
4668
|
catch { updaterResult = null; }
|
|
4669
|
+
// Before settleIfSignalled, which exits: an interrupted run used to leak one ruvnet-update-result-* temp dir.
|
|
4304
4670
|
if (updaterResultDir) fs.rmSync(updaterResultDir, { recursive: true, force: true });
|
|
4671
|
+
settleIfSignalled(r);
|
|
4305
4672
|
// FALLBACK (2026-07-17), scoped 2026-07-18 to exists-but-FAILED only. An OLDER bundle whose
|
|
4306
4673
|
// canonicalManifestUrl points at the dead main/kb/.last-built.json path and 404s (the exact break a
|
|
4307
4674
|
// real user, Jan Lafko, hit). NEVER leave the user stranded at a 404: re-run THIS installer as a
|
|
@@ -4318,7 +4685,7 @@ async function runUpdate() {
|
|
|
4318
4685
|
});
|
|
4319
4686
|
if (outcome.verdict === 'refused-retained-copies') {
|
|
4320
4687
|
warn('nothing was changed: full copies of earlier brain generations already sit beside this one (listed above).');
|
|
4321
|
-
info('
|
|
4688
|
+
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
4689
|
}
|
|
4323
4690
|
if (outcome.fallback && FLAG_HOST_SYNC_ONLY) {
|
|
4324
4691
|
// Host synchronization has a narrower contract than a full update: it must converge the
|
|
@@ -4370,6 +4737,7 @@ async function runUpdate() {
|
|
|
4370
4737
|
fr = spawnSync(process.execPath, [self, '--force'], { stdio: 'inherit',
|
|
4371
4738
|
env: { ...refreshEnv, RUVNET_BRAIN_NO_UPDATE_FALLBACK: '1' } });
|
|
4372
4739
|
}
|
|
4740
|
+
settleIfSignalled(fr);
|
|
4373
4741
|
updateStatus = fr.error ? 1 : (fr.status === null ? 1 : fr.status);
|
|
4374
4742
|
} else {
|
|
4375
4743
|
updateStatus = outcome.exitCode;
|
|
@@ -4380,8 +4748,9 @@ async function runUpdate() {
|
|
|
4380
4748
|
updaterExit, finalExit: updateStatus, fallback: outcome.fallback, verdict: outcome.verdict,
|
|
4381
4749
|
updateResult: updaterResult,
|
|
4382
4750
|
});
|
|
4383
|
-
settleRefresh(updateStatus, { phase: 'source-enumeration',
|
|
4384
|
-
|
|
4751
|
+
settleRefresh(updateStatus, { phase: 'source-enumeration',
|
|
4752
|
+
terminalVerdict: outcome.verdict === 'recovery-required' ? 'recovery-required' : 'failed' });
|
|
4753
|
+
detachGuards();
|
|
4385
4754
|
return;
|
|
4386
4755
|
}
|
|
4387
4756
|
if (cleanupPending) updateStatus = 0;
|
|
@@ -4419,7 +4788,7 @@ async function runUpdate() {
|
|
|
4419
4788
|
recordRefreshPhase(refreshReceipt, 'source-enumeration', 'FAIL', { failures: installed.failures,
|
|
4420
4789
|
reason: 'legacy updater returned success without a valid installed ReleaseCoverage projection' });
|
|
4421
4790
|
settleRefresh(1, { phase: 'source-enumeration', terminalVerdict: 'failed' });
|
|
4422
|
-
|
|
4791
|
+
detachGuards();
|
|
4423
4792
|
return;
|
|
4424
4793
|
}
|
|
4425
4794
|
phaseEvidence = Object.fromEntries(UPDATE_REFRESH_PHASES.map((phase) => [phase, {
|
|
@@ -4433,26 +4802,76 @@ async function runUpdate() {
|
|
|
4433
4802
|
if (!phaseEvidence[phase]) {
|
|
4434
4803
|
recordRefreshPhase(refreshReceipt, phase, 'FAIL', { reason: `updater omitted ${phase} evidence` });
|
|
4435
4804
|
settleRefresh(1, { phase, terminalVerdict: 'failed' });
|
|
4436
|
-
|
|
4805
|
+
detachGuards();
|
|
4437
4806
|
return;
|
|
4438
4807
|
}
|
|
4439
4808
|
recordRefreshPhase(refreshReceipt, phase, 'PASS', phaseEvidence[phase]);
|
|
4440
4809
|
}
|
|
4441
|
-
|
|
4442
|
-
|
|
4443
|
-
|
|
4444
|
-
|
|
4445
|
-
|
|
4446
|
-
|
|
4447
|
-
|
|
4448
|
-
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
4453
|
-
|
|
4454
|
-
|
|
4810
|
+
const finished = await finishUpdate({ kbDir, brainHome, refreshReceipt, outcome, updaterResult, cleanupPending,
|
|
4811
|
+
settle: settleRefresh, converge: async () => await convergeHostsAfterUpdate(kbDir) });
|
|
4812
|
+
detachGuards();
|
|
4813
|
+
await printConfirmation({ footprint: finished.footprint?.after || null });
|
|
4814
|
+
}
|
|
4815
|
+
|
|
4816
|
+
/**
|
|
4817
|
+
* What host convergence means for an `--update` whose knowledge phases all PASSED (the shared 4.7.21 exit
|
|
4818
|
+
* contract). Converged: PASS. A host restart as the ONLY gap (Codex reports restartScope 'unproven' on every
|
|
4819
|
+
* boot-level change): the knowledge update stands, exit 0, and the phase is recorded required:false with
|
|
4820
|
+
* restartPending, so the nightly no longer reports a good update as FAILED and still reconciles its modules
|
|
4821
|
+
* (audit C, real receipt on the owner's Mac, Oct 24). Anything else: a required FAIL, exit 1.
|
|
4822
|
+
*/
|
|
4823
|
+
export function hostConvergenceUpdateVerdict(convergence) {
|
|
4824
|
+
if (convergence?.ok && convergence.convergence?.healthy === true) return { status: 'PASS', required: true, exitCode: 0 };
|
|
4825
|
+
if (convergence?.restartOnly === true && convergence.convergence?.state === 'host-restart-required') {
|
|
4826
|
+
return { status: 'PASS', required: false, restartPending: true, exitCode: 0 };
|
|
4455
4827
|
}
|
|
4828
|
+
return { status: 'FAIL', required: true, exitCode: 1 };
|
|
4829
|
+
}
|
|
4830
|
+
|
|
4831
|
+
/**
|
|
4832
|
+
* The part of `--update` after every knowledge phase has PASSED: host convergence, managed router additions,
|
|
4833
|
+
* nightly module reconciliation, lifecycle retention, footprint, terminal settlement. Exported with its effects
|
|
4834
|
+
* injectable so the exit contract is testable without a network or a real host:
|
|
4835
|
+
* 0 knowledge updated (a host restart as the only gap is an advisory); 12 cleanup pending; 1 a real failure.
|
|
4836
|
+
* Lifecycle-evidence retention is an ADVISORY here, never the terminal verdict: it used to turn every good
|
|
4837
|
+
* update into recovery-required exit 1 and a ~745 MB reinstall, nightly (audit C). The updater's own
|
|
4838
|
+
* `retentionAdvisory` (forge-update, 4.7.21) is recorded beside the installer's measurement.
|
|
4839
|
+
*/
|
|
4840
|
+
export async function finishUpdate({ kbDir, brainHome, refreshReceipt, outcome, updaterResult = null, cleanupPending = false,
|
|
4841
|
+
settle,
|
|
4842
|
+
converge = convergeHostsAfterUpdate,
|
|
4843
|
+
reconcile = reconcileNightlyModules,
|
|
4844
|
+
routerDefault = refreshManagedRouterDefault,
|
|
4845
|
+
managedCatalog = () => applyManagedCatalogUpdate({ routerDir: path.join(os.homedir(), '.claude', 'model-router'), packageRoot: REPO_ROOT }),
|
|
4846
|
+
prune = pruneLifecycleEvidence,
|
|
4847
|
+
enforce = enforceFootprint,
|
|
4848
|
+
enroll = () => {
|
|
4849
|
+
// `--update --auto` = update now AND enroll in Evergreen, so this is the LAST time it's ever run by
|
|
4850
|
+
// hand. enableNightly() prints its own real verification (plist path + launchctl result).
|
|
4851
|
+
if (!FLAG_AUTO) return;
|
|
4852
|
+
info(c.dim("\n--auto set: enrolling in Evergreen auto-update so you never run this again…\n"));
|
|
4853
|
+
enableNightly(); // exits on its own with verified output; if it returns, fall through to the update verdict
|
|
4854
|
+
},
|
|
4855
|
+
} = {}) {
|
|
4856
|
+
let updateStatus = 0;
|
|
4857
|
+
info(c.dim('\nsynchronizing every detected host to this exact published version…\n'));
|
|
4858
|
+
const convergence = await converge(kbDir);
|
|
4859
|
+
const host = hostConvergenceUpdateVerdict(convergence);
|
|
4860
|
+
if (host.exitCode !== 0) {
|
|
4861
|
+
let active;
|
|
4862
|
+
try { active = JSON.parse(fs.readFileSync(path.join(footprintRoots().brainHome, 'active.json'), 'utf8')); } catch { /* report unknown activation */ }
|
|
4863
|
+
warn(hostSynchronizationFailureMessage(convergence, active));
|
|
4864
|
+
updateStatus = 1;
|
|
4865
|
+
} else if (host.restartPending) {
|
|
4866
|
+
warn(`the knowledge update succeeded; a host restart is still pending — ${convergence.convergence.action}`);
|
|
4867
|
+
} else if (convergence.convergence?.notice) info(convergence.convergence.notice);
|
|
4868
|
+
recordRefreshPhase(refreshReceipt, 'host-convergence', host.status, {
|
|
4869
|
+
state: convergence?.convergence?.state || null, error: convergence?.error || null,
|
|
4870
|
+
...(host.restartPending ? { restartPending: true, advisory: 'host restart pending; knowledge update succeeded' } : {}),
|
|
4871
|
+
...(convergence?.convergence?.openSessions ? { openSessions: convergence.convergence.openSessions } : {}),
|
|
4872
|
+
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
4873
|
+
required: host.required,
|
|
4874
|
+
});
|
|
4456
4875
|
// Issue #87: a normal lifecycle update must also carry MANAGED MODEL additions to an existing
|
|
4457
4876
|
// user. This call used to live only in offerRouterProfile() — the fresh-install path — so someone
|
|
4458
4877
|
// who already had ~/.claude/model-router/catalog.json (i.e. every existing user, the only ones who
|
|
@@ -4460,13 +4879,10 @@ async function runUpdate() {
|
|
|
4460
4879
|
// updated. Additive and non-interactive; their overrides win, metered rows are never auto-enabled,
|
|
4461
4880
|
// and a failure here is reported but never fails an otherwise-good update.
|
|
4462
4881
|
if (updateStatus === 0) {
|
|
4463
|
-
const defaultReceipt =
|
|
4882
|
+
const defaultReceipt = routerDefault();
|
|
4464
4883
|
recordRefreshAdvisory(refreshReceipt, 'managed-router-default', defaultReceipt.action === 'failed' ? 'SKIP' : 'PASS', defaultReceipt);
|
|
4465
4884
|
try {
|
|
4466
|
-
const managed =
|
|
4467
|
-
routerDir: path.join(os.homedir(), '.claude', 'model-router'),
|
|
4468
|
-
packageRoot: REPO_ROOT,
|
|
4469
|
-
});
|
|
4885
|
+
const managed = managedCatalog();
|
|
4470
4886
|
if (managed.action === 'merged' && managed.added.length) {
|
|
4471
4887
|
ok(`model router: added ${managed.added.length} managed model(s) — ${managed.added.join(', ')} (your catalog edits were preserved)`);
|
|
4472
4888
|
}
|
|
@@ -4476,37 +4892,41 @@ async function runUpdate() {
|
|
|
4476
4892
|
recordRefreshAdvisory(refreshReceipt, 'managed-catalog', 'SKIP', { reason: e.message });
|
|
4477
4893
|
}
|
|
4478
4894
|
}
|
|
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 });
|
|
4895
|
+
// Only enroll if the update itself succeeded — never promise "you're set forever" on a failed update.
|
|
4896
|
+
if (updateStatus === 0) enroll();
|
|
4897
|
+
// The knowledge phases passed, so the nightly must run the installed modules even when a host did not
|
|
4898
|
+
// converge; otherwise a host problem froze the 03:30 job on yesterday's coordinator (audit C).
|
|
4899
|
+
reconcile({ brainHome, refreshReceipt });
|
|
4487
4900
|
let retention;
|
|
4488
4901
|
try {
|
|
4489
|
-
retention =
|
|
4902
|
+
retention = prune({ brainHome, kbDir, preserveRefreshRunIds: [refreshReceipt.runId],
|
|
4490
4903
|
preserveTransactionPaths: updaterResult?.transactionReceipts ? [updaterResult.transactionReceipts] : [] });
|
|
4491
4904
|
} catch (error) {
|
|
4492
4905
|
retention = { schemaVersion: 1, kind: 'ruvnet-brain-lifecycle-evidence-retention',
|
|
4493
4906
|
withinBudget: false, unsafe: [{ path: brainHome, reason: error.message }] };
|
|
4494
4907
|
}
|
|
4495
|
-
const retentionFailed = retention
|
|
4496
|
-
const
|
|
4497
|
-
recordRefreshPhase(refreshReceipt, 'cleanup',
|
|
4908
|
+
const retentionFailed = retention?.withinBudget !== true;
|
|
4909
|
+
const updaterRetention = updaterResult?.retentionAdvisory || null;
|
|
4910
|
+
recordRefreshPhase(refreshReceipt, 'cleanup', cleanupPending ? 'FAIL' : (updateStatus === 0 ? 'PASS' : 'SKIP'), {
|
|
4498
4911
|
reason: cleanupPending ? 'verified live generation has redundant rollback cleanup pending'
|
|
4499
|
-
:
|
|
4500
|
-
: (updateStatus === 0 ? 'transaction settled' : 'upstream required phase failed'),
|
|
4912
|
+
: (updateStatus === 0 ? 'transaction settled' : 'upstream required phase failed'),
|
|
4501
4913
|
storageDelta: updaterResult?.storageDelta || null,
|
|
4502
4914
|
lifecycleRetention: retention,
|
|
4503
4915
|
execution: { kind: 'executed', runId: refreshReceipt.runId },
|
|
4504
|
-
required:
|
|
4916
|
+
required: cleanupPending || updateStatus === 0,
|
|
4505
4917
|
});
|
|
4918
|
+
if (retentionFailed || updaterRetention) {
|
|
4919
|
+
const unsafe = (retentionFailed ? retention?.unsafe || [] : []).map((entry) => entry?.path).filter(Boolean);
|
|
4920
|
+
warn(`lifecycle evidence could not be fully pruned (advisory; the update itself stands)${unsafe.length ? `: ${unsafe.slice(0, 3).join(', ')}` : ''}`);
|
|
4921
|
+
try {
|
|
4922
|
+
recordRefreshAdvisory(refreshReceipt, 'lifecycle-retention', 'FAIL', {
|
|
4923
|
+
installer: retentionFailed ? retention : null, updater: updaterRetention });
|
|
4924
|
+
} catch { /* the advisory never blocks settlement */ }
|
|
4925
|
+
}
|
|
4506
4926
|
// ADR-0098: after the swap, enforce the footprint (the updater just released its own rollback; this
|
|
4507
4927
|
// releases everything else that must not exist) and record the result on the receipt as an advisory —
|
|
4508
4928
|
// a footprint problem is reported, it never fails an otherwise-good update.
|
|
4509
|
-
const footprint =
|
|
4929
|
+
const footprint = enforce({ holdingRefreshLock: true });
|
|
4510
4930
|
try {
|
|
4511
4931
|
const after = footprint?.after;
|
|
4512
4932
|
recordRefreshAdvisory(refreshReceipt, 'footprint', after && after.kbCopies === 1 && !after.cruft.length && after.withinBudget ? 'PASS' : 'FAIL', {
|
|
@@ -4514,13 +4934,15 @@ async function runUpdate() {
|
|
|
4514
4934
|
budgetBytes: after?.budgetBytes ?? null, removed: footprint?.removed.length ?? 0, freedBytes: footprint?.freedBytes ?? 0,
|
|
4515
4935
|
});
|
|
4516
4936
|
} catch { /* the advisory never blocks settlement */ }
|
|
4517
|
-
|
|
4518
|
-
|
|
4519
|
-
|
|
4520
|
-
|
|
4937
|
+
// A genuine failure (a required host convergence) outranks cleanup-pending: 12 tells the coordinator and
|
|
4938
|
+
// host-update the knowledge applied and only redundant cleanup is owed, so it must not mask an exit 1.
|
|
4939
|
+
const failed = updateStatus !== 0;
|
|
4940
|
+
const exitCode = failed ? updateStatus : (cleanupPending ? 12 : 0);
|
|
4941
|
+
settle(exitCode, {
|
|
4942
|
+
phase: failed ? 'failed' : (cleanupPending ? 'cleanup' : 'complete'),
|
|
4943
|
+
terminalVerdict: failed ? 'failed' : cleanupPending ? 'cleanup-pending' : (outcome?.verdict === 'noop' ? 'noop' : 'applied'),
|
|
4521
4944
|
storageDelta: updaterResult?.storageDelta || null, lifecycleRetention: retention });
|
|
4522
|
-
|
|
4523
|
-
await printConfirmation({ footprint: footprint?.after || null });
|
|
4945
|
+
return { exitCode, footprint, hostConvergence: host };
|
|
4524
4946
|
}
|
|
4525
4947
|
|
|
4526
4948
|
/**
|
|
@@ -4625,15 +5047,56 @@ const SPEND_GUARD_LABEL = 'com.ruvnet.spend-watchdog';
|
|
|
4625
5047
|
const spendGuardScriptPath = () => path.join(os.homedir(), '.claude', 'scripts', 'api-spend-watchdog.mjs');
|
|
4626
5048
|
const spendGuardPlistPath = () => path.join(os.homedir(), 'Library', 'LaunchAgents', `${SPEND_GUARD_LABEL}.plist`);
|
|
4627
5049
|
|
|
4628
|
-
|
|
5050
|
+
/**
|
|
5051
|
+
* Every LaunchAgent that already runs an api-spend-watchdog.mjs, under ANY label (the owner's runs as
|
|
5052
|
+
* com.stuartkerr.api-spend-watchdog). Matched on ProgramArguments, so a second hourly job is never added beside
|
|
5053
|
+
* an existing one. Read-only; a binary plist is converted with plutil, an unreadable one is skipped.
|
|
5054
|
+
*/
|
|
5055
|
+
export function spendGuardAgents({ agentsDir = path.join(os.homedir(), 'Library', 'LaunchAgents') } = {}) {
|
|
5056
|
+
let names = [];
|
|
5057
|
+
try { names = fs.readdirSync(agentsDir).filter((name) => name.endsWith('.plist')); } catch { return []; }
|
|
5058
|
+
const found = [];
|
|
5059
|
+
for (const name of names) {
|
|
5060
|
+
const file = path.join(agentsDir, name);
|
|
5061
|
+
let text;
|
|
5062
|
+
try {
|
|
5063
|
+
const bytes = fs.readFileSync(file);
|
|
5064
|
+
text = bytes.subarray(0, 6).toString('latin1') === 'bplist'
|
|
5065
|
+
? spawnSync('plutil', ['-convert', 'xml1', '-o', '-', file], { encoding: 'utf8' }).stdout || ''
|
|
5066
|
+
: bytes.toString('utf8');
|
|
5067
|
+
} catch { continue; }
|
|
5068
|
+
const args = /<key>\s*ProgramArguments\s*<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(text)?.[1] || '';
|
|
5069
|
+
if (!/api-spend-watchdog\.mjs/.test(args)) continue;
|
|
5070
|
+
const label = /<key>\s*Label\s*<\/key>\s*<string>([^<]*)<\/string>/.exec(text)?.[1]?.trim() || name.replace(/\.plist$/, '');
|
|
5071
|
+
found.push({ label, plist: file });
|
|
5072
|
+
}
|
|
5073
|
+
return found;
|
|
5074
|
+
}
|
|
5075
|
+
|
|
5076
|
+
export function enableSpendGuard() {
|
|
5077
|
+
// A spend watchdog already scheduled under another label (the owner's own job, or an earlier hand install)
|
|
5078
|
+
// is the same safety net: say where it is and add nothing. Two hourly jobs mean two alerts per event.
|
|
5079
|
+
const foreign = spendGuardAgents().filter((agent) => agent.label !== SPEND_GUARD_LABEL);
|
|
5080
|
+
if (foreign.length) {
|
|
5081
|
+
ok(`spend watchdog already installed as ${c.bold(foreign[0].label)} (${foreign[0].plist}) — not adding a second hourly job`);
|
|
5082
|
+
return 'already-on';
|
|
5083
|
+
}
|
|
4629
5084
|
// The npx checkout is ephemeral, so copy the bundled watchdog to a persistent home the launchd
|
|
4630
|
-
// job can point at for good
|
|
5085
|
+
// job can point at for good — but NEVER over a different file already there: it may be the user's own
|
|
5086
|
+
// fixed copy (the owner's 2026-10-25 flood fix was one interactive install away from being reverted).
|
|
4631
5087
|
const src = path.join(__dirname, 'api-spend-watchdog.mjs');
|
|
4632
5088
|
const dst = spendGuardScriptPath();
|
|
4633
5089
|
if (!fs.existsSync(src)) { warn('spend-watchdog source missing from this bundle — skipping (non-fatal)'); return 'no-source'; }
|
|
4634
5090
|
fs.mkdirSync(path.dirname(dst), { recursive: true });
|
|
4635
|
-
|
|
4636
|
-
|
|
5091
|
+
let existing = null;
|
|
5092
|
+
try { existing = fs.readFileSync(dst); } catch { /* absent */ }
|
|
5093
|
+
if (existing && !existing.equals(fs.readFileSync(src))) {
|
|
5094
|
+
warn(`kept your existing ${c.bold(dst)} — it differs from the bundled watchdog, so it was not overwritten`);
|
|
5095
|
+
info(`to use the bundled one instead: ${c.bold(`rm ${dst}`)} then ${c.bold('npx ruvnet-brain --enable-spend-guard')}`);
|
|
5096
|
+
} else if (!existing) {
|
|
5097
|
+
fs.copyFileSync(src, dst);
|
|
5098
|
+
ok(`installed the spend watchdog → ${c.bold(dst)}`);
|
|
5099
|
+
} else ok(`spend watchdog script already current → ${c.bold(dst)}`);
|
|
4637
5100
|
|
|
4638
5101
|
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
4639
5102
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
@@ -4686,6 +5149,8 @@ function disableSpendGuard() {
|
|
|
4686
5149
|
const plistPath = spendGuardPlistPath();
|
|
4687
5150
|
const scriptPath = spendGuardScriptPath();
|
|
4688
5151
|
const existed = fs.existsSync(plistPath);
|
|
5152
|
+
// The script stays when another LaunchAgent runs it: that job is not ours to break.
|
|
5153
|
+
const sharedWith = spendGuardAgents().filter((agent) => agent.label !== SPEND_GUARD_LABEL);
|
|
4689
5154
|
if (TEST_MODE) {
|
|
4690
5155
|
warn('RUVNET_BRAIN_TEST=1 — skipping launchctl bootout (plist removal only)');
|
|
4691
5156
|
} else {
|
|
@@ -4693,7 +5158,8 @@ function disableSpendGuard() {
|
|
|
4693
5158
|
spawnSync('launchctl', ['bootout', `gui/${process.getuid()}/${SPEND_GUARD_LABEL}`], { stdio: 'ignore' });
|
|
4694
5159
|
}
|
|
4695
5160
|
let failed = false;
|
|
4696
|
-
|
|
5161
|
+
if (sharedWith.length && fs.existsSync(scriptPath)) info(`kept ${scriptPath} — ${sharedWith[0].label} runs it`);
|
|
5162
|
+
for (const p of sharedWith.length ? [plistPath] : [plistPath, scriptPath]) {
|
|
4697
5163
|
if (!fs.existsSync(p)) continue;
|
|
4698
5164
|
try { fs.rmSync(p); } catch (e) {
|
|
4699
5165
|
failed = true;
|
|
@@ -4748,10 +5214,10 @@ function footprintEvidence() {
|
|
|
4748
5214
|
|
|
4749
5215
|
/** Enforce the footprint now: remove what must not exist (proof-gated), rotate over-cap logs, collect
|
|
4750
5216
|
* lease-free plugin generations. Narrates every removal and every refusal; never throws. */
|
|
4751
|
-
export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false } = {}) {
|
|
5217
|
+
export function enforceFootprint({ holdingRefreshLock = false, pruneEvidence = false, quiet = false, archive = false } = {}) {
|
|
4752
5218
|
let result;
|
|
4753
5219
|
try {
|
|
4754
|
-
result = sweepFootprint({ apply: true, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
|
|
5220
|
+
result = sweepFootprint({ apply: true, archive, holdingRefreshLock, now: footprintNow(), evidence: footprintEvidence(),
|
|
4755
5221
|
collectPluginGenerations: ({ registryPath, apply }) => prunePluginGenerations({ registryPath, apply }),
|
|
4756
5222
|
pruneEvidence: pruneEvidence ? (args) => pruneLifecycleEvidence(args) : null });
|
|
4757
5223
|
} catch (error) {
|
|
@@ -4810,7 +5276,10 @@ export function machineFootprint() {
|
|
|
4810
5276
|
}
|
|
4811
5277
|
if (process.platform === 'darwin') {
|
|
4812
5278
|
add('Spend watchdog (LaunchAgent)', spendGuardPlistPath(), 'npx ruvnet-brain --disable-spend-guard');
|
|
4813
|
-
|
|
5279
|
+
// A script another LaunchAgent runs is not ours to list for removal (disableSpendGuard keeps it).
|
|
5280
|
+
if (!spendGuardAgents().some((agent) => agent.label !== SPEND_GUARD_LABEL)) {
|
|
5281
|
+
add('Spend watchdog script', spendGuardScriptPath(), 'npx ruvnet-brain --disable-spend-guard');
|
|
5282
|
+
}
|
|
4814
5283
|
}
|
|
4815
5284
|
const cmds = pluginCommandsDir();
|
|
4816
5285
|
if (cmds) items.push({
|
|
@@ -4831,6 +5300,12 @@ export function machineFootprint() {
|
|
|
4831
5300
|
items.push({ label: 'CLAUDE.md block (6 lines, between markers)', path: cmdPath, undo: 'npx ruvnet-brain --uninstall' });
|
|
4832
5301
|
}
|
|
4833
5302
|
} catch { /* unreadable */ }
|
|
5303
|
+
try {
|
|
5304
|
+
const grokAgents = grokPaths().agents;
|
|
5305
|
+
if (fs.existsSync(grokAgents) && fs.readFileSync(grokAgents, 'utf8').includes(CLAUDE_MD_START)) {
|
|
5306
|
+
items.push({ label: 'Grok AGENTS.md search-first line (between markers)', path: grokAgents, undo: 'npx ruvnet-brain --uninstall' });
|
|
5307
|
+
}
|
|
5308
|
+
} catch { /* unreadable */ }
|
|
4834
5309
|
add('Usage-counts preference', telemetryConsentPath(), 'delete this file');
|
|
4835
5310
|
|
|
4836
5311
|
// EVERYTHING ELSE THIS INSTALLER WRITES. The first version of this listed the KB bundle and
|
|
@@ -4935,8 +5410,7 @@ function showWhatsNew() {
|
|
|
4935
5410
|
* Backed up and written atomically, same as when it was added — taking something away is at least
|
|
4936
5411
|
* as sensitive as putting it there.
|
|
4937
5412
|
*/
|
|
4938
|
-
function removeClaudeMdBlock() {
|
|
4939
|
-
const p = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
5413
|
+
function removeClaudeMdBlock(p = path.join(os.homedir(), '.claude', 'CLAUDE.md')) {
|
|
4940
5414
|
let src = '';
|
|
4941
5415
|
try { src = fs.existsSync(p) ? fs.readFileSync(p, 'utf8') : ''; } catch { return 'unreadable'; }
|
|
4942
5416
|
// PAIR THE MARKERS PROPERLY. The first version took the FIRST start and the FIRST end, unpaired.
|
|
@@ -5015,6 +5489,7 @@ function uninstallAll() {
|
|
|
5015
5489
|
|
|
5016
5490
|
const claudeMd = removeClaudeMdBlock();
|
|
5017
5491
|
if (claudeMd === 'removed') ok('removed our block from ~/.claude/CLAUDE.md (your content untouched, backup saved)');
|
|
5492
|
+
if (removeClaudeMdBlock(grokPaths().agents) === 'removed') ok('removed our line from ~/.grok/AGENTS.md (your content untouched, backup saved)');
|
|
5018
5493
|
|
|
5019
5494
|
// GAP FIX: this used to be permanently "manual" — machineFootprint() treated ANY settings.json edit
|
|
5020
5495
|
// as something only the user could safely touch. That blanket rule was right for edits we cannot
|
|
@@ -5085,9 +5560,16 @@ function uninstallAll() {
|
|
|
5085
5560
|
// Exported (testable under RUVNET_BRAIN_IMPORT_ONLY=1, like offerNightly). Never throws — the caller
|
|
5086
5561
|
// also guards, because a finished install must never be broken by an optional safety offer.
|
|
5087
5562
|
export async function offerSpendGuard() {
|
|
5563
|
+
// Read-only detection first, so an existing guard under ANY label is reported as on (and never offered twice).
|
|
5564
|
+
if (process.platform === 'darwin') {
|
|
5565
|
+
const existing = spendGuardAgents();
|
|
5566
|
+
if (existing.length || fs.existsSync(spendGuardPlistPath())) {
|
|
5567
|
+
ok(`spend watchdog already installed${existing.length ? ` as ${existing[0].label}` : ''} — runaway API spend will alert you`);
|
|
5568
|
+
return 'already-on';
|
|
5569
|
+
}
|
|
5570
|
+
}
|
|
5088
5571
|
if (FLAG_NO_NIGHTLY_PROMPT || TEST_MODE) return 'suppressed';
|
|
5089
5572
|
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
5573
|
|
|
5092
5574
|
step(
|
|
5093
5575
|
'One more safety net — a spend watchdog',
|
|
@@ -6262,6 +6744,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
6262
6744
|
` • the Codex host ${hostReady && pluginReady ? c.green('wired at user scope') : c.yellow('needs attention')} — search_ruvnet + native Brain skills`,
|
|
6263
6745
|
);
|
|
6264
6746
|
}
|
|
6747
|
+
const grokLine = grokStatusLine(currentGrokStatus());
|
|
6748
|
+
if (grokLine) console.log(` • ${grokLine}`);
|
|
6265
6749
|
if (env) {
|
|
6266
6750
|
const t = (okv) => (okv ? c.green('✓ present') : c.yellow('not added (optional)'));
|
|
6267
6751
|
console.log(` • rUv build toolkit — Ruflo: ${t(env.ruflo)} · RuVector: ${t(env.ruvector)} ${c.dim('answers work without them')}`);
|
|
@@ -6276,8 +6760,8 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
6276
6760
|
console.log(`\n ${c.bold('Where it runs:')} ${c.bold(`everywhere you use ${hostLabel}`)} — CLI and supported editor/desktop surfaces.`);
|
|
6277
6761
|
console.log(` It's ${c.bold('user-level (global)')}: open ANY repo or folder and it's already there. Nothing per project,`);
|
|
6278
6762
|
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(`
|
|
6763
|
+
console.log(`\n ${c.bold('How it runs:')} search_ruvnet, Brain skills and proof commands when you ask, plus the allowlisted`);
|
|
6764
|
+
console.log(` lifecycle hooks (${hookCountPhrase()}): grounding reminders, a write gate, and continuity snapshots.`);
|
|
6281
6765
|
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
6766
|
console.log(` Release regardless (that part isn't cached). For the bleeding-edge installer too, use ${c.bold('npx github:stuinfla/ruvnet-brain')}.`);
|
|
6283
6767
|
|
|
@@ -6294,12 +6778,12 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
6294
6778
|
console.log(`\n ${c.bold('How you\'ll know it\'s working:')}`);
|
|
6295
6779
|
console.log(` ${c.green('✓')} ${c.bold('search_ruvnet')} returns cited source paths when invoked.`);
|
|
6296
6780
|
console.log(` ${c.green('✓')} Brain skills and slash commands remain available.`);
|
|
6297
|
-
console.log(` ${c.green('✓')}
|
|
6781
|
+
console.log(` ${c.green('✓')} Writing rUv-product code the Brain has not looked up is refused until search_ruvnet grounds it.`);
|
|
6298
6782
|
|
|
6299
6783
|
// ── honest expectations ──
|
|
6300
6784
|
console.log(`\n ${c.bold('What to expect (honestly):')}`);
|
|
6301
|
-
console.log(` • Grounding
|
|
6302
|
-
console.log(` •
|
|
6785
|
+
console.log(` • Grounding reminders are added only to prompts that touch the rUv stack.`);
|
|
6786
|
+
console.log(` • Other work proceeds normally; the write gate is the only hook that refuses by default.`);
|
|
6303
6787
|
console.log(` • Not sure it's on? Run ${c.bold('npx ruvnet-brain --doctor')} any time for a health check.
|
|
6304
6788
|
• Want to see it answer, live, right now? ${c.bold('npx ruvnet-brain --demo')} — 2 real questions, real cited answers.`);
|
|
6305
6789
|
|
|
@@ -6325,7 +6809,7 @@ function success({ cacheDir, isCustom, plugin, codexHost, codexPlugin, env, nigh
|
|
|
6325
6809
|
console.log(`\n ${c.bold('If it earns it:')} a GitHub star helps other people find the brain —`);
|
|
6326
6810
|
console.log(` ${c.bold('https://github.com/stuinfla/ruvnet-brain')} ${c.dim('· feedback in one command: npx ruvnet-brain --feedback')}`);
|
|
6327
6811
|
|
|
6328
|
-
console.log(`\n ${c.dim(
|
|
6812
|
+
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
6813
|
console.log('');
|
|
6330
6814
|
}
|
|
6331
6815
|
|
|
@@ -6345,11 +6829,15 @@ Usage:
|
|
|
6345
6829
|
footprint within budget, no cruft. --doctor --json prints only that, as JSON.
|
|
6346
6830
|
npx ruvnet-brain --clean Remove everything that must not exist (old KB copies — never one holding
|
|
6347
6831
|
private files the live brain lacks — old installers, over-cap logs), then confirm.
|
|
6832
|
+
npx ruvnet-brain --clean --archive
|
|
6833
|
+
Same, and for a kept copy: copy only the files it alone holds into
|
|
6834
|
+
kb.archive/<stamp>/ (verified byte-for-byte), then release that copy.
|
|
6348
6835
|
EXITS NON-ZERO when the install is genuinely broken, so it can gate a
|
|
6349
6836
|
script: npx ruvnet-brain --doctor && ./deploy.sh
|
|
6350
6837
|
npx ruvnet-brain --doctor --hooks
|
|
6351
|
-
|
|
6352
|
-
|
|
6838
|
+
Read-only proof that the shipped hook manifests register only the
|
|
6839
|
+
allowlisted continuity handlers (${hookCountPhrase()}).
|
|
6840
|
+
Executes no hook body.
|
|
6353
6841
|
npx ruvnet-brain --demo Guided walkthrough — 2 real questions, real cited answers
|
|
6354
6842
|
npx ruvnet-brain --feedback Tell us how it went — prefills a GitHub Discussion with your brain
|
|
6355
6843
|
version, platform, and a 3-line health summary (you see exactly
|
|
@@ -6366,6 +6854,8 @@ Usage:
|
|
|
6366
6854
|
block only — your own CLAUDE.md content is preserved and backed up)
|
|
6367
6855
|
npx ruvnet-brain --enable-spend-guard Install the hourly runaway-agent spend alarm (alert-only)
|
|
6368
6856
|
npx ruvnet-brain --disable-spend-guard Remove it (safe to run any time)
|
|
6857
|
+
npx ruvnet-brain --grok-agents-md Grok CLI only, opt-in: add one search-first line to ~/.grok/AGENTS.md
|
|
6858
|
+
(only if no search_ruvnet line is there; backed up; --uninstall removes it)
|
|
6369
6859
|
npx ruvnet-brain --enhance-claude-md Add the 6-line RuvNet-Brain block to ~/.claude/CLAUDE.md
|
|
6370
6860
|
(appended at the bottom between markers; your file is backed up first)
|
|
6371
6861
|
(a default install RECOMMENDS nightly and asks, defaulting to yes)
|
|
@@ -6412,11 +6902,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6412
6902
|
// in a unit test ran a real install against the maintainer's HOME. Direct argv identity is the
|
|
6413
6903
|
// authority boundary. The old IMPORT_ONLY flag may still be present in a long-lived test worker;
|
|
6414
6904
|
// 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));
|
|
6905
|
+
const invokedDirectly = isDirectInvocation(import.meta.url);
|
|
6420
6906
|
if (!invokedDirectly) return;
|
|
6421
6907
|
if (FLAG_HELP) return showHelp();
|
|
6422
6908
|
if (NAMED_RELEASE?.error) {
|
|
@@ -6461,7 +6947,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6461
6947
|
// remains and nothing that must not exist is left (a private-unique copy kept for safety is a 1).
|
|
6462
6948
|
if (FLAG_CLEAN) {
|
|
6463
6949
|
if (!FLAG_JSON) printBanner('clean');
|
|
6464
|
-
const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON });
|
|
6950
|
+
const swept = enforceFootprint({ pruneEvidence: true, quiet: FLAG_JSON, archive: FLAG_ARCHIVE });
|
|
6465
6951
|
const result = await printConfirmation({ footprint: swept?.after || null, json: FLAG_JSON });
|
|
6466
6952
|
process.exitCode = swept && result.footprint.kbCopies === 1 && result.footprint.cruft.length === 0 ? 0 : 1;
|
|
6467
6953
|
return;
|
|
@@ -6489,6 +6975,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6489
6975
|
if (FLAG_ENABLE_SPEND_GUARD) { enableSpendGuard(); return; }
|
|
6490
6976
|
if (FLAG_DISABLE_SPEND_GUARD) { disableSpendGuard(); return; }
|
|
6491
6977
|
if (FLAG_UNINSTALL) { uninstallAll(); return; }
|
|
6978
|
+
if (FLAG_GROK_AGENTS_MD) { reportGrokAgentsLine(addGrokAgentsLine()); return; }
|
|
6492
6979
|
if (FLAG_WHAT_CHANGED) { printBanner('what RuvNet Brain put on this machine'); printFootprint(); return; }
|
|
6493
6980
|
if (FLAG_WHATS_NEW) { showWhatsNew(); return; }
|
|
6494
6981
|
|
|
@@ -6538,6 +7025,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6538
7025
|
let installedTag = null;
|
|
6539
7026
|
let latestTag = null;
|
|
6540
7027
|
let resolvedRelease = null; // reused below so the release is resolved at most once
|
|
7028
|
+
let namedTag = null, namedMove = false, namedDowngrade = false; // --version / --pin over an existing install (#504)
|
|
6541
7029
|
if (alreadyInstalled && !FLAG_FORCE) {
|
|
6542
7030
|
installedTag = installedBrainVersion(cacheDir); // 'unknown' when SOURCE.json has no releaseTag
|
|
6543
7031
|
try {
|
|
@@ -6580,10 +7068,28 @@ the installer reports that boot-level declarations changed.
|
|
|
6580
7068
|
ahead = Boolean(a && b && cmpTag(a, b) > 0);
|
|
6581
7069
|
staleSkip = Boolean(b && (a === null || (a !== b && !ahead)));
|
|
6582
7070
|
}
|
|
7071
|
+
// A NAMED release (--version / --pin) runs no lookup, so latestTag stays null and the block below
|
|
7072
|
+
// used to print "the release lookup failed" for a lookup that never happened, installing nothing
|
|
7073
|
+
// (#504). The operator named the target: same tag = truthful skip; different or unknown = take the
|
|
7074
|
+
// same signature-verified download path. Older than installed is allowed (explicit request), loudly.
|
|
7075
|
+
const named = resolvedRelease && (resolvedRelease.source === 'pinned' || resolvedRelease.source === 'forced')
|
|
7076
|
+
? (resolvedRelease.tag_name || resolvedRelease.tag) : null;
|
|
7077
|
+
if (named) {
|
|
7078
|
+
const nrm = (v) => (v == null || v === 'unknown' ? null : String(v).replace(/^v/, ''));
|
|
7079
|
+
namedTag = named;
|
|
7080
|
+
if (isCorpusReleaseTag(named)) namedMove = installedCorpusTag(cacheDir) !== named;
|
|
7081
|
+
else {
|
|
7082
|
+
namedMove = nrm(installedTag) !== nrm(named);
|
|
7083
|
+
namedDowngrade = Boolean(nrm(installedTag) && cmpTag(nrm(installedTag), nrm(named)) > 0);
|
|
7084
|
+
}
|
|
7085
|
+
}
|
|
6583
7086
|
}
|
|
6584
7087
|
|
|
6585
|
-
if (alreadyInstalled && !FLAG_FORCE && !staleSkip) {
|
|
6586
|
-
if (
|
|
7088
|
+
if (alreadyInstalled && !FLAG_FORCE && !staleSkip && !namedMove) {
|
|
7089
|
+
if (namedTag) {
|
|
7090
|
+
step('Brain already at the named release — skipping the download', `installed ${installedTag} matches ${namedTag}`);
|
|
7091
|
+
ok(`found ${namedTag} at ${cacheDir} — nothing to download`);
|
|
7092
|
+
} else if (latestTag && ahead) {
|
|
6587
7093
|
// Never silently imply equality when the user is AHEAD — that would be a small lie, and it is
|
|
6588
7094
|
// the one that hides a downgrade. Skipping is right; misdescribing why is not.
|
|
6589
7095
|
step('Brain already current — skipping the download', `installed ${installedTag} is NEWER than the latest release (${latestTag})`);
|
|
@@ -6598,6 +7104,10 @@ the installer reports that boot-level declarations changed.
|
|
|
6598
7104
|
info(`When you have a connection: ${c.bold('npx ruvnet-brain --update')} ${c.dim('(or --force to refetch now)')}`);
|
|
6599
7105
|
}
|
|
6600
7106
|
} else {
|
|
7107
|
+
if (namedMove) {
|
|
7108
|
+
if (namedDowngrade) warn(`${namedTag} is OLDER than the installed ${installedTag} — downgrading because you named it explicitly.`);
|
|
7109
|
+
step('Moving to the named release', `installed ${installedTag || 'unknown'} → named ${namedTag}`);
|
|
7110
|
+
}
|
|
6601
7111
|
if (staleSkip) {
|
|
6602
7112
|
step('Brain is out of date — fetching the current release', `installed ${installedTag || 'unknown'} → latest ${latestTag}`);
|
|
6603
7113
|
info(`${c.dim('(the old installer skipped this whenever any brain was present, which is why stale installs never moved)')}`);
|
|
@@ -6686,6 +7196,9 @@ the installer reports that boot-level declarations changed.
|
|
|
6686
7196
|
codexHost = wireCodexHost();
|
|
6687
7197
|
if (codexHost.host) {
|
|
6688
7198
|
codexPlugin = wireCodexPlugin();
|
|
7199
|
+
const hostRefresh = await refreshCodexSharedHost(codexPlugin);
|
|
7200
|
+
const refreshNote = codexHostRefreshMessage(hostRefresh, codexPlugin);
|
|
7201
|
+
if (refreshNote) (refreshNote.level === 'warn' ? warn : info)(refreshNote.text);
|
|
6689
7202
|
if (codexPlugin.available) printCodexLifecycle(await codexLifecycleStatus());
|
|
6690
7203
|
}
|
|
6691
7204
|
} catch (e) {
|
|
@@ -6711,6 +7224,7 @@ the installer reports that boot-level declarations changed.
|
|
|
6711
7224
|
const env = detectEnvironment();
|
|
6712
7225
|
try { await offerStack(env); } catch (e) { warn(`(toolkit check skipped: ${e && e.message})`); }
|
|
6713
7226
|
try { await offerClaudeMd(); } catch { /* non-fatal — never let an offer break the install */ }
|
|
7227
|
+
try { await offerGrokAgentsMd(); } catch { /* non-fatal — never let an offer break the install */ }
|
|
6714
7228
|
// Stuart's requirement: a default install must end by clearly recommending nightly auto-updates
|
|
6715
7229
|
// and asking, DEFAULTING TO YES (TTY + macOS + not already on). Non-fatal like every other offer.
|
|
6716
7230
|
let nightly = 'skipped';
|