diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34035d45..8e014d2f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -368,9 +368,17 @@ jobs: run: node scripts/release-authority.mjs - name: Version lockstep at source run: npm run version:check - - name: Exact-artifact release QE + - name: Build the immutable npm candidate exactly once + shell: bash run: | + set -euo pipefail mkdir -p "$RUNNER_TEMP/release-evidence" + npm pack --json --pack-destination "$RUNNER_TEMP/release-evidence" > "$RUNNER_TEMP/release-evidence/npm-pack.json" + artifact="$RUNNER_TEMP/release-evidence/$(node -p "JSON.parse(require('fs').readFileSync(process.env.RUNNER_TEMP + '/release-evidence/npm-pack.json'))[0].filename")" + test -s "$artifact" + echo "RUVNET_SEALED_PACKAGE=$artifact" >> "$GITHUB_ENV" + - name: Exact-artifact release QE + run: | npm exec -- vitest run \ --config tests/qe/release/vitest.config.mjs \ --reporter=json \ @@ -379,10 +387,8 @@ jobs: - name: Seal the exact stabilization candidate run: | npm audit --audit-level=high --json > "$RUNNER_TEMP/release-evidence/npm-audit.json" - npm pack --json --pack-destination "$RUNNER_TEMP/release-evidence" > "$RUNNER_TEMP/release-evidence/npm-pack.json" - artifact="$RUNNER_TEMP/release-evidence/$(node -p "JSON.parse(require('fs').readFileSync(process.env.RUNNER_TEMP + '/release-evidence/npm-pack.json'))[0].filename")" node scripts/stabilization-receipt.mjs \ - --artifact "$artifact" \ + --artifact "$RUVNET_SEALED_PACKAGE" \ --qe "$RUNNER_TEMP/release-evidence/release-qe.json" \ --audit "$RUNNER_TEMP/release-evidence/npm-audit.json" \ --out "$RUNNER_TEMP/release-evidence/candidate-receipt.json" diff --git a/.github/workflows/protected-release.yml b/.github/workflows/protected-release.yml index 4e5e5667..f49e059b 100644 --- a/.github/workflows/protected-release.yml +++ b/.github/workflows/protected-release.yml @@ -1,7 +1,7 @@ name: protected-release # The candidate CI produces one immutable package + receipt. This workflow proves that exact -# candidate is current main, carries it across the reviewer-protected Production boundary, and is +# candidate is current main, carries it across the branch-protected Production boundary, and is # the only workflow allowed to invoke the publisher. on: workflow_dispatch: @@ -11,7 +11,7 @@ on: required: true type: string version: - description: Exact release generation; this workflow is locked to 4.0.6 + description: Exact release generation; this workflow is locked to 4.0.7 required: true type: string release_qe_run_id: @@ -22,13 +22,14 @@ on: permissions: actions: read contents: read + issues: read concurrency: - group: protected-release-${{ inputs.version }} + group: ruvnet-brain-release cancel-in-progress: false env: - EXPECTED_VERSION: 4.0.6 + EXPECTED_VERSION: 4.0.7 EXPECTED_SHA: ${{ inputs.candidate_sha }} jobs: @@ -63,6 +64,15 @@ jobs: test "$(git rev-parse origin/main)" = "$EXPECTED_SHA" || { echo 'candidate is not current origin/main' >&2; exit 1; } test -z "$(git status --porcelain)" || { echo 'candidate checkout is dirty' >&2; exit 1; } + - name: Require zero maintainer-governed release blockers + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + blockers="$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --label release-blocker --limit 100 --json number --jq 'length')" + test "$blockers" -eq 0 || { echo "$blockers governed release blocker(s) remain open" >&2; exit 1; } + - name: Prove exact-SHA CI and release-qe completed successfully env: GH_TOKEN: ${{ github.token }} @@ -173,7 +183,7 @@ jobs: run: | set -euo pipefail mkdir -p "$RUNNER_TEMP/release-seed" - gh release download v4.0.3 --repo "$GITHUB_REPOSITORY" --pattern ruvnet-brain.zip --dir "$RUNNER_TEMP/release-seed" + gh release download --repo "$GITHUB_REPOSITORY" --pattern ruvnet-brain.zip --dir "$RUNNER_TEMP/release-seed" unzip -q "$RUNNER_TEMP/release-seed/ruvnet-brain.zip" -d "$RUNNER_TEMP/release-seed/extracted" # Finder metadata (`__MACOSX/._*.big.rvf`) preserves a filename but is not an RVF. Select # one canonical directory deterministically and fail closed if the archive is ambiguous. @@ -189,7 +199,7 @@ jobs: node scripts/rvf-index-audit.mjs --dir "${asset_dirs[0]}" printf '%s\n' "${asset_dirs[0]}" > "$RUNNER_TEMP/release-assets-path" - - name: Revalidate after approval and invoke the one canonical publisher + - name: Publish the CI-sealed candidate through the one canonical transaction env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} GITHUB_TOKEN: ${{ github.token }} @@ -206,8 +216,6 @@ jobs: set -euo pipefail RUVNET_RELEASE_ASSETS="$(cat "$RUVNET_RELEASE_ASSETS_FILE")" export RUVNET_RELEASE_ASSETS - node scripts/release-authority.mjs - node scripts/release-proof.mjs --candidate "$RUVNET_CANDIDATE_RECEIPT" node scripts/release.mjs --publish - name: Require the machine-generated publication seal @@ -220,6 +228,7 @@ jobs: --publication release-evidence/publication-receipt.json - name: Preserve both append-only receipts + if: always() uses: actions/upload-artifact@v4 with: name: publication-evidence-${{ needs.release-qe-proof.outputs.candidate_sha }} diff --git a/.github/workflows/stranger-matrix.yml b/.github/workflows/stranger-matrix.yml index 741e8169..65f36d7b 100644 --- a/.github/workflows/stranger-matrix.yml +++ b/.github/workflows/stranger-matrix.yml @@ -41,11 +41,23 @@ name: stranger-matrix # named — "wait until the stdout-budget finding has a real fix" — is now discharged; what remains # is the admin action and the rest of items 2-9. on: - push: - branches: [main] - pull_request: - branches: [main] - workflow_dispatch: {} + workflow_run: + workflows: [ci] + types: [completed] + workflow_dispatch: + inputs: + release_qe_run_id: + description: Successful ci run containing the sealed candidate + required: true + type: string + +permissions: + actions: read + contents: read + +env: + CANDIDATE_RUN_ID: ${{ github.event.workflow_run.id || inputs.release_qe_run_id }} + CANDIDATE_SHA: ${{ github.event.workflow_run.head_sha || github.sha }} jobs: # ── shared prep, three POSIX-shaped jobs (ubuntu / macos share the exact same steps) ───────────── @@ -53,11 +65,18 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ env.CANDIDATE_SHA }} - uses: actions/setup-node@v4 with: node-version: '20' - - name: npm pack the candidate SHA - run: npm pack --silent --pack-destination "$RUNNER_TEMP" + - name: Download the one CI-sealed candidate + uses: actions/download-artifact@v4 + with: + github-token: ${{ github.token }} + run-id: ${{ env.CANDIDATE_RUN_ID }} + name: release-evidence-${{ env.CANDIDATE_SHA }} + path: ${{ runner.temp }} - name: npm install FROM the tarball into a fresh project (never the checkout) run: | mkdir -p "$RUNNER_TEMP/proj" @@ -90,11 +109,18 @@ jobs: runs-on: macos-latest steps: - uses: actions/checkout@v4 + with: + ref: ${{ env.CANDIDATE_SHA }} - uses: actions/setup-node@v4 with: node-version: '20' - - name: npm pack the candidate SHA - run: npm pack --silent --pack-destination "$RUNNER_TEMP" + - name: Download the one CI-sealed candidate + uses: actions/download-artifact@v4 + with: + github-token: ${{ github.token }} + run-id: ${{ env.CANDIDATE_RUN_ID }} + name: release-evidence-${{ env.CANDIDATE_SHA }} + path: ${{ runner.temp }} - name: npm install FROM the tarball into a fresh project (never the checkout) run: | mkdir -p "$RUNNER_TEMP/proj" @@ -134,11 +160,18 @@ jobs: shell: bash steps: - uses: actions/checkout@v4 + with: + ref: ${{ env.CANDIDATE_SHA }} - uses: actions/setup-node@v4 with: node-version: '20' - - name: npm pack the candidate SHA - run: npm pack --silent --pack-destination "$RUNNER_TEMP" + - name: Download the one CI-sealed candidate + uses: actions/download-artifact@v4 + with: + github-token: ${{ github.token }} + run-id: ${{ env.CANDIDATE_RUN_ID }} + name: release-evidence-${{ env.CANDIDATE_SHA }} + path: ${{ runner.temp }} - name: npm install FROM the tarball into a fresh project (never the checkout) run: | mkdir -p "$RUNNER_TEMP/proj" @@ -170,11 +203,18 @@ jobs: shell: pwsh steps: - uses: actions/checkout@v4 + with: + ref: ${{ env.CANDIDATE_SHA }} - uses: actions/setup-node@v4 with: node-version: '20' - - name: npm pack the candidate SHA - run: npm pack --silent --pack-destination "$env:RUNNER_TEMP" + - name: Download the one CI-sealed candidate + uses: actions/download-artifact@v4 + with: + github-token: ${{ github.token }} + run-id: ${{ env.CANDIDATE_RUN_ID }} + name: release-evidence-${{ env.CANDIDATE_SHA }} + path: ${{ runner.temp }} - name: npm install FROM the tarball into a fresh project (never the checkout) run: | New-Item -ItemType Directory -Force -Path "$env:RUNNER_TEMP/proj" | Out-Null @@ -233,12 +273,17 @@ jobs: - name: apk add zip/unzip ONLY — explicitly no jq, no gh run: apk add --no-cache zip unzip - uses: actions/checkout@v4 + with: + ref: ${{ env.CANDIDATE_SHA }} # No actions/setup-node here: the alpine image's own node/npm are what a musl-based hostile # box actually has; actions/setup-node downloads a glibc build that will not run on it. - - name: npm pack the candidate SHA - run: | - mkdir -p /tmp/ci-out - npm pack --silent --pack-destination /tmp/ci-out + - name: Download the one CI-sealed candidate + uses: actions/download-artifact@v4 + with: + github-token: ${{ github.token }} + run-id: ${{ env.CANDIDATE_RUN_ID }} + name: release-evidence-${{ env.CANDIDATE_SHA }} + path: /tmp/ci-out - name: npm install FROM the tarball into a fresh project (never the checkout) run: | mkdir -p /tmp/ci-out/proj diff --git a/README.md b/README.md index 3e8e3efc..2fea1d84 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ # 🧠 RuvNet Brain -### 🧠 RuvNet Brain — [![RuvNet Brain version 4.0.6 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.0.6-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json) +### 🧠 RuvNet Brain — [![RuvNet Brain version 4.0.7 — updated 2026-07-30 03:24 EDT](https://img.shields.io/badge/version_4.0.7-updated_2026--07--30_03:24_EDT-1E90FF?style=for-the-badge&labelColor=0757BA)](https://github.com/stuinfla/ruvnet-brain/blob/main/plugin/.claude-plugin/plugin.json) **A portable, source-grounded brain over Reuven Cohen's (rUv's) RuvNet stack — delivered as a Claude Code plugin that makes Claude _use_ the stack instead of fighting it.** @@ -247,11 +247,11 @@ Our code now does **one honest job**: a price transform. A model your subscripti Result: **11 jobs supervised, every one producing a fresh successful receipt.** One had been *totally blind* — writing zero bytes on a healthy day, so "ran fine" and "never ran" were indistinguishable. Cured without changing a line of its logic. -### 3. A subagent can no longer inherit your expensive model by accident +### 3. Inherited subagent models are now auditable **A subagent inherits your session's model unless something says otherwise.** Ten agents on an Opus session are ten Opus agents; on a Fable session that's `$10/$50` per Mtok — up to **10× what the same mechanical work costs on Haiku**. That single default was the biggest cost leak in the harness, and an advisory rule did not fix it (the router's entire first life saved **$0.018**). -So 2.5.1 makes it a **wall, not advice**: a `PreToolUse` gate that **blocks any subagent dispatch that doesn't declare a `model`**, tells you which tier the task actually needs, and logs every allowed dispatch so routing is *auditable* rather than merely claimed. Forks still inherit — that's what a fork is. +The hook records both explicit-model and inherited-model dispatches so routing is *auditable* rather than merely claimed. It does not claim to block Agent/Task calls: Claude Code 2.1.220 consumes those `PreToolUse` results only after `tool_dispatch_end` ([issue #84](https://github.com/stuinfla/ruvnet-brain/issues/84), [upstream #83195](https://github.com/anthropics/claude-code/issues/83195)). Forks still inherit — that's what a fork is. > **`npm run falsify`** — the adversary. Every question that had to be asked of this project ("is the nightly *actually* running?", "is that really rUv's code?", "why is my quota still burning?", "is CI *actually* green?") is now a check that fails on an **unproven claim**, not merely on broken code. Because tests you wrote yourself passing is circular evidence. diff --git a/SECURITY.md b/SECURITY.md index 3a052c94..c0a6b50c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -Updated: 2026-07-22 +Updated: 2026-08-02 Created: 2026-07-06 RuvNet Brain runs on your machine, downloads a knowledge bundle, and (with your consent) can update @@ -39,7 +39,7 @@ is every one of them, what it does, and whether it can block you: | `SessionStart` | `session-start.sh` | Prints the "brain active" confirmation, checks a handful of local state files (nightly-failure marker, brain-health flags), and — rate-limited to once per ~15 min — does a single read-only `curl` to check whether a newer plugin version exists. First-run-only, it asks two one-time yes/no questions (auto-update? anonymous usage counts?) and records your answer to a local file. | No — always exits 0. | | `UserPromptSubmit` | `ground-ruvnet.sh` | Reads your prompt text and, if it matches certain keyword patterns, injects grounding/context text before Claude answers (e.g. call `search_ruvnet` before asserting a stack capability). Also runs a rate-limited (~6 h) background version check against the public npm registry for a few packages. | No — always exits 0. | | `PreToolUse` (`Write`\|`Edit`\|`Bash`) | `hijack-ruvnet.sh` | Scans the content of a proposed Write/Edit/Bash for third-party defaults it thinks the rUv stack already has a native replacement for, and injects a suggestion via `additionalContext`. | No — `permissionDecision: "defer"`, never denies. | -| `PreToolUse` (`Task`) | `route-dispatch.sh` | If — and only if — you've opted into model-cost routing (a confirmed `~/.claude/model-router/profile.json` exists), blocks a subagent dispatch that doesn't declare an explicit `model`, so it can't silently inherit the calling session's (possibly expensive) model. A non-interactive install may write detected values with an explicit `assumed:` basis; that profile is inert until you confirm it with the router setup. Recovery: re-issue the same call with an explicit `model:` (`haiku` mechanical / `sonnet` analytical / `opus` judgment). Escape hatch when inheritance is genuinely correct: `RUVNET_ALLOW_INHERITED_MODEL=1`. | **Yes, but opt-in only.** No profile, or an assumption-only profile, means this hook is a no-op for you. | +| `PreToolUse` (`Task`\|`Agent`) | `route-dispatch.sh` | For users who opted into model-cost routing, records whether a subagent declared a model or inherited the caller's model. Claude Code 2.1.220 checks Agent/Task hook results after dispatch, so this is bounded, silent audit rather than a false refusal ([#84](https://github.com/stuinfla/ruvnet-brain/issues/84)). No profile, or an assumption-only profile, means the hook is a no-op. | **No.** Always exits 0; the host currently offers no synchronous input-dependent enforcement boundary for these tools. | | `PreToolUse` (`Bash`) | `verify-interface.sh` | For a confirmed router profile, legacy raw Bash that mentions a managed ecosystem CLI receives advisory context pointing to the structured `ruvnet_cli_help` → `ruvnet_cli_run` boundary. Issue #48 retired authorization decisions reconstructed from shell text; the structured MCP tools enforce the finite executable list, successful-help freshness, literal argv, and `shell:false`. | **No.** The legacy hook always exits 0 and is silent for absent or assumption-only profiles. | | `PreToolUse` (`Bash`) | `design-wall.sh` | **Gated to this repo, not opt-in-gated.** First resolves the git root and checks the plugin manifest's own `name` field (`design-wall.sh:48-56`); **on any other project it exits 0 immediately and does nothing.** Inside a ruvnet-brain checkout it blocks `git commit` when staged files include anything under `explainer/`, `console/`, or a `README.md`, blocks `vercel ... --prod`, and blocks `open`-ing a hardcoded list of this project's own URLs — unless a fresh (≤45 min) passing design-grade stamp exists. Escape hatch: `RUVNET_SKIP_DESIGN_WALL=1`. | **Yes — but only inside this repo.** It cannot block work in your own projects. (An earlier version of this row said it could match paths in *any* project; the repo-identity gate makes that false. Corrected 2026-07-22.) | | `PreToolUse` (`Write`\|`Edit`\|`MultiEdit`\|`NotebookEdit`) | `protect-brain-state.sh` | Blocks a write **only** to the brain's own on/off state: the sentinel `~/.config/ruvnet-brain/brain-off` and the settings mirror `~/.config/ruvnet-brain/settings.json` (plus their `.bak-*`/`.lock`/`.tmp-*` siblings). It exists so an agent cannot switch the brain back on — or off — behind your back; ADR-054 treats an agent-initiated flip as a consent violation. It reads only the target path, never file content, and touches nothing else. | **Yes — and this one is neither opt-in nor repo-scoped**, because a consent switch an agent can silently flip is not a switch. It matches those paths and nothing else, so it cannot block ordinary work. Added 2026-07-26 (v3.9.84). | diff --git a/bin/install.mjs b/bin/install.mjs index 5e15b4a9..ed90a6d0 100755 --- a/bin/install.mjs +++ b/bin/install.mjs @@ -24,6 +24,7 @@ import { requiredEmbedderModels, missingEmbedderModels, } from '../kb/model-requirements.mjs'; +import { mergeManagedCatalog } from '../scripts/model-router-catalog.mjs'; // SEC-0010 #6 — the Ed25519 PUBLIC key is EMBEDDED here (not a separate file) so the installer's // trust root travels with the installer code itself: an attacker who swaps the downloaded bundle @@ -616,6 +617,7 @@ export function beginConsoleRuntimeTransaction(cacheDir, sourceRoot = REPO_ROOT) ['console', 'console'], ['scripts', 'scripts'], ['plugin/scripts', 'plugin/scripts'], + ['data/model-catalog.json', 'data/model-catalog.json'], ['kb/brain-profile.mjs', 'kb/brain-profile.mjs'], ['bin/install.mjs', 'bin/install.mjs'], ['package.json', 'package.json'], @@ -642,6 +644,15 @@ export function beginConsoleRuntimeTransaction(cacheDir, sourceRoot = REPO_ROOT) fs.rmSync(staged, { recursive: true, force: true }); throw new Error(`console runtime version ${packageManifest.version || '(missing)'} does not match candidate ${PACKAGE_VERSION}`); } + try { + const providerCatalog = JSON.parse(fs.readFileSync(path.join(staged, 'data', 'model-catalog.json'), 'utf8')); + if (!providerCatalog.providers || typeof providerCatalog.providers !== 'object' || !Object.keys(providerCatalog.providers).length) { + throw new Error('providers object is missing or empty'); + } + } catch (error) { + fs.rmSync(staged, { recursive: true, force: true }); + throw new Error(`console runtime model-catalog is invalid: ${error.message}`); + } const stagedEntry = path.join(staged, 'scripts', 'onboarding-console.mjs'); for (const syntaxTarget of [stagedEntry, path.join(staged, 'bin', 'install.mjs')]) { const checked = spawnSync(process.execPath, ['--check', syntaxTarget], { encoding: 'utf8' }); @@ -791,7 +802,8 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false 'Wiring the Claude Code plugin', 'this registers search_ruvnet + the grounding hook so Claude uses the brain automatically', ); - const manualMarketplace = 'claude plugin marketplace add stuinfla/ruvnet-brain'; + const marketplaceSource = process.env.RUVNET_CLAUDE_MARKETPLACE_SOURCE || 'stuinfla/ruvnet-brain'; + const manualMarketplace = `claude plugin marketplace add ${marketplaceSource}`; const manualInstall = 'claude plugin install ruvnet-brain@ruvnet-brain --scope user'; if (!have('claude')) { @@ -809,7 +821,7 @@ function wirePlugin({ expectedVersion = PACKAGE_VERSION, requireManaged = false if (requireManaged && !before.managed) return { host: false, wired: false, action: 'unmanaged' }; const addedMarket = before.managed ? tryRun('claude', ['plugin', 'marketplace', 'update', 'ruvnet-brain']) - : tryRun('claude', ['plugin', 'marketplace', 'add', 'stuinfla/ruvnet-brain']); + : tryRun('claude', ['plugin', 'marketplace', 'add', marketplaceSource]); // Deliberately NOT reassuring here. This used to say "it may already be added — that's fine", // which is a GUESS about someone else's machine, and when it was wrong the user finished the // install with a working search_ruvnet, no slash commands, and a message telling them all was @@ -1118,11 +1130,27 @@ const CODEX_PLUGIN_ID = 'ruvnet-brain@ruvnet-brain'; const CODEX_MARKETPLACE = 'ruvnet-brain'; const CODEX_MARKETPLACE_SOURCE = 'stuinfla/ruvnet-brain'; -function prepareCodexMarketplace() { - const target = path.join( +function codexMarketplaceTarget() { + return path.join( process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'codex-marketplace', ); +} + +function codexMarketplaceReady(target = codexMarketplaceTarget()) { + try { + const manifest = JSON.parse(fs.readFileSync(path.join(target, '.claude-plugin', 'marketplace.json'), 'utf8')); + return manifest?.name === CODEX_MARKETPLACE + && Array.isArray(manifest.plugins) + && manifest.plugins.some((plugin) => plugin?.name === 'ruvnet-brain' && plugin?.source === './plugin') + && fs.existsSync(path.join(target, 'plugin', '.codex-plugin', 'plugin.json')); + } catch { + return false; + } +} + +function prepareCodexMarketplace() { + const target = codexMarketplaceTarget(); const staged = `${target}.tmp-${process.pid}`; fs.rmSync(staged, { recursive: true, force: true }); fs.mkdirSync(path.join(staged, '.claude-plugin'), { recursive: true }); @@ -1199,6 +1227,18 @@ export function wireCodexPlugin({ } = {}) { if (!fs.existsSync(codexDir)) return { host: false, action: 'no-host' }; const options = { codexBin, codexHome, cwd }; + // Codex loads every configured marketplace before it can report plugin state. Repair only an + // absent/malformed Brain-owned snapshot before that probe, or the probe can fail before its own + // repair path is reachable. A healthy current cache stays byte-untouched, and plugin enablement + // is still read and preserved below. + let localMarketplace = null; + if (runJson === runCodexJson && !codexMarketplaceReady()) { + try { localMarketplace = prepareCodexMarketplace(); } + catch (error) { + if (announce) warn(`Codex marketplace preparation failed — ${error.message}`); + return { host: true, action: 'marketplace-prepare-failed', error: error.message }; + } + } const before = codexPluginStatus({ ...options, runJson }); if (!before.available) { if (announce) warn(`Codex plugin lifecycle not installed — ${before.error}`); @@ -1213,7 +1253,13 @@ export function wireCodexPlugin({ return { host: true, action: 'unchanged', ...before }; } - const localMarketplace = runJson === runCodexJson ? prepareCodexMarketplace() : null; + if (runJson === runCodexJson && !localMarketplace) { + try { localMarketplace = prepareCodexMarketplace(); } + catch (error) { + if (announce) warn(`Codex marketplace preparation failed — ${error.message}`); + return { host: true, action: 'marketplace-prepare-failed', error: error.message }; + } + } const markets = runJson(['plugin', 'marketplace', 'list', '--json'], options); if (!markets.ok) { if (announce) warn(`Codex marketplace check failed — ${markets.error}`); @@ -1728,6 +1774,21 @@ async function doctor() { warn('brain not found here — run the installer first: npx ruvnet-brain'); return 1; // "not installed" is a FAILING doctor, not a neutral one } + const convergencePath = path.join(process.env.RUVNET_BRAIN_HOME || path.join(os.homedir(), '.cache', 'ruvnet-brain'), 'host-convergence.json'); + let hostConvergence = { healthy: true, state: 'not-recorded' }; + if (fs.existsSync(convergencePath)) { + try { + hostConvergence = classifyHostConvergence(JSON.parse(fs.readFileSync(convergencePath, 'utf8'))); + if (hostConvergence.healthy) ok(`host convergence receipt: ${hostConvergence.state}`); + else { + warn(`host convergence incomplete: ${hostConvergence.state}`); + info(`Retry the same generation: ${c.bold('npx ruvnet-brain --update')}${hostConvergence.action ? `; ${hostConvergence.action}` : ''}`); + } + } catch (error) { + hostConvergence = { healthy: false, state: 'invalid-receipt', error: error.message }; + warn(`host convergence receipt is invalid: ${error.message}`); + } + } have('node') ? ok('node present') : warn('node missing'); have('npm') ? ok('npm present') : warn('npm missing'); have('claude') ? ok('claude CLI present') : warn('claude CLI missing (plugin wiring needs it)'); @@ -1908,6 +1969,7 @@ async function doctor() { || codexLifecycleFailed || codexWiringFailed || codexReadinessFailed + || !hostConvergence.healthy || Boolean(rufloOperational && !rufloOperational.healthy); if (failed && !hookResult && !groundingUnprovenPersisted) { console.log(` ${c.red('✗ FAILING')} — the warnings above are real. Re-run ${c.bold('npx ruvnet-brain')} to repair.`); @@ -2243,7 +2305,29 @@ export function syncHostsAfterUpdate(cacheDir = resolvedKbDir(), { } } if (!okApplied) return fail({ applyStatus: applied.status, error: applied.error?.message || 'Stable Spine activation failed' }); - return { ok: true, results, applyStatus: applied.status }; + const convergence = classifyHostConvergence({ + desiredVersion: PACKAGE_VERSION, + hosts: { + claude: { state: results.claude.host ? 'ready' : 'absent', version: results.claude.version || null }, + codex: { state: results.codex?.action === 'disabled' ? 'disabled' : (results.codexHost?.host ? 'ready' : 'absent'), version: results.codex?.version || null }, + }, + consoleRuntime: results.consoleRuntime, + }); + return { ok: true, convergence, results, applyStatus: applied.status }; +} + +export function classifyHostConvergence(receipt, expectedVersion = PACKAGE_VERSION) { + if (!receipt || receipt.desiredVersion !== expectedVersion) { + return { healthy: false, state: 'version-mismatch', action: `required version ${expectedVersion}` }; + } + const hostStates = Object.values(receipt.hosts || {}); + const badHost = hostStates.find((host) => !['ready', 'disabled', 'absent'].includes(host?.state) + || (host.state === 'ready' && host.version !== expectedVersion)); + if (badHost) return { healthy: false, state: 'host-pending', action: 're-run host synchronization' }; + if (receipt.consoleRuntime?.state !== 'ready') { + return { healthy: false, state: receipt.consoleRuntime?.state || 'console-unproven', action: 'restart Console, then re-run --doctor' }; + } + return { healthy: true, state: 'channels-converged' }; } function runUpdate() { @@ -2890,7 +2974,28 @@ export async function offerRouterProfile() { for (const [src, dst] of [['catalog.template.json', 'catalog.json'], ['policy.default.mjs', 'policy.default.mjs']]) { const s = path.join(pkgRoot, 'config', 'model-router', src); const d = path.join(routerDir, dst); - if (fs.existsSync(s) && !fs.existsSync(d)) { fs.copyFileSync(s, d); ok(`installed ${dst} (edit freely — goldie keeps prices fresh where scheduled)`); } + if (!fs.existsSync(s)) continue; + if (!fs.existsSync(d)) { + fs.copyFileSync(s, d); + ok(`installed ${dst} (edit freely — goldie keeps prices fresh where scheduled)`); + continue; + } + if (src === 'catalog.template.json') { + try { + const existing = JSON.parse(fs.readFileSync(d, 'utf8')); + const managed = JSON.parse(fs.readFileSync(s, 'utf8')); + const merged = mergeManagedCatalog(existing, managed); + if (merged !== existing) { + fs.copyFileSync(d, `${d}.pre-managed-merge`); + const tmp = `${d}.tmp-${process.pid}`; + fs.writeFileSync(tmp, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(tmp, d); + ok(`merged ${merged.candidates.length - existing.candidates.length} managed subscription model(s); your catalog overrides were preserved`); + } + } catch (error) { + warn(`managed model additions were not merged (${error.message}); your existing catalog was left unchanged`); + } + } } let copied = 0; // dispatch-receipt + metaharness-receipts added 2026-07-13: without the LOGGER, subagent routing is diff --git a/config/model-router/catalog.template.json b/config/model-router/catalog.template.json index c25ad3f6..3a49abb7 100644 --- a/config/model-router/catalog.template.json +++ b/config/model-router/catalog.template.json @@ -1,6 +1,7 @@ { "_comment": "Model candidate catalog for model-router-engine.mjs. EDIT ME freely. Pricing is $/Mtok. 'verified' names the source+date a price was confirmed live; null pricing means UNKNOWN \u2014 the engine will NOT invent a cost for it (same honesty rule as route-cheap.mjs). 'harness' = which agent(s) can launch each model \u2014 EMPTY [] means landscape-only: known to exist, but no wired execution path yet, so the engine will never select it (see model-router-engine.mjs pool filter). Do not add a real execution path (an entry to route-cheap.mjs PRICING or a harness value) until dispatch is actually implemented and tested \u2014 an engine that 'chooses' a model it can't run is worse than one that doesn't know the model exists. 'subscription' = harness(es) under which this model is covered by a flat subscription (Claude Max, Codex/ChatGPT), so its marginal cost to the user is ~$0 \u2014 the default policy treats those as free and will NOT prefer a billed model over them (never spend where the subscription is free).", - "updated": "2026-07-12 (template)", + "managedVersion": 2, + "updated": "2026-08-02 (managed template)", "candidates": [ { "id": "claude-haiku-4-5-20251001", @@ -45,6 +46,20 @@ }, "verified": "2026-07-07 route-cheap.mjs FRONTIER (API price; $0 under Max)" }, + { + "id": "claude-opus-5", + "provider": "anthropic", + "harness": [ + "claude-code" + ], + "subscription": [ + "claude-code" + ], + "tier": "frontier", + "costPerMTok": null, + "verified": "2026-08-02 Claude Code 2.1.220 subscription launch (canonicalModel claude-opus-5)", + "note": "Launchability is host-scoped. The managed row adds the reviewed candidate; the user's subscription profile still decides whether it is available and covered." + }, { "id": "claude-fable-5", "provider": "anthropic", @@ -217,4 +232,4 @@ "note": "LANDSCAPE-ONLY: 295B MoE Apache-2.0, strong generalist, second-tier coder (SWE-bench V 78.0 vs GLM-5.2 84.2, vendor numbers). IMPORTANT: this is the PAID slug on purpose \u2014 the tencent/hy3:free promo window closes ~2026-07-20 and would silently die/bill; never wire the :free slug. See goldie/2026-07-12.md Q3." } ] -} \ No newline at end of file +} diff --git a/console/app.js b/console/app.js index db269cb4..4185fa9d 100644 --- a/console/app.js +++ b/console/app.js @@ -2099,17 +2099,24 @@ function renderRouterEngine(re) { const pool = Array.isArray(re.pool) ? re.pool : []; const TIER_ORDER = { mechanical: 0, cheap: 1, mid: 2, frontier: 3 }; const byTier = (a, b) => (TIER_ORDER[a.tier] ?? 9) - (TIER_ORDER[b.tier] ?? 9); - const lensTable = (rows, costOf, costHead) => el('div', { class: 'scroll-x' }, + const recommended = (Array.isArray(re.decisions) ? re.decisions : []).find((decision) => decision && decision.model); + const lensTable = (rows, costOf, costHead, { showRouting = false } = {}) => el('div', { class: 'scroll-x' }, el('table', { class: 'tb rp-tb' }, el('thead', {}, el('tr', {}, el('th', { scope: 'col' }, 'Bucket'), el('th', { scope: 'col' }, 'Model'), - el('th', { scope: 'col' }, costHead))), + el('th', { scope: 'col' }, costHead), + showRouting ? el('th', { scope: 'col' }, 'Routing') : null)), el('tbody', {}, rows.map((p) => el('tr', {}, el('td', { class: 'rp-band' }, p.tier || '—'), el('td', {}, el('div', { class: 'rp-model' }, prettyModel(p.id))), el('td', { class: 'cell-mono num' }, costOf(p)), + showRouting ? el('td', {}, recommended?.model === p.id + ? chip('last selected', 'green', recommended.reason || 'The latest router receipt selected this model.') + : 'available') : null, ))))); - // Development: only what this machine's harness can launch; best (cheapest-marginal) per bucket. + // Development is an inventory, not a recommendation filter. The previous one-row-per-tier + // projection hid equal-cost Fable behind Opus based solely on array order. Every launchable row + // remains visible; the latest actual routing receipt is a separate marker. const bestPerTier = (rows, price) => { const seen = {}; for (const p of rows) { @@ -2118,9 +2125,9 @@ function renderRouterEngine(re) { } return Object.values(seen).sort(byTier); }; - const devRows = bestPerTier( - pool.filter((p) => (p.harness || []).includes('claude-code')), - (p) => (p.subscriptionCovered ? -1 : p.marginalPerMTok ?? Infinity)); + const devRows = pool + .filter((p) => (p.harness || []).includes('claude-code')) + .sort((a, b) => byTier(a, b) || String(a.id).localeCompare(String(b.id))); const prodRows = bestPerTier( pool.filter((p) => p.listPerMTok != null && p.provider !== 'local'), (p) => p.listPerMTok ?? Infinity); @@ -2130,7 +2137,7 @@ function renderRouterEngine(re) { el('span', { class: 'rp-obj' }, 'you, in Claude Code — models your plan covers win at $0 marginal')), lensTable(devRows, (p) => (p.subscriptionCovered ? el('b', { title: 'covered by your subscription — zero marginal cost' }, '$0 · yours') : money(p.marginalPerMTok)), - 'Your cost')); + 'Your cost', { showRouting: true })); const prodBlock = el('div', { class: 'rp-profile' }, el('div', { class: 'rp-head' }, el('span', { class: 'rp-name' }, 'Production'), @@ -2140,7 +2147,7 @@ function renderRouterEngine(re) { const poolFoot = el('p', { class: 'fineprint' }, re.catalogSource === 'built-in-fallback' ? `No personal catalog found — showing a minimal built-in set of ${pool.length}. Run \`node scripts/model-router-setup.mjs\` to build your real catalog, then the engine weighs yours on every call.` - : `Best pick per bucket shown; the engine weighs all ${pool.length} candidates in its catalog on every call — nothing is retired by being off this summary.`); + : `All ${devRows.length} launchable Claude Code models are shown. The engine weighs all ${pool.length} candidates; the routing marker comes from its latest receipt, never catalog order.`); // Decisions: dedupe consecutive identical picks, keep 3, humanize the reason head. The full // append-only log stays on disk — this is a pulse, not a table of record. @@ -4011,6 +4018,7 @@ function renderLessons(data) { if (c.awaitingYou) chips.push(chip(`${c.awaitingYou} awaiting you`, 'amber', 'Recorded, but not yet agreed to by you. Until you decide, it does not enforce at full strength.')); if (c.active) chips.push(chip(`${c.active} on`, 'green')); if (c.off) chips.push(chip(`${c.off} off`, 'grey', 'Switched off by you. The record of where you taught it is kept.')); + if (c.quarantined) chips.push(chip(`${c.quarantined} imported`, 'nt', 'Maintainer or demonstration history. It is visible for audit but cannot become your personal policy.')); if (c.blocking) chips.push(chip(`${c.blocking} can stop me`, 'cyan', 'These interrupt me at their moment and I cannot continue until the check passes.')); setChips('chips-lessons', chips); @@ -4022,6 +4030,7 @@ function renderLessons(data) { const box = el('input', { type: 'checkbox', class: 'lesson-switch', id: `lsw-${r.id}`, 'aria-label': `${isOn ? 'Turn off' : 'Turn on'}: ${r.statement.slice(0, 60)}`, + disabled: r.quarantined || null, }); box.checked = isOn; @@ -4073,7 +4082,9 @@ function renderLessons(data) { chip(r.origin, r.userStated ? 'green' : 'nt', r.userStated ? 'You said this. Only lessons you stated yourself are allowed to reach the strongest level.' - : 'I inferred this from what happened. A lesson I inferred can never be raised to "Stops me", however often it fires — the model does not get to ratify its own rules.'), + : r.quarantined + ? 'This came from bundled maintainer or demonstration history. It is not your statement and cannot be turned into your personal policy.' + : 'I inferred this from what happened. A lesson I inferred can never be raised to "Stops me", however often it fires — the model does not get to ratify its own rules.'), r.taughtCount ? chip(`taught ${r.taughtCount}×`, 'grey') : null, r.awaitingYou ? chip('awaiting your decision', 'amber', 'Recorded, but you have not agreed to it yet.') : null, ].filter(Boolean); @@ -4097,9 +4108,9 @@ function renderLessons(data) { : null, r.projects && r.projects.length ? el('p', null, el('strong', null, 'Learned in: '), r.projects.join(', ')) : null, - el('p', { class: 'muted' }, - 'Turning this off hides the rule without deleting the record of where you taught it — ', - 'you can switch it back on here at any time.'))); + el('p', { class: 'muted' }, r.quarantined + ? 'This imported record is quarantined for audit. It cannot be switched on or ratified as your policy.' + : 'Turning this off hides the rule without deleting the record of where you taught it — you can switch it back on here at any time.'))); list.append(el('div', { class: `cap-row lesson-row${r.demoted ? ' is-off' : ''}` }, // NO `for=` here. The label WRAPS its checkbox, which is already an implicit association; a @@ -4142,9 +4153,10 @@ function partitionLessons(list, rows) { // the card whose job is to be the truth about it, and printed BECAUSE they used the control we // gave them. Found by GPT-5.6-Sol, 2026-07-24. A group's heading must be derivable from the state // of the rows inside it, which is why the counts below are computed from the split, never passed in. - const asks = [], inForce = [], off = []; + const asks = [], inForce = [], off = [], quarantined = []; rows.forEach((r, i) => { - if (r.demoted) off.push(kids[i]); + if (r.quarantined) quarantined.push(kids[i]); + else if (r.demoted) off.push(kids[i]); else if (r.awaitingYou) asks.push(kids[i]); else inForce.push(kids[i]); }); @@ -4171,6 +4183,12 @@ function partitionLessons(list, rows) { `${off.length} ${off.length === 1 ? 'rule you switched off' : 'rules you switched off'} — not in force; switch back on any time`), el('div', { class: 'cap-list' }, ...off))); } + if (quarantined.length) { + out.push(el('details', { class: 'lessons-more' }, + el('summary', null, + `${quarantined.length} imported maintainer or demonstration ${quarantined.length === 1 ? 'record' : 'records'} — quarantined, not your policy`), + el('div', { class: 'cap-list' }, ...quarantined))); + } return out; } diff --git a/console/architecture.html b/console/architecture.html index 0e756d24..3c743e37 100644 --- a/console/architecture.html +++ b/console/architecture.html @@ -749,7 +749,7 @@

3 · The hooks

SessionStartsession-start.shnudgeTells you the brain is on. Runs 4 pure-filesystem health checks in under 5 ms — no cache dir? no .rvf at all? reader deps gone? last real search failed? Injects the capability playbook once per session. UserPromptSubmitground-ruvnet.shnudgeThree gates on your prompt: RuvNet (you named the stack → ground before asserting), drift (you reached for a classical default → name the rUv replacement), build (a build request → apply the playbook). PreToolUseWrite · Edit · Bashhijack-ruvnet.shnudgeScans the payload for four categories of classical default — generic vector stores, paid embedding APIs, generic RAG frameworks, hand-rolled memory glue — and names the rUv replacement. Never blocks. One word on line 12 would make it block; it ships as advisory because a false-positive refusal bricks real work. - PreToolUseTaskroute-dispatch.shblocksRefuses a subagent dispatch that would silently inherit your expensive main-loop model. Opt-in only — with no router profile on disk it does nothing, not even warn. + PreToolUseTask · Agentroute-dispatch.shauditRecords declared versus inherited model use. Claude Code 2.1.220 consumes this result after dispatch, so it never claims a late refusal. Opt-in only — with no router profile on disk it does nothing. PreToolUseBashverify-interface.shblocksRefuses an ecosystem CLI subcommand whose --help you have not read in 24 h. Born from reporting AgentDB broken three times when the real defect was passing a query positionally instead of with -q. PreToolUseBashdesign-wall.shblocksRefuses shipping or opening a visual surface with no fresh design grade. Scoped to this repo since issue #17, after a plain git commit in an unrelated project got blocked demanding someone else's ritual. PreToolUse · Stoplesson-hooks.shnudgeSurfaces your own recorded corrections at the moment they apply — writing code, changing the machine, asserting a fact, claiming done. diff --git a/data/manifest.json b/data/manifest.json index 1e275cb2..32362ada 100644 --- a/data/manifest.json +++ b/data/manifest.json @@ -1,5 +1,5 @@ { - "brainVersion": "4.0.6", + "brainVersion": "4.0.7", "generated": "2026-07-30T07:24:51.903Z", "generatedHuman": "Thu, 30 Jul 2026 07:24:51 GMT", "coverage": { diff --git a/docs/ARCHITECTURE-MAP.md b/docs/ARCHITECTURE-MAP.md index 7385263a..21aa6d4d 100644 --- a/docs/ARCHITECTURE-MAP.md +++ b/docs/ARCHITECTURE-MAP.md @@ -1,4 +1,4 @@ -Updated: 2026-07-28 21:50:00 EDT | Version 1.0.1 +Updated: 2026-08-02 18:25:00 EDT | Version 1.0.2 Created: 2026-07-22 11:05:00 EDT # The Architecture Map — what the pieces ARE, and what you lose if you take only some @@ -212,7 +212,7 @@ Six Claude Code events, 14 invocations, 10 distinct scripts. All but two route t | UserPromptSubmit | `ground-ruvnet.sh` | advisory | Three independent gates on your prompt text: **RUVNET** (you named the stack → ground before asserting), **DRIFT** (you reached for a classical default → name the rUv replacement), **BUILD** (a build request → apply the playbook). Plus an always-on status footer. | | UserPromptSubmit | `lesson-hooks.sh assert-fact/recommend-architecture` | advisory | Surfaces your own recorded corrections that apply to stating a fact or proposing an architecture. | | PreToolUse `Write\|Edit\|Bash` | `hijack-ruvnet.sh` | advisory (`permissionDecision: defer`) | Scans the payload for four categories of classical default — vector stores (`pinecone\|pgvector\|chroma\|weaviate\|faiss\|milvus\|qdrant\|hnswlib\|annoy`), paid embedding APIs, RAG/agent frameworks (`langchain\|llamaindex\|autogen\|crewai\|semantic-kernel`), memory glue (`mem0\|zep\|redis+memory`) — and injects the rUv replacement. **Never blocks.** `DECISION="defer"` is on line 12 and a one-word edit makes it `deny`; it ships as `defer` because a false-positive deny would brick legitimate work. | -| PreToolUse `Task` | `route-dispatch.sh` | **blocking** | Refuses a subagent dispatch that would silently inherit your main-loop model. **Opt-in only**: no `~/.claude/model-router/profile.json` → the hook does nothing, not even a warning. | +| PreToolUse `Task\|Agent` | `route-dispatch.sh` | advisory audit | Records declared versus inherited model use. Claude Code 2.1.220 consumes this hook after dispatch, so it always exits 0 and never claims a late refusal. **Opt-in only**: no `~/.claude/model-router/profile.json` → no receipt. | | PreToolUse `Bash` | `verify-interface.sh` | advisory | Points legacy raw-shell callers to the structured `ruvnet_cli_help` → `ruvnet_cli_run` boundary. It never blocks: issue #48 retired authorization decisions derived from reconstructed shell structure. | | PreToolUse `Bash` | `design-wall.sh` | **blocking** | Refuses shipping/committing/opening a visual surface without a fresh design-grade stamp. **Repo-scoped since issue #17** — it checks the plugin manifest's own name and stays silent everywhere else, after a plain `git commit` in an unrelated project got blocked demanding a ruvnet-brain ritual. | | PreToolUse `Write\|Edit\|MultiEdit` / `Bash` | `lesson-hooks.sh write-code` / `mutate-machine` | advisory | Your recorded corrections for writing code / changing the machine. | diff --git a/docs/CONVENTIONS-AUDIT.md b/docs/CONVENTIONS-AUDIT.md index 8d4e484c..ecde8f1f 100644 --- a/docs/CONVENTIONS-AUDIT.md +++ b/docs/CONVENTIONS-AUDIT.md @@ -1,7 +1,7 @@ # The Conventions Audit — every rule in this repo, and whether anything enforces it Created: 2026-07-22 -Updated: 2026-07-28 — issue #48 structured CLI boundary and raw-shell demotion +Updated: 2026-08-02 — issue #84 truthful Agent/Task hook timing semantics Why: The owner, 2026-07-22 — *"There are dozens of things like this that are the difference between you acting as a smart learning partner and somebody that needs to constantly be reminded of everything all the time."* This document is the enumeration of "things like this." It exists because @@ -96,8 +96,8 @@ Eight enforcement surfaces exist. This is the honest denominator for everything | 2 | **CI `ci.yml` / windows-unit** | full `tests/unit` on win32 | hard red | cross-platform regressions | | 3 | **`scripts/git-hooks/pre-push`** | secret scan + `verify-channels --pre-push` | refuses push | live API keys, channel drift | | 4 | **`.claude/settings.json` PreToolUse:Bash** | `version-bump-gate.sh` | exit 2 | every push carries a version bump | -| 5 | **`~/.claude/settings.json` PreToolUse** | `route-dispatch.sh`, `ground-before-write.sh`; `verify-interface.sh` advisory | exit 2 only from the two walls | un-routed subagent fan-out and ungrounded rUv-domain writes; legacy raw-shell CLI calls receive migration guidance only | -| 6 | **`plugin/hooks/hooks.json`** | hooks via `hook-shim.mjs` | 4 blocking | `route-dispatch`, `design-wall`, `unprompted-speech`, and `protect-state`; interface guidance is advisory | +| 5 | **`~/.claude/settings.json` PreToolUse** | `ground-before-write.sh`; `verify-interface.sh` advisory | exit 2 only from the grounding wall | ungrounded rUv-domain writes; legacy raw-shell CLI calls receive migration guidance only | +| 6 | **`plugin/hooks/hooks.json`** | hooks via `hook-shim.mjs` | 3 blocking | `design-wall`, `unprompted-speech`, and `protect-state`; route-dispatch and interface guidance are advisory | | 7 | **`scripts/release.mjs`** | gates A–E | aborts ship | both suites, narrative version, clean tree, live channel walk | | 8 | **`Stop` hook** | `continuation-gate.mjs` | advisory (exit 0) | unfinished authorized work | diff --git a/docs/INTELLIGENT-UPDATING.md b/docs/INTELLIGENT-UPDATING.md index a65353e0..3c31000e 100644 --- a/docs/INTELLIGENT-UPDATING.md +++ b/docs/INTELLIGENT-UPDATING.md @@ -1,4 +1,4 @@ -Updated: 2026-07-18 11:20:00 EDT | Version 2.0.0 +Updated: 2026-08-02 18:25:00 EDT | Version 2.0.1 Created: 2026-07-18 10:55:00 EDT # Intelligent Updating — how RuvNet Brain stays current without ever trapping you @@ -83,7 +83,7 @@ one Node shim with an explicit table: ``` command: node "${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs" ground-ruvnet (advisory entries keep `|| true`) -command: node "${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs" route-dispatch (blocking entry — NO `|| true`) +command: node "${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs" route-dispatch || true (advisory on the host's async Agent/Task boundary) ``` `hook-shim.mjs` (part of the frozen shell, designed to never need changing) carries a dispatch diff --git a/docs/adr/0008-autonomous-engineering-loop.md b/docs/adr/0008-autonomous-engineering-loop.md index 15848612..1944c089 100644 --- a/docs/adr/0008-autonomous-engineering-loop.md +++ b/docs/adr/0008-autonomous-engineering-loop.md @@ -2,13 +2,20 @@ id: ADR-008 status: Accepted date: 2026-06-28 -updated: 2026-07-27 +updated: 2026-08-02 updated_source: derived-from-git --- # ADR-0008: The autonomous RuvNet-native engineering loop (the build product on top of the brain) **Status**: Accepted (2026-06-28) -**Updated**: 2026-07-09 — the loop CONTRACT is now implemented via ADR-0011 Phase 1: autonomy gate in ground-ruvnet.sh (no-halt override, resume-first, hard fence) + scripts/loop-checkpoint.mjs (machine-checkable done-criteria, two-strike no-progress, atomic checkpoints). Full autonomous build-test-score loop remains open. +**Updated**: 2026-08-02 — automatic parallel work now has one enforceable lifecycle transition on +Claude Code: the full shared task ledger is created before spawning, execution fills available host +slots, and a synchronous `TeammateIdle` hook refuses idle while an unassigned, dependency-ready task +exists. The hook reads Claude's ledger without mutating it; Claude's locked `TaskUpdate` performs the +claim. Initial decomposition/fan-out remains model-directed. Codex 0.146.0 has no `TeammateIdle` or +`TaskCompleted` hook or equivalent shared-task hook ledger, so Codex recycling remains explicit +lead guidance and must not be reported as enforced. The full autonomous build-test-score loop remains +open. **Date**: 2026-06-28 **Origin:** Stuart's "real definition of success" (2026-06-28) — the brain @@ -65,7 +72,12 @@ Ruflo + RuVector/AgentDB + hooks + an autonomous build-verify skill into a singl source so the agent reasons *from* truth); `PreToolUse` hard-deny (block pgvector/pinecone/chroma/weaviate deps + hand-rolled cosine/JSON-embeddings when an RVF path exists); `pre-task` auto-spawn for complex tasks; `Stop` semantic judge (re-open once if a RuvNet capability is dismissed without a citation). Grounding/ - routing become **structural, not optional**. Drift is measured against the ADR-0005 SLO each release. + routing become **structural, not optional**. For multi-part work, create the complete dependency + ledger before spawning and saturate only the native host's available executor capacity. Claude + Code's `TeammateIdle` boundary enforces the completion → next-ready-task transition without + editing the host ledger. Codex currently provides guidance only for that transition; its manifest + deliberately does not claim or register unsupported events. Drift is measured against the + ADR-0005 SLO each release. 5. **Auto-visuals as a build step, not an afterthought.** The Completion phase invokes image generation (`gen-images.mjs`, gpt-image-1) + the frontend-design discipline to produce the explaining web page / diff --git a/docs/adr/0013-onboarding-console.md b/docs/adr/0013-onboarding-console.md index cc7b85d2..9ce87d02 100644 --- a/docs/adr/0013-onboarding-console.md +++ b/docs/adr/0013-onboarding-console.md @@ -3,7 +3,7 @@ id: ADR-013 title: The Onboarding Console — RuvNet Brain becomes a mirror, an advisor, and only then a configurator status: Implemented date: 2026-07-14 -updated: 2026-08-01 +updated: 2026-08-02 updated_source: derived-from-git authors: [Stuart Kerr, Claude Code] tags: [onboarding, ux, config, stack, memory-health, savings, safety] @@ -34,7 +34,21 @@ for subsequent reuse. The issue #79 transaction now stages and syntax-verifies ` before host activation, persists its exact version/source identity, activates it only after host and Stable Spine convergence, and restores the prior runtime if activation or receipt finalization fails. The convergence receipt reports `pending-console-restart` when an owned instance still serves older -bytes; the next launch uses the receipt/token lifecycle above to replace it safely. +bytes; the next launch uses the receipt/token lifecycle above to replace it safely. The running +Console reads its version from that staged `runtime-identity.json` before consulting any host plugin +registry, so a Codex-only installation has a valid ownership receipt and can replace stale bytes on +the same URL. Real-browser acceptance builds both candidates through the installer's canonical +runtime transaction rather than a parallel hand-maintained fixture manifest. + +**Updated 2026-08-02** — Console integrity issues #83, #85, #86, and #87 make four trust boundaries +explicit. Bundled maintainer lessons carry imported-owner provenance, legacy fingerprints are +quarantined, and imported/demonstration rows cannot become personal policy. Compaction survival now +requires a fresh structurally valid versioned snapshot (with validated Ruflo legacy migration paths), +not arbitrary file existence. The packed and staged Console runtime must contain a valid provider +catalog; native boolean provider detection remains visible with an explicit degraded state if that +catalog cannot load. Finally, normal installs add reviewed subscription-covered managed models to an +existing user catalog without overwriting user rows or adding metered authority, while the Console +shows the complete launchable development inventory and marks the latest routing receipt separately. **Updated 2026-08-01** — issue #81 identified the residual project-discovery split left after issue #19. `scripts/memory-doctor.mjs` now owns the common and configured candidate-root policy, canonical diff --git a/docs/adr/0049-console-rebuild-explainers-scope-checkboxes.md b/docs/adr/0049-console-rebuild-explainers-scope-checkboxes.md index 688a2baf..b7c6b662 100644 --- a/docs/adr/0049-console-rebuild-explainers-scope-checkboxes.md +++ b/docs/adr/0049-console-rebuild-explainers-scope-checkboxes.md @@ -114,6 +114,7 @@ project the data is about. A cross-project isolation test proves it, mutation-ch | Date | What changed | Why (with referents) | |---|---|---| +| 2026-08-02 | Re-read the final 4.0.7 Console and installer changes; the explainer, scoped recommendations, evidence-backed controls, project cache, and profile decisions remain unchanged. | Commits `3668b1b`, `5a638f6`, and `78e897b` add runtime provenance, owner-only issue inventory, provider availability, and a remotely durable release transaction through `console/app.js`, `scripts/onboarding-console.mjs`, and `bin/install.mjs`. Those changes expose state and protect delivery; they do not broaden recommendation scope, consent, Fix All, undo, cache, or profile semantics. Exact-SHA CI and public-artifact proof remain release gates. | | 2026-08-02 | Re-read the Console timing receipt, explicit fixture-root boundary, and dual-host update-test seam. The explainer, per-recommendation scope, evidence-backed checkbox, project-keyed cache, and two-profile decisions remain unchanged. | `scripts/onboarding-console.mjs` now returns aggregate `revalidationMs`, `undoJournalMs`, `childRemedyMs`, and `totalMs` from the existing revalidate → journal → remedy path; it does not change recommendation construction, scope, consent, or undo. Commit `8f06287` adds the absolute, normalized `RUVNET_CONSOLE_ROOT` fixture boundary with the production default still `os.homedir()`; global binaries and credentials remain on the system home. Commit `c1f5b45` makes `bin/install.mjs` host-update collaborators injectable for the dual-host matrix while retaining its production defaults. | | 2026-08-01 | Completed issue #79's installer/update transaction for the persistent Console runtime. | `bin/install.mjs` now stages and syntax-checks the exact candidate runtime, records `runtime-identity.json`, activates only after host/Stable-Spine convergence, rolls back on failure, and binds the identity plus `ready` or `pending-console-restart` into `host-convergence.json`. This changes runtime delivery, not the explainer, recommendation-scope, consent, cache, or profile decisions governed here. | | 2026-08-01 | Re-read the governed Console and installer changes on the clean integration candidate. The explainer, recommendation-scope, evidence-backed checkbox, and project-keyed cache decisions remain unchanged. | `scripts/onboarding-console.mjs` now identifies a running Console by a private scoped receipt plus `/api/runtime` identity before reuse or owned replacement; it does not alter recommendation rendering, scope, apply, or cache semantics. `bin/install.mjs` now delegates What's New to the installed payload's `plugin/scripts/whats-new.mjs`; it does not change Console controls or install profiles. Focused candidate tests exist for both changes, but exact-SHA cross-platform and published-artifact proof remain outside this currency review. | diff --git a/docs/adr/0050-issue-pipeline-cannot-silence-itself.md b/docs/adr/0050-issue-pipeline-cannot-silence-itself.md index 7976fff0..26ca7347 100644 --- a/docs/adr/0050-issue-pipeline-cannot-silence-itself.md +++ b/docs/adr/0050-issue-pipeline-cannot-silence-itself.md @@ -3,7 +3,7 @@ id: ADR-050 title: The issue pipeline may never manufacture its own acknowledgment — awareness, escalation, and a fixer that knows when to stop status: Accepted date: 2026-07-24 -updated: 2026-08-01 +updated: 2026-08-02 impl: wired authors: [Stuart Kerr, Claude Code] tags: [issues, automation, alerting, sla, security, circuit-breaker] @@ -12,7 +12,7 @@ relates: [ADR-049] governs: - scripts/issue-watch.mjs - scripts/issue-fix.mjs - - plugin/scripts/session-start.sh + - plugin/scripts/session-start-core.mjs - plugin/skills/ruvnet-brain/SKILL.md - plugin/skills/release-proof/SKILL.md --- @@ -58,11 +58,18 @@ follow-up: run automation under a dedicated GitHub App/bot identity so the separ enforced by GitHub itself rather than by a string convention. Requires an owner-side setup action; the marker exclusion is the complete now-fix.) -**I2. Awareness is immediate and unconditional; escalation is the second page, not the first.** +**I2. Maintainer awareness is immediate and unconditional; escalation is the second page, not the first.** The watcher pages ONCE the first time it sees any open issue (delivery-derived state, retried until the push actually goes out), independent of SLA math. The 4h SLA breach page remains as -escalation. The session banner always shows the open count; breaches only change its urgency. -Awareness latency is now bounded by the watcher cadence (≤1h), not by 4h-plus-never. +escalation. The session banner shows the open count only when an explicit, owner-only (`0600`), +repo-scoped local entitlement exists at `~/.config/ruvnet-brain/maintainer-issues.json`; a normal +installation is silent even if an `open-issues.json` file is present. The installer never creates +or copies this entitlement. POSIX requires a regular, non-symlink file owned by the current uid with +no group/world permissions; Windows fails closed because this dependency-free hook does not inspect +ACL ownership. Invalid, stale, or implausibly future-dated observations are silent. Breaches only +change urgency for the entitled maintainer. Awareness +latency remains bounded by the watcher cadence (≤1h), not by 4h-plus-never, without leaking the +maintainer's operational queue into an end user's terminal. **I3. A failing fixer is silent in public and loud in private.** (Amended same day by the owner's direct order, which also completed duel Phase 1 item 4 ahead of schedule: *"don't spew @@ -180,6 +187,7 @@ The four parallel agents working tonight support this distinction. They show tha | Date | What changed | Why (with referents) | |---|---|---| +| 2026-08-02 | Restricted the SessionStart open-issue banner to an explicit owner-only, repo-scoped local entitlement; default and wrong-repo installations emit zero issue-count bytes. Hardened that boundary to reject symlinks, foreign uid/mode, malformed or future observations, and all Windows visibility until ACL ownership can be verified. | The prior unconditional `surfaceIssues()` call fulfilled maintainer awareness but could expose the maintainer repository's operational queue to any installation carrying a fresh status file. `plugin/scripts/session-start-core.mjs` now fails silent without the local entitlement, and parity tests prove normal-user silence plus Stuart-only visibility. | | 2026-08-01 | Re-read the issue-pipeline boundary after the publication-receipt skill changed; the accepted supervised fixer, human acknowledgment, escalation, and no-autopublish decisions remain unchanged. | `plugin/skills/release-proof/SKILL.md` now requires a post-publication receipt produced from exact public npm/GitHub bytes and installed-host probes. It does not call `scripts/issue-fix.mjs`, post issue comments, or weaken ADR-050's prohibition on automated public mutations. Issue closure remains downstream of a verified release. | | 2026-08-01 | Reconciled the executable fixer boundary with the accepted supervised-worktree decision: scheduled `unattended` mode is now read-only triage, while explicit `supervised` mode may prepare a tested local candidate but cannot push, comment, merge, commit, or promote. Removed the test-only observer from `governs:` so implementation status is derived from production surfaces rather than whether a unit test has a runtime caller. | `scripts/issue-fix.mjs` exposes `executionPolicy()`, defaults to `unattended`, removes git/gh from the worker allowlist, preserves dirty candidate worktrees as recovery evidence, and reports local candidate state to the integration owner. `tests/unit/fix-workstream-guidance.test.mjs` verifies the Brain guidance but is intentionally not a production caller; governing it caused `doc-currency` to downgrade an otherwise wired decision to `built` for the wrong reason. Public automation remains prohibited pending the GitHub App identity follow-up. | | 2026-08-01 | Connected the accepted session-supervised worktree decision to the Brain's always-on fix behavior and the existing fail-closed release authority. Every non-trivial writing lane now receives one isolated worktree; focused evidence hands off to one clean integration owner; dirty lanes are retained for recovery; immutable candidate and publication seals govern promotion language. | The decision already required stable worktrees and human-controlled integration, while `plugin/skills/release-proof/SKILL.md` already rejected dirty/unbound release candidates. The missing seam was `plugin/skills/ruvnet-brain/SKILL.md`: it orchestrated fixes without requiring that delivery rail. `tests/unit/fix-workstream-guidance.test.mjs` now makes the connection executable and prevents a future guidance edit from removing it silently. | diff --git a/docs/adr/0051-codex-host-wiring.md b/docs/adr/0051-codex-host-wiring.md index dc42305c..421d4191 100644 --- a/docs/adr/0051-codex-host-wiring.md +++ b/docs/adr/0051-codex-host-wiring.md @@ -256,6 +256,8 @@ native Windows. | Date | What changed | Why (with referents) | |---|---|---| +| 2026-08-02 | Re-read the final 4.0.7 Codex payload and installer changes; stable MCP registration, managed configuration, lifecycle adaptation, generation-independent hooks, and native skill discovery remain intact. | Commits `3668b1b`, `67b283e`, and `78e897b` add installed provenance, bounded swarm-slot recycling, and the remote release transaction through `bin/install.mjs`, the packaged skills, and the Codex plugin manifest. They preserve disabled-plugin state and do not create a second host transport. Exact-SHA CI and public clean-install proof remain release gates. | +| 2026-08-02 | Repaired a missing or malformed Brain-owned Codex marketplace snapshot before asking Codex to report plugin state; an explicitly disabled plugin remains disabled. | Public 4.0.6 installed the RVF Brain successfully but a retained local marketplace registration pointed at a missing snapshot, so `codex plugin list --json` failed before the old later repair path could run. `bin/install.mjs` now validates and atomically rebuilds only its own snapshot before that probe, reports preparation failures distinctly, and leaves healthy current caches byte-untouched. Real isolated-Codex regressions cover missing, malformed, disabled, and preparation-failure states. | | 2026-08-02 | Made the stable Codex hook door fail-open before Node can report a missing module, and aligned it with custom `CODEX_HOME`. | The 4.0.5 manifest invoked the stable wrapper directly. Plugin-only upgrades, a retained plugin after uninstall, or a deleted wrapper therefore failed before the wrapper's safety logic could run; custom Codex homes also wrote and read different paths. Every 4.0.6 registration now uses a cross-platform inline Node trampoline that resolves the installer's stable path, returns silent exit 0 when it is absent or unhealthy, and preserves only intentional blocking exit 2. The wrapper adds an internal deadline below the host deadline and suppresses unexpected adapter failures. Focused source tests pass 23/23 and the packed-artifact regression passes 7/7; exact-SHA CI and public-artifact proof remain the release gates. | | 2026-08-02 | Re-read the governed Codex manifest for the 4.0.5 patch release. The stable MCP registration, managed-block merge, lifecycle adapter, generation-independent wrapper, hook schema, and native skill-discovery decisions are unchanged; only the product generation advanced. | Commit `1c2bdbf` changes `plugin/.codex-plugin/plugin.json` from 4.0.4 to 4.0.5 through the single-source version workflow. `node scripts/sync-version.mjs --check`, 141 focused version/release tests, and `node scripts/no-silent-substitution.mjs` passed before push. Exact-SHA CI and public Claude/Codex installation remain release gates and are not claimed by this row. | | 2026-08-01 | Re-read the complete host boundary after the four-state update matrix and public-artifact receipt producer landed. Claude-only, Codex-only, both-host, and neither-host installs are now explicit acceptance states; absent hosts remain untouched, an explicitly disabled Codex lifecycle remains disabled, and either detected-host failure restores the prior Console runtime byte-for-byte. | `tests/unit/console-runtime-transaction.test.mjs` exercises the four host states, host-specific failures, persisted runtime identity, and pending-restart counts through injected host adapters in `bin/install.mjs`; the integrated focused rerun passed 70/70. `scripts/publication-receipt.mjs` verifies installed Claude and Codex payloads after publication but does not alter their MCP or lifecycle transport. Native-Windows exact-SHA CI and public installation proof remain release gates, not claims made by this row. | diff --git a/docs/adr/0053-experience-level-qa-architecture.md b/docs/adr/0053-experience-level-qa-architecture.md index bb71ac7d..d0d971c2 100644 --- a/docs/adr/0053-experience-level-qa-architecture.md +++ b/docs/adr/0053-experience-level-qa-architecture.md @@ -203,6 +203,7 @@ budgets (500ms vs 1s prompt-path), the stricter number won. v1's matrix section | Date | What changed | Why (with referents) | |---|---|---| +| 2026-08-02 | Re-read the final 4.0.7 browser probe after Console provenance and inventory landed; the 4,000ms gate and three-OS journey architecture remain unchanged. | Commit `3668b1b` updates `tests/ux/render-probe.mjs` to observe the new runtime identity and owner-only issue inventory exposed by the shipped Console. It does not weaken the click path, timing oracle, OS-derived controls, or failure conditions. Exact-SHA CI remains the cross-platform authority. | | 2026-08-02 | Re-read the real Console Fix All journey after making the timing oracle account for the actual user click rather than a harness-only trial click. The 4,000ms hard gate and the three-OS journey architecture are unchanged. | Commits `7b8b41d`, `57bbe0b`, and `d7bed73` make `tests/ux/render-probe.mjs` report response/render/verification phases, prohibit `trial: true`, and perform exactly one real Fix All click. Commit `8f06287` replaces test `HOME`/`USERPROFILE` overrides with an explicit absolute `RUVNET_CONSOLE_ROOT` fixture plus named config/state files; `tests/unit/console-root.test.mjs` rejects relative traversal input at startup. Three macOS browser trials passed Fix All in 2325ms, 2453ms, and 2294ms; this local evidence does not replace the required cross-platform scheduled probes. | | 2026-08-01 | Re-read the governed experience surfaces after the clean recovery candidate added a serialized `release-qe` job; the journey architecture and its remaining limitations are unchanged. | `.github/workflows/ci.yml` now runs `scripts/release-authority.mjs`, `npm run version:check`, and the exact-artifact release-QE configuration in one named job. No governed scenario, report, UX probe, or performance budget changed, and this local candidate still lacks exact-SHA remote CI and published-byte proof. | | 2026-07-30 | Updated the cross-platform Console acceptance oracle to the shipped 4.0.2 controls and made platform-conditional absence an explicit, OS-derived contract. | PR #68’s exact-SHA UX jobs proved `tests/ux/render-probe.mjs` still expected only provider/advocacy plus seven unsupported controls. The product has eight universally owned controls and a ninth nightly control on macOS; the oracle derives that expectation from `process.platform`, never from the product output it is grading. `HOME` and `USERPROFILE` now share the same fixture root so Windows must surface the known npx defect and exercise real save/reload, Fix All, and undo paths rather than silently accepting zero recommendations. | diff --git a/docs/adr/0054-brain-on-off-and-scope.md b/docs/adr/0054-brain-on-off-and-scope.md index 96402645..e303bacf 100644 --- a/docs/adr/0054-brain-on-off-and-scope.md +++ b/docs/adr/0054-brain-on-off-and-scope.md @@ -99,7 +99,7 @@ brain. The contract, per plane: it as success or outage. - **Hooks — per-entry `offBehavior` in the shim's table** (silence / run / partial): advertising, grounding, and the advisory legacy `verify-interface` notice go silent; the NON-brain safety - walls (route-dispatch cost wall and design-wall) STAY ON — they guard money and honesty, not + non-retrieval protections (route-dispatch cost audit and design-wall) STAY ON — they guard money and honesty, not retrieval. Issue #48 moved interface enforcement to structured MCP arguments. - **session-start splits internally**: auto-updater heartbeat, GONG health alarm and SLA banner keep running (an off machine must still receive fixes — otherwise the fix for an off-state bug @@ -191,6 +191,7 @@ failure. v1 draft's Decision + risks register superseded above; Context stands. | Date | What changed | Why (with referents) | |---|---|---| +| 2026-08-02 | Re-read every final 4.0.7 change touching the installer, hook shim, SessionStart core, and Console. Sentinel authority, per-plane OFF behavior, maintenance choice, and Complete/RuVector profile semantics remain unchanged; `impl: verification-expired` remains honest. | Commits `28baa9c`, `3668b1b`, `00110e5`, `67b283e`, `5a638f6`, and `78e897b` make dispatch timing truthful, keep maintainer issue alerts private, recycle completed swarm slots, expose provider/runtime identity, and harden remote release recovery. Each path still resolves Brain-OFF before user-facing activity and none writes or overrides the sentinel. This re-read does not rerun the eight multi-host OFF gates or mint a new digest. | | 2026-08-02 | Re-read the Console's timing receipt and explicit fixture-root boundary. They do not change sentinel authority, the `brainEnabled` mirror, per-plane OFF behavior, maintenance choice, or Complete/RuVector profile semantics; `impl: verification-expired` remains honest. | Commit `7b8b41d` only exposes aggregate timing for the existing apply path. Commit `8f06287` adds `RUVNET_CONSOLE_ROOT` for console-owned config/state/cache/discovery in isolated tests, with production retaining `os.homedir()` and global binaries/credentials left on the system home. `scripts/brain-state.mjs` still owns the sentinel through its existing explicit `RUVNET_BRAIN_STATE_DIR` seam. The focused root/timing tests and local browser trials do not rerun this ADR's eight multi-host OFF gates, so no new `verified` date or digest is claimed. | | 2026-08-01 | Added the exact-candidate Console runtime to the installer/update convergence transaction; `impl: verification-expired` remains honest. | Issue #79 changes `bin/install.mjs` delivery only: staged syntax verification, atomic activation/rollback, persisted runtime identity, and pending-restart reporting. It does not read, write, remove, or override `~/.config/ruvnet-brain/brain-off`, `brainEnabled`, maintenance choice, or the Complete/RuVector profile. Focused transaction/lifecycle tests cover the delivery behavior; the eight multi-host OFF gates were not rerun here. | | 2026-08-01 | Re-read every governed path changed on the clean integration candidate and deliberately downgraded `impl:` from `verified` to `verification-expired`; the master-switch, sentinel precedence, per-plane OFF law, maintenance behavior, and storage-profile decision remain unchanged in source. | `plugin/scripts/hook-shim.mjs` now bounds and closes stdin for five blocking consumers while preserving the pre-read OFF-silence exit and every `offBehavior` value. `scripts/onboarding-console.mjs` adds scoped runtime ownership/replacement without changing sentinel or profile writes. `bin/install.mjs`, `plugin/mcp/server.mjs`, and `plugin/scripts/session-start-core.mjs` add installed What's New and truthful `registered | ready | degraded` MCP readiness (integration commit `b606900`); the OFF branch still suppresses advertising and preserves maintenance. The stored `verified_digest: 7e4e5c249715` no longer recomputes, and neither this lane nor #78's 138/138 focused tests reran gates 1-8 across both hosts; verification therefore stays expired. | diff --git a/docs/adr/0055-proactivity-that-meshes.md b/docs/adr/0055-proactivity-that-meshes.md index 0fdd05fa..f8ae3ea7 100644 --- a/docs/adr/0055-proactivity-that-meshes.md +++ b/docs/adr/0055-proactivity-that-meshes.md @@ -48,7 +48,7 @@ history below; it does not rewrite that history. `kb/forge-evidence.mjs`. This closes the current exact-evidence routing gap; it does not claim every open-world query can yield a deterministic blocking fact. - **Consent remains fail-safe.** Commit `63e5e67` makes an `assumed:` router profile insufficient - to activate a blocking wall. The later raw-interface demotion in `4ad464e` narrows that rule: + to activate dispatch auditing. The later raw-interface demotion in `4ad464e` narrows that rule: route-dispatch still requires confirmed consent; interface guidance is nonblocking regardless. The ADR is `impl: wired`, not a claim that every aspirational dispatcher in §2 exists. The shipped @@ -361,7 +361,7 @@ decision planes, not lesson count (ADR-030). (GPT-5.6's definition record, adopted). - **Distribution tier**: `plugin/hooks/hooks.json` + packed artifact + active-generation spine — the ONLY channel that reaches other machines. Therefore the write wall - (ground-before-write + grounding-stamp/receipt bridge) and the dispatch wall (route-dispatch, + (ground-before-write + grounding-stamp/receipt bridge) and the dispatch audit (route-dispatch, matcher `Task|Agent`, anchored) move from this machine's `~/.claude/settings.json` into the shipped plugin (opt-in via profile.json preserved — the gates already self-gate); marketplace-clone direct paths are retired; user settings keep machine-specific reminders @@ -523,7 +523,7 @@ vercel 4 · superpowers 1. P1/P2 SessionStart startup|resume → session-start (advisory, partial, 5s). P3 UserPromptSubmit `.*` → ground-ruvnet (advisory, silence, 5s; warm 153–181ms). P4 UserPromptSubmit `*` → unprompted-speech (blocking, silence, 5s; warm 225–265ms). P5 PreToolUse `Write|Edit|Bash` → -hijack-ruvnet (advisory, silence, 5s; 69–73ms). P6 PreToolUse `Task` → route-dispatch (blocking, +hijack-ruvnet (advisory, silence, 5s; 69–73ms). P6 PreToolUse `Task|Agent` → route-dispatch (advisory audit, run, 5s). P7 PreToolUse `^(Write|Edit|MultiEdit|NotebookEdit)$` → protect-state (blocking, run, 5s; 61–65ms). P8/P9 PreToolUse `Bash` → verify-interface / design-wall (blocking, run, 5s; 201–204 / 170–175ms). P10/P11 PreToolUse write/bash → unprompted-speech (blocking, silence, 5s; @@ -568,9 +568,9 @@ SessionStart worst-case is security-guidance's 180s; SessionEnd = 3 concurrent s - **F1** Timeout-unit schism, user layer — **fixed 03:00 tonight; now a regression fixture.** - **F2** Untimed blocking route-dispatch (user layer, 600s default) — the ADR-053 duel find recreated one layer up — **fixed 03:00 tonight; regression fixture.** (Both duelists.) -- **F3** Subagent-wall matcher split — **fixed 2026-07-28; regression fixture.** The redundant - user-layer dispatch wall was removed, leaving the shipped plugin's anchored registration as the - single blocking wall. The merged-registry test now fails if a second copy reappears. (Both.) +- **F3** Subagent-hook matcher split — **fixed 2026-07-28; regression fixture.** The redundant + user-layer dispatch registration was removed, leaving the shipped plugin's anchored registration + as the single audit. The merged-registry test now fails if a second copy reappears. (Both.) - **F4** Anchoring inconsistency inside hooks.json (`^(...)$` at :71/:101/:111 vs unanchored :51/:123); NotebookEdit hits hijack/learn-capture by substring accident. (Fable.) - **F5** Stop bypasses the spine — no table entry, no mode, no offBehavior; contradicts the @@ -684,6 +684,9 @@ delegation drift goes to the interrupt tier (§3.7.9). | Date | What changed | Why (with referents) | |---|---|---| +| 2026-08-02 | Kept SessionStart below its byte budget while restoring the build-only L4 behavioral contract in the prompt hook. The four-plane decision law, Brain-OFF split, consent boundary, and outcome-only learning rule remain unchanged. | `plugin/scripts/session-start-core.mjs` remains compact; `plugin/scripts/ground-ruvnet.sh` now carries one concise build-only contract line instead of depending on prose removed from SessionStart. `scripts/behavioral-l1-l4.mjs` and the SessionStart byte probe are the paired acceptance boundaries. | +| 2026-08-02 | Re-read the final 4.0.7 lesson, SessionStart, hook, and swarm changes. The four-plane decision law, Brain-OFF split, bounded advisory routing, consent boundary, and outcome-only learning rule remain unchanged. | Commits `3668b1b`, `00110e5`, `67b283e`, and `5a638f6` add lesson provenance, private maintainer alerts, completed-slot recycling, and runtime/provider identity through governed hook and SessionStart paths. The new receipts do not promote host completion to artifact correctness, and the advisory dispatch hook still cannot claim a blocking boundary. Exact-SHA and packed-host QE remain release gates. | +| 2026-08-02 | Corrected `route-dispatch` from a claimed blocking wall to a bounded, silent audit. The registration and shim are advisory, and omitted-model calls record `enforcement: advisory-host-timing` while always exiting 0. | Claude Code 2.1.220 registers Agent/Task `PreToolUse` asynchronously and checks the result only after `tool_dispatch_end` (ruvnet-brain #84; Anthropic #83195). An exit-2 result at that point cannot stop an already-completed dispatch. Focused subprocess tests prove the registered hook returns inside its declared timeout and does not consume or alter a foreign hook process. | | 2026-08-02 | Re-read the governed lesson-delivery surface after the source-bound learning replay correction. The four-plane decision law, Brain-OFF behavior, hook registrations, consent boundary, and outcome-only learning rule are unchanged. Lesson delivery is now tested against the direct command form that the stored correction actually requires, with fixture configuration isolated from the operator's real home. | Commits `613a10e`, `71de57b`, `fddd49f`, and `711ff31` change `plugin/scripts/lesson-store.mjs`, `plugin/scripts/lesson-gate.mjs`, `plugin/scripts/lesson-presentation.mjs`, and `plugin/scripts/lesson-command-scope.mjs`; they add direct-action acceptance, split the lesson gate into bounded modules, honor `RUVNET_CONFIG_ROOT`, and redact host paths before proof hashing. Exact merged candidate `1e51f06` passed the 96/96 focused replay suite, the source-bound portfolio check, all four causal mutants, 2,778 unit tests, and 238 integration/browser tests. This is candidate evidence only: it does not establish published-artifact, Windows/WSL2, independent-writer/verifier, or two-grader >=95 proof. | | 2026-08-01 | Re-read the governed mesh after bounded hook stdin and truthful MCP readiness changed two transport surfaces. The four-plane decision law, handler modes, OFF behavior, receipts, learning boundary, and single-source hook bodies remain unchanged. | `plugin/scripts/hook-shim.mjs` now captures at most 64 KiB for `route-dispatch`, `ground-before-write`, `design-wall`, `protect-state`, and `unprompted-speech`, then spawns each consumer with finite closed input; the existing 32 KiB quiet-prompt path keeps its classifier. Integration commit `b606900` makes `plugin/scripts/session-start-core.mjs` say `registered`, `ready`, or `degraded` from a process-checked readiness receipt instead of treating registration as grounding proof. OFF-silent handlers still exit before any read, and the OFF SessionStart branch still suppresses advertising. Issue #80's focused hook suite and #78's 138/138 focused tests are not proof that the still-open 20x load/broken-world matrix, packed hosts, or every foreign hook is green. | | 2026-07-31 | Restored stdout-budget headroom in the native SessionStart authority by shortening only redundant onboarding prose. The Console, routing, and auto-update offers retain the same one-time triggers, choices, commands, and no-repeat behavior. | The old packed candidate exceeded the 4,096-byte contract by 33 bytes on macOS and 22 bytes on Ubuntu. The corrected real packed macOS scenario passed all 76 registered hook firings; focused SessionStart/console tests passed 106/106, the plugin battery passed 60/60, and the registered latency gate passed at 208ms cold, 143ms p95, and 146ms max. | diff --git a/docs/adr/0057-ninety-five-on-both-graders.md b/docs/adr/0057-ninety-five-on-both-graders.md index 7feb5c4d..50d0da79 100644 --- a/docs/adr/0057-ninety-five-on-both-graders.md +++ b/docs/adr/0057-ninety-five-on-both-graders.md @@ -3,7 +3,7 @@ id: ADR-057 title: 95 on both graders — closing a 38/53 against a self-reported 83, dimension by dimension status: Proposed date: 2026-07-27 -updated: 2026-08-01 +updated: 2026-08-02 impl: verification-expired verified: 2026-07-30 verified_digest: 1c276a7dfbc5 @@ -245,6 +245,7 @@ to the five governed paths; it does not adjudicate the product or substitute for | Date | What changed | Why (with referents) | |---|---|---| +| 2026-08-02 | Re-read every final 4.0.7 governed change and kept this plan Proposed with `impl: verification-expired`; no external grade is promoted. | Commits `281df57`, `28baa9c`, `3668b1b`, `67b283e`, and `78e897b` change installer recovery, hook timing, packaged provenance, swarm recycling, and protected publication. These changes preserve the two-independent-grader requirement and do not satisfy it by source inspection. Exact-SHA CI, candidate receipts, published bytes, and post-publication clean installs remain authoritative. | | 2026-08-01 | Re-read the governed installer after the four-state Claude/Codex transaction matrix. The plan remains Proposed and `impl: verification-expired`; focused host proof does not promote either external grader or the overall score. | `bin/install.mjs` now exposes the minimum injected seams needed to prove Claude-only, Codex-only, both, neither, disabled-host preservation, and rollback on either host failure. The integrated focused suite passed 70/70, while the full unit run recorded 2,726 passes and two version-fixture failures that were corrected and rerun 43/43. Exact-SHA CI, public-artifact clean installs, and two independent scores at or above 95 remain outstanding. | | 2026-08-01 | Re-read the governed installer changes and deliberately downgraded `impl:` from `verified` to `verification-expired`. The 95 plan remains Proposed and every external score/proof limitation remains open. | `bin/install.mjs` now delegates What's New to the installed plugin payload and, in #78 integration commit `b606900`, declares a 30-second Codex MCP startup deadline and reports worker readiness separately from registration. Those changes do not alter the five governed claims in the source ledger below. Nevertheless `verified_digest: 1c276a7dfbc5` no longer recomputes, and a source read plus #78's 138/138 focused tests cannot mint a new verification checkpoint. No broad/packed/exact-SHA/public artifact was proved, no external grader reran, and the downstream substitution/clean-room gaps remain open. | | 2026-07-30 | Re-read the governed 4.0.2 source and kept this decision Proposed: local packed/focused evidence is not external release proof or a 95 score. | `bin/install.mjs` now persists Console runtime and validates controls; `plugin/hooks/hooks.json` removed parser-invalid metadata and retains valid description/hooks fields. Still OPEN: external exact-SHA matrix, published clean install, both independent graders, WhitSentry clean-room replay, downstream substitution audit, and D4 N=3 promotion threshold. | diff --git a/docs/adr/0058-the-95-contract.md b/docs/adr/0058-the-95-contract.md index 7336abc8..676ed3ec 100644 --- a/docs/adr/0058-the-95-contract.md +++ b/docs/adr/0058-the-95-contract.md @@ -42,6 +42,7 @@ governs: - scripts/self-update.mjs - scripts/nightly-wrapper.sh - .github/workflows/ci.yml + - .github/workflows/stranger-matrix.yml - .github/workflows/protected-release.yml - plugin/skills/release-proof/SKILL.md - plugin/skills/release-proof/scripts/release-proof.mjs @@ -65,14 +66,15 @@ scoped evidence instead of widening; the same query passes in 0.671s, with 118/1 3/3 live MCP cases. The new `release-proof` skill and executable authority fail closed on dirty lineage, zero/skipped/todo work, open issues, exact-SHA GitHub failures, artifact/host/grader binding splits, missing self-RVF, deadline-margin breaches, and public-byte drift. GitHub now enforces required -checks for admins and protects `Production – ruvnet-brain` with required review and no admin bypass. +checks for admins and protects `Production – ruvnet-brain` with protected-branch deployment policy. The 4.0.4 candidate exposed a release-graph defect after merge: the protected workflow consumed a `release-evidence-` artifact that CI never produced. The corrected transport gives the `release-qe` job one responsibility: test, pack, and upload an append-only stabilization receipt containing only facts that job actually observed. The protected workflow derives the package digest from those bytes, requires the candidate to equal current `origin/main`, and independently proves the -exact-SHA CI run is complete and green. It then crosses `Production – ruvnet-brain` for reviewer -approval before invoking the sole publisher. The stabilization receipt explicitly makes no >=95 +exact-SHA CI run is complete and green. It then crosses `Production – ruvnet-brain` automatically +before invoking the sole publisher; Stuart's standing release authorization removed the redundant +manual reviewer click on 2026-08-02. The stabilization receipt explicitly makes no >=95 claim; the strict two-grader >=95 promotion contract remains unsatisfied and remains a separate program. `scripts/release.mjs --publish` rejects local, differently named, and receipt/mode-mismatched invocations before remote mutation. After publication, `scripts/publication-receipt.mjs` @@ -81,6 +83,11 @@ digests and channel identities to match the candidate, installs the public npm b Claude and Codex homes, exercises the installed MCP self-store within its deadline, and runs the exact-SHA published-surface probe. It writes the append-only publication receipt only after the existing two-receipt evaluator accepts every observation; missing or split evidence stays red. +On 2026-08-02 the candidate path was collapsed into an evidence DAG: `release-qe` creates one npm +tarball before testing, every release-QE and stranger-host consumer uses those exact uploaded bytes, +and the publisher no longer repeats source/unit/version/wiring/latest-CI/push gates or a second +public-channel walk. Exact SHA, digest, security, OS/host, staged transaction, and public receipt +guarantees remain fail-closed; only duplicate execution was removed. D4 has a current Codex-backed 3/3 treated versus 0/3 control artifact on source SHA `63e5e67`, plus committed delete-lesson and brain-off-treated causal failures in `2b39f68`. D3 now executes its real signal lifecycle from the @@ -356,6 +363,8 @@ correct: **the strong claim was the defect.** | Date | What changed | Why (with referents) | |---|---|---| +| 2026-08-02 | Re-read the final 4.0.7 governed release, installer, hook, QE, and lesson surfaces. The vector-minimum law, exact-SHA evidence binding, protected sole publisher, and post-publication seal remain unchanged; this row makes no score or shipped claim. | Commits `28baa9c`, `3668b1b`, `00110e5`, `67b283e`, `5a638f6`, and `78e897b` make dispatch timing truthful, preserve installed provenance, keep owner alerts private, recycle swarm slots, expose provider identity, and make publication remotely recoverable. The protected workflow still requires current-main identity and the successful exact-SHA `release-qe` run before publication, then verifies public bytes and installed hosts. | +| 2026-08-02 | Removed the redundant human reviewer from `Production – ruvnet-brain` while retaining the environment, protected-branch policy, exact-SHA CI, sealed artifact, sole publisher, and post-publication receipt. | Stuart explicitly granted standing production authorization after the 4.0 launch phase. GitHub environment protection now has zero required reviewers and retains the branch-policy rule; protected-release run `30768302283` crossed the boundary without a pause and published 4.0.6 with both seals green. | | 2026-08-02 | Re-read the protected release and Codex hook boundaries for the 4.0.6 emergency candidate. The release remains a stabilization path and makes no 95 claim. | Commit `eab11d8` changes `plugin/hooks/codex-hooks.json`, `plugin/scripts/codex-hook-wrapper.mjs`, `.github/workflows/protected-release.yml`, `scripts/stabilization-receipt.mjs`, and exact source/packed-artifact regressions. Plugin-only, missing-wrapper, retained-plugin-after-uninstall, and isolated `CODEX_HOME` paths now fail open before Node can emit `MODULE_NOT_FOUND`; unexpected crashes/timeouts are silent while intentional blocking exit 2 remains enforced. Focused source gates passed 79/79 and the packed release boundary passed 7/7. Public npm/GitHub bytes, exact-SHA remote CI, and post-publication host proof remain unproven until the protected workflow completes. | | 2026-08-02 | Re-read D4 and the release-vector boundary after the learning replay was split into bounded modules and its evidence made source-bound and self-verifying. The eight-dimension minimum contract and the rule that UNKNOWN cannot be averaged into PASS are unchanged. | Commits `b4a8749`, `b044737`, `0f13b43`, `711ff31`, and `2c1acf4` change `scripts/learning-replay.mjs` and `scripts/release-vector.mjs`, add the replay contract/execution/fixture/proof modules, bind each proof to source SHA, arm, run, trap, and mutant identity, isolate destructive mutants, redact host paths, and reject orphaned or mismatched evidence. Exact merged candidate `1e51f06` passed 96/96 focused tests, both portfolio verifiers, all four expected-red mutants, 2,778 unit tests, and 238 integration/browser tests. The proof subsystem remains 94/100 because disk-space preflight and an independently implemented verifier are absent; this row makes no published-package, Windows/WSL2, or two-external-grader >=95 claim. | | 2026-08-01 | The protected release asset restore now excludes AppleDouble metadata, deterministically requires exactly one canonical RVF directory, audits every restored RVF before the publisher, and makes `build-bundle.mjs` independently ignore `._*` pseudo-stores. | Protected release run `30727791825` passed 2,735 tests and exact-main proof, then failed before publication because recursive `find` selected a 163-byte `__MACOSX/._*.big.rvf` resource-fork stub from the v4.0.3 ZIP. Replaying the corrected selector against that exact 529.8 MB public artifact chose the real directory and audited 62/62 RVFs successfully. npm remained 4.0.2 and no v4.0.4 GitHub release existed after the failure. | diff --git a/docs/adr/0062-remote-durable-release-transaction.md b/docs/adr/0062-remote-durable-release-transaction.md new file mode 100644 index 00000000..6f3bba22 --- /dev/null +++ b/docs/adr/0062-remote-durable-release-transaction.md @@ -0,0 +1,198 @@ +--- +id: ADR-062 +title: Remote-durable staged release transaction +status: Accepted +date: 2026-08-02 +updated: 2026-08-02 +authors: [Stuart Kerr] +tags: [release, evidence, transaction, npm, github, receipts, recovery] +supersedes: [] +relates: [ADR-053, ADR-058] +governs: + - .github/workflows/ci.yml + - .github/workflows/stranger-matrix.yml + - .github/workflows/protected-release.yml + - scripts/release.mjs + - scripts/release-transaction.mjs + - scripts/release-transaction-provider.mjs + - scripts/staged-host-verifier.mjs + - docs/ddd/0015-release-transaction-context.md +--- + +# ADR-062 — Remote-durable staged release transaction + +**Status**: Accepted + +**Date**: 2026-08-02 + +## Context + +Issue #77 proved that a cross-provider release can expose a new GitHub generation while npm and +installed hosts remain on the old generation. The existing protected release rail now binds a +candidate to an exact SHA and stores a local `dist/release-transaction.json`, but the reopening of +#77 identifies four remaining gaps: + +1. local transaction state is lost with the runner; +2. GitHub and npm default channels are changed without first staging both providers; +3. interruption at a promotion boundary is not exhaustively recoverable; +4. convergence evidence does not exercise the actual Claude and Codex host interfaces as part of + the transaction commit. + +GitHub drafts are visible only to authorized writers and can carry release assets. npm publication +under an explicit non-default tag makes immutable package bytes installable without changing +`latest`. Provider calls cannot be atomic with each other, so correctness must come from a durable, +idempotent state machine rather than call ordering alone. + +## Decision drivers + +- No supported client observes B until B has passed candidate and host gates. +- A clean runner can resume B from remote evidence alone. +- Every provider call is preceded by a durable write-ahead intent and followed by observed-state + reconciliation. +- Replays are idempotent and bind `version + tag + SHA + npm integrity + bundle digest`. +- A pending B blocks B+1. +- Local files and workflow logs are caches/evidence, never release authority. +- Tests use provider fakes; no test may create drafts, tags, packages, or dist-tag changes. + +## Evidence DAG implementation (2026-08-02) + +Candidate CI now packs the npm tarball exactly once, before release QE, and identifies those bytes +by SHA-256 in the candidate receipt. Release QE and the five stranger-host cells consume that same +uploaded tarball; no host repacks the checkout. The protected publisher downloads the receipt and +sealed bytes, rechecks their SHA/version/digest identity at the Production boundary, and passes the +tarball unchanged into the staged npm/GitHub transaction. + +Source tests, unit tests, version/wiring checks, and exact-SHA CI proof are candidate-builder +responsibilities. The publisher does not rerun them, recheck a weaker "latest CI" verdict, or push +main. It performs only the receipt boundary, signed bundle assembly, staged host acceptance, +promotion, one public-channel verification, and final publication receipt. Any source change creates +a new SHA and therefore a new candidate artifact instead of restarting unrelated publisher gates. + +## Considered approaches + +### 1. GitHub Actions artifact as transaction authority + +Advantages: already produced by CI, immutable per upload, straightforward workflow wiring. + +Rejected as the sole authority: artifacts expire and are tied to a workflow run. They remain useful +append-only evidence, but expiry or run deletion must not make a pending release unrecoverable. + +### 2. Dedicated branch/ref containing transaction records + +Advantages: durable Git object history and natural compare-and-swap semantics. + +Rejected: it creates a second mutable release control plane, requires commits unrelated to product +source, and complicates branch protection and publisher authority. + +### 3. Draft GitHub release plus transaction asset (chosen) + +The draft is already the GitHub staging object for the exact tag/SHA. A canonical +sequence-named `release-transaction-.json` assets make that object the recovery anchor. +npm stages the immutable version under `candidate-v` (never `latest`). CI artifacts retain append-only copies of +the staged and final receipts for audit. + +Receipts are create-only, Ed25519-signed, hash-chained, and carry a monotonic sequence plus fencing +token. The publisher downloads and verifies the just-written asset before crossing the next +boundary. A duplicate sequence or stale fence loses ownership and performs no further mutation. +Recovery accepts only the highest valid chain for the same immutable identity; conflicting identity, +signature, chain, or regressed state fails closed. + +## Transaction protocol + +### Identity + +`transactionId = sha256(version | tag | candidateSha | packageSha512 | bundleSha256)`. + +Every state and provider observation repeats those fields. Any mismatch is a collision, not a new +attempt. + +### Prepare + +1. Verify synchronized manifests, exact candidate SHA CI, sealed npm tarball, signed bundle, and + candidate receipt. +2. Under one repository-wide publisher lock, discover all draft/published release anchors and npm + candidate tags. A pending different identity blocks B. Duplicate/orphan drafts for B are adopted + only when their tag/SHA identity is exact and unambiguous; otherwise the run fails without mutation. +3. Create or adopt a GitHub **draft** explicitly targeting the candidate SHA. This does not advance + `releases/latest`. +4. Upload signed assets and the initial signed remote transaction receipt; download and verify it. +5. Verify Claude-only, Codex-only, and dual-host fixtures from digest-addressed local sealed package + and bundle files through the supported host interfaces; no public release lookup is used. Record + that local result separately from remote staging evidence. +6. Write `npm-stage-intent`, then publish the sealed tarball with the non-default + `candidate-v` tag. Observe registry version, integrity, and candidate tag; record + `npm-candidate-staged`. +7. Re-download the candidate tarball through npm and all bundle assets through the authenticated + GitHub draft API, verify their identity digests, then re-run the three fixtures. Record `prepared` only + when both staged providers reproduce the same host receipt. A fresh Codex fixture may report + `PENDING_REVIEW` only when the real doctor proves the exact plugin installed and names explicit + lifecycle-hook trust as its sole nonzero condition; every other nonzero doctor result fails. + +If any prepare step fails, GitHub remains a draft and npm `latest` remains A. An npm version is +immutable, so compensation is reconciliation: retain the candidate tag for B and resume or mark the +draft failed; never reuse B for different bytes. + +### Promote + +1. Record `github-promote-intent`; publish the verified draft with `make_latest=false`. +2. Observe tag-to-SHA, non-latest status, and release assets; record `github-promoted-nonlatest`. +3. Record `npm-promote-intent`; move npm `latest` to the already-observed B bytes. +4. Observe npm `latest`; record `npm-promoted`. +5. Record `github-latest-intent`; make the exact published B release latest, observe it, and record + `defaults-promoted`. +6. Publish the signed current-release manifest/receipt, run the immediate surface probe, re-run + actual Claude/Codex doctor interfaces, and record `channels-converged` only after all agree. + +If non-latest GitHub promotion succeeds and npm promotion fails, retry B; supported defaults remain +A. If npm promotion succeeds while GitHub remains draft/non-latest, compensate npm `latest` back to +the receipt's captured A only after re-observing that `latest` still equals B. Any third identity is a +race and becomes `manual-intervention-required`, never a blind rollback. A poisoned immutable B may +enter signed, explicitly human-authorized terminal `aborted`; automation cannot abort or reuse B. + +### Recovery + +- Discover the unique draft/published release whose transaction asset identifies B. +- Reconcile provider observations before trusting the recorded state; an intent may have completed + even if the process died before its completion receipt. +- Continue the same B only. A different pending identity blocks the run. +- A step is skipped only when provider state proves its exact postcondition. +- `doctor` fails for every state other than `channels-converged` and prints the same-candidate resume + command plus any required host restart/review action. +- Publisher doctor uses authenticated draft receipts; user doctor trusts only the public signed + current-release receipt and reports an unpublished transaction as unknown rather than healthy. + +## Invariants + +1. One canonical publisher owns all release/default-channel mutations. +2. No remote/default mutation occurs before exact-SHA candidate proof. +3. The first remotely durable receipt exists before npm publication or GitHub publication. +4. GitHub draft and npm candidate tag never count as the supported current generation. +5. Receipt state is monotonic; identity is immutable. +6. Every non-idempotent boundary has a write-ahead intent and an observed postcondition. +7. `channels-converged` requires the signed final receipt, live surfaces, and actual managed-host + interface receipts. +8. A disabled plugin stays disabled; changed hooks remain pending explicit host review; a live + Console is either restarted onto B or reported `pending-console-restart`. +9. One constant workflow concurrency group plus remote fencing serializes all versions. +10. Ordinary open issues are not release authority; only maintainer-governed release-blocker + metadata blocks publication. + +## Consequences + +- Release code gains a small domain state machine and provider adapter boundary. +- The protected workflow must retain both staged and final receipts even on failure. +- A failed release may leave an unpublished GitHub draft and an immutable npm candidate version. + This is deliberate evidence, not debris to overwrite. +- Cross-provider atomicity remains impossible, but no incomplete B is called current and every + partial promotion has a deterministic compensation/resume path. +- Exact final-version npm staging can be selected by semver-range consumers even under a non-default + tag. The supported-client invariant covers bare/`latest` and explicit-version Brain installs; the + receipt and doctor surface the unavoidable registry visibility rather than claiming isolation. + +## Authoritative references + +- GitHub Releases and drafts: https://docs.github.com/en/rest/releases/releases +- GitHub release assets: https://docs.github.com/en/rest/releases/assets +- npm candidate tags and immutable package versions: https://docs.npmjs.com/cli/publish/ +- npm dist-tags: https://docs.npmjs.com/adding-dist-tags-to-packages/ +- Domain model: `docs/ddd/0015-release-transaction-context.md` diff --git a/docs/ddd/0002-onboarding-console.md b/docs/ddd/0002-onboarding-console.md index 3841931e..be9700c0 100644 --- a/docs/ddd/0002-onboarding-console.md +++ b/docs/ddd/0002-onboarding-console.md @@ -1,6 +1,6 @@ # DDD — The Onboarding Console -Updated: 2026-07-27 +Updated: 2026-08-02 Created: 2026-07-14 > **Status: Implemented (2026-07-14).** Contexts 1 (Stack Inventory), 2 (Wiring Survey), 4 @@ -28,6 +28,9 @@ convention, that is deliberate: **we have already proven that advisory rules do | **Recommendation** | A proposal carrying evidence, cost, and a reversal | An instruction | | **ChangePlan** | A set of mutations the user explicitly consented to | Anything we do on our own | | **Receipt** | A recorded measurement of something that actually happened | An estimate | +| **Personal lesson** | A current-user statement eligible for explicit ratification | Bundled maintainer, imported, inferred, or demonstration history | +| **Snapshot** | A fresh artifact that validates against the versioned session schema | An arbitrary file at a familiar path | +| **Managed candidate** | A reviewed additive model fact shipped by Brain | Permission to overwrite a user override or enable metered spend | --- diff --git a/docs/ddd/0015-release-transaction-context.md b/docs/ddd/0015-release-transaction-context.md new file mode 100644 index 00000000..b889ce10 --- /dev/null +++ b/docs/ddd/0015-release-transaction-context.md @@ -0,0 +1,159 @@ +Updated: 2026-08-02 20:59:00 EDT | Version 1.1.0 +Created: 2026-08-02 20:10:00 EDT + +# DDD-0015 — The Release Transaction bounded context + +Governs: ADR-062 · Issue #77 · `scripts/release.mjs` + +## Purpose and boundary + +The Release Transaction context converts one sealed candidate into one supported product +generation across GitHub, npm, Claude, Codex, Stable Spine, and Console. It owns provider staging, +promotion, compensation, and receipts. It does not build the corpus, choose a version, modify source, +or authorize publication. + +## Ubiquitous language + +| Term | Exact meaning | +|---|---| +| **Candidate** | Immutable B identity: version, tag, exact source SHA, npm integrity, and bundle digest. | +| **Prior generation** | The observed supported A identity captured before promotion. | +| **Draft anchor** | GitHub draft for B containing signed assets and the remote transaction receipt. | +| **Candidate channel** | npm `candidate-vB`; it resolves B without moving `latest`. | +| **Intent** | Durable receipt written before a provider boundary, naming the exact attempted transition. | +| **Observation** | Provider state read after a call or on recovery; success depends on observation, not exit text. | +| **Prepared** | Draft assets, npm candidate bytes, and all isolated host fixtures agree on B. Defaults remain A. | +| **Promoted** | A provider default has moved to B; this is partial until the final manifest and all surfaces converge. | +| **Compensated** | A default channel was restored to captured A after unsafe promotion order/failure. | +| **Committed** | Signed current-release receipt names B and every live/host check agrees; state is `channels-converged`. | + +## Aggregate + +`ReleaseTransaction` is the aggregate root. Its identity and signed, hash-chained event sequence are +immutable. Sequence-named GitHub draft assets are authoritative remote state; local JSON is a disposable cache; workflow +artifacts are append-only audit evidence. + +### Entity: CandidateIdentity + +- `transactionId` +- `version`, `tag`, `candidateSha` +- `packageSha512`, `bundleSha256` +- sealed package/bundle asset names + +### Entity: ProviderSnapshot + +- prior GitHub latest tag/SHA and npm latest version +- draft id, draft flag, tag target, asset digests +- npm candidate/latest tag targets and registry integrity +- signed current-release manifest identity + +### Value object: HostConvergence + +- `claude`, `codex`, and `dual` fixture verdicts +- Stable Spine generation +- plugin installed/enabled state +- hook review requirement +- Console state (`ready` or `pending-console-restart`) + +## State machine + +| State | Meaning | Allowed next states | +|---|---|---| +| `initialized` | Candidate proof valid locally; no remote authority yet. | `remote-prepare-intent` | +| `remote-prepare-intent` | Draft creation identity is fixed. | `remote-prepared` | +| `remote-prepared` | Draft anchor and verified receipt exist. | `asset-upload-intent` | +| `asset-upload-intent` | Create-only staged asset upload is write-ahead recorded. | `host-verification-intent` | +| `host-verification-intent` | Digest-bound local candidate host verification is pending. | `local-hosts-verified` | +| `local-hosts-verified` | Local sealed bytes pass all host fixtures. | `npm-stage-intent` | +| `npm-stage-intent` | Candidate publish is write-ahead recorded. | `npm-candidate-staged` | +| `npm-candidate-staged` | Registry proves B under non-default tag. | `remote-host-verification-intent` | +| `remote-host-verification-intent` | Exact bytes must be re-downloaded from both staged providers. | `prepared` | +| `prepared` | Draft, npm candidate, and host fixtures agree; defaults are A. | `github-promote-intent` | +| `github-promote-intent` | Non-latest GitHub draft publication is write-ahead recorded. | `github-promoted-nonlatest` | +| `github-promoted-nonlatest` | GitHub B is public but not latest; defaults remain A. | `npm-promote-intent` | +| `npm-promote-intent` | npm latest transition A→B is write-ahead recorded. | `defaults-promoted` | +| `npm-promoted` | npm latest observes B; GitHub latest remains A. | `github-latest-intent`, `compensation-intent` | +| `github-latest-intent` | GitHub latest transition A→B is write-ahead recorded. | `defaults-promoted` | +| `defaults-promoted` | Both provider defaults observe B. | `finalize-intent` | +| `finalize-intent` | Signed manifest/surface/host convergence is pending. | `channels-converged` | +| `channels-converged` | Only terminal success. | none | +| `compensation-intent` | Unsafe npm-first partial promotion must return to A. | `compensated`, `manual-intervention-required` | +| `compensated` | Defaults again expose A; B remains resumable. | the interrupted B intent | +| `manual-intervention-required` | Compensation/reconciliation cannot prove a safe supported state. | explicit same-B repair only | +| `aborted` | Human-authorized terminal failure; B is burned and B+1 may start. | none | + +An exception is not a state transition. On restart, the aggregate observes providers and advances or +compensates according to proven postconditions. + +## Commands + +- `PrepareRelease(candidateReceipt)` +- `ResumeRelease(transactionId)` +- `StageNpmCandidate(sealedTarball)` +- `VerifyStagedHosts(claude, codex, dual)` +- `PromoteGithubDraft()` +- `PromoteNpmLatest()` +- `CompensateNpmLatest(priorGeneration)` +- `FinalizeRelease(signedManifest)` +- `DiagnoseRelease(transactionId)` + +Each command is idempotent for one identity and rejects a competing pending identity. + +## Domain events + +`ReleaseInitialized` · `RemotePrepareIntended` · `RemoteReceiptVerified` · +`NpmStageIntended` · `NpmCandidateObserved` · `HostVerificationIntended` · +`StagedHostsConverged` · `GithubPromotionIntended` · `GithubPromotionObserved` · +`NpmPromotionIntended` · `NpmPromotionObserved` · `CompensationIntended` · +`CompensationObserved` · `FinalizationIntended` · `SurfaceProbePassed` · +`ManagedHostsConverged` · `ChannelsConverged` · `ManualInterventionRequired`. + +Every event contains transaction identity, monotonic sequence, prior state, next state, provider +observations, UTC timestamp, fencing token, previous receipt digest, signer identity, and signature. + +## Invariants + +- RT-1: Candidate identity never changes after `ReleaseInitialized`. +- RT-2: State sequence never decreases or skips a required intent/observation pair. +- RT-3: A remote receipt is verified before GitHub publication or npm package publication. +- RT-4: npm candidate publication uses only the version-specific non-default tag. +- RT-5: GitHub publication operates only on the verified draft anchor targeting the exact SHA. +- RT-6: No B+1 transaction starts while B is nonterminal. +- RT-7: Provider command success is insufficient; exact remote observation is mandatory. +- RT-8: `prepared` requires Claude-only, Codex-only, and dual-host results from staged bytes. +- RT-9: npm-first partial promotion is compensated to captured A before retry. +- RT-10: `channels-converged` requires signed manifest, live surface probe, Stable Spine, Claude, + Codex, and Console convergence. +- RT-11: Disabled remains disabled; changed hooks require explicit host review. +- RT-12: Receipt conflicts or compensation failure are visible hard failures. +- RT-13: A stale fencing token performs no provider mutation. +- RT-14: Only an explicitly authorized human action may enter `aborted`. + +## Policies + +### Recovery policy + +Load the remote receipt, validate its digest/identity, observe both providers, and derive the next +command. Never infer success from the previous process exit code or a changed tag. + +### Compensation policy + +GitHub-first partial promotion resumes forward because the public release is immutable evidence and +npm B bytes are already staged. npm-first partial promotion restores npm `latest` to captured A +before continuing. A failed rollback becomes `manual-intervention-required`. + +### Doctor policy + +Doctor reports healthy only for `channels-converged`. All other states name B, the last proven +state, observed splits, and the exact same-B resume/repair action. `pending-console-restart` and +pending hook review remain non-converged. + +## Anti-corruption layers + +- **GitHub adapter:** draft/release/tag/asset APIs become typed observations. +- **npm adapter:** registry versions, integrity, candidate tag, and latest tag become typed + observations. +- **Host adapter:** invokes the supported Claude/Codex install and doctor interfaces; filesystem + inspection supplements but never replaces those verdicts. +- **Workflow adapter:** supplies authorization and exact-SHA evidence but cannot declare domain + success. diff --git a/explainer/index.html b/explainer/index.html index dd6eafb2..392ce78e 100644 --- a/explainer/index.html +++ b/explainer/index.html @@ -67,7 +67,7 @@ "alternateName": "RuvNet-Brain", "applicationCategory": "DeveloperApplication", "operatingSystem": "Cross-platform (Node.js)", - "softwareVersion": "4.0.6", + "softwareVersion": "4.0.7", "datePublished": "2026-06-30", "dateModified": "2026-07-28", "description": "A downloadable, source-grounded brain that treats Claude Code and OpenAI Codex as first-class hosts. Use either one or both. The installer wires each detected host into rUv's real RuvNet source — RuVector/RVF, Ruflo, AgentDB, RuLake, SPARC and 69 indexed repos — with search_ruvnet, proactive grounding, learning capture and session continuity.", @@ -233,7 +233,7 @@ Built by Stuart Kerr at Isovision.ai. Free & fair use — so everyone can fully leverage the high end of agentic coding.

-

STATUS live · v4.0.6

+

STATUS live · v4.0.7

diff --git a/kb/RVF-GENERATIONS.json b/kb/RVF-GENERATIONS.json index f146c3f6..6f3e3467 100644 --- a/kb/RVF-GENERATIONS.json +++ b/kb/RVF-GENERATIONS.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "brainVersion": "4.0.6", - "releaseTag": "v4.0.6", + "brainVersion": "4.0.7", + "releaseTag": "v4.0.7", "stores": { "agentdb": { "file": "agentdb.big.rvf", diff --git a/kb/package.json b/kb/package.json index d829fb3d..558ab54a 100644 --- a/kb/package.json +++ b/kb/package.json @@ -1,6 +1,6 @@ { "name": "ruvnet-brain-kb", - "version": "4.0.6", + "version": "4.0.7", "private": true, "type": "module", "description": "Self-contained RVF knowledge base for ruvnet-brain, built by rvf-kb-forge. Run `npm i` here, then use forge-ask.mjs (CLI) or forge-mcp.mjs (MCP stdio server). Vectors live in ruvnet-brain.rvf; full passage text in ruvnet-brain.passages.jsonl.", diff --git a/package-lock.json b/package-lock.json index 9b8dfa07..cf3014ce 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "ruvnet-brain", - "version": "4.0.6", + "version": "4.0.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ruvnet-brain", - "version": "4.0.6", + "version": "4.0.7", "license": "MIT", "dependencies": { "@metaharness/flywheel": "^0.1.7", diff --git a/package.json b/package.json index f214994c..3678338b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ruvnet-brain", - "version": "4.0.6", + "version": "4.0.7", "description": "One-command installer for RuvNet Brain — a portable, source-grounded brain over rUv's RuvNet building blocks, delivered as a Claude Code plugin so Claude uses the stack instead of fighting it.", "type": "module", "bin": { @@ -68,6 +68,7 @@ "LICENSE", "console/", "config/model-router/", + "data/model-catalog.json", "scripts/", "kb/verify-citation.mjs", "kb/zip-extract.mjs", diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index e72b19bf..d4bb8eae 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ruvnet-brain", "description": "RuvNet brain transplant for Claude Code — grounds every RuvNet decision in real source across 69 rUv repositories, prefers Ruflo / RuVector-RVF / AgentDB over training-prior defaults (pgvector, Pinecone, hand-rolled cosine), and can pull in any RuvNet repo on demand. Ships an enforced UserPromptSubmit retrieve-and-inject grounding hook that sharply reduces drift.", - "version": "4.0.6", + "version": "4.0.7", "author": { "name": "Stuart Kerr" }, diff --git a/plugin/.codex-plugin/plugin.json b/plugin/.codex-plugin/plugin.json index 81179aaf..d42667fa 100644 --- a/plugin/.codex-plugin/plugin.json +++ b/plugin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "ruvnet-brain", - "version": "4.0.6", + "version": "4.0.7", "description": "Source-grounded RuvNet knowledge, lifecycle enforcement, and learning for Codex.", "author": { "name": "Stuart Kerr" diff --git a/plugin/hooks/hooks.json b/plugin/hooks/hooks.json index 22d01f71..d3720807 100644 --- a/plugin/hooks/hooks.json +++ b/plugin/hooks/hooks.json @@ -23,6 +23,18 @@ ] } ], + "PreCompact": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" session-snapshot PreCompact || true", + "timeout": 5 + } + ] + } + ], "UserPromptSubmit": [ { "matcher": ".*", @@ -61,7 +73,7 @@ "hooks": [ { "type": "command", - "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" route-dispatch", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" route-dispatch || true", "timeout": 5 } ] @@ -175,6 +187,16 @@ } ], "SessionEnd": [ + { + "matcher": ".*", + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" session-snapshot SessionEnd || true", + "timeout": 5 + } + ] + }, { "matcher": ".*", "hooks": [ @@ -186,6 +208,17 @@ ] } ], + "TeammateIdle": [ + { + "hooks": [ + { + "type": "command", + "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs\" swarm-slot-recycler", + "timeout": 5 + } + ] + } + ], "Stop": [ { "matcher": "*", diff --git a/plugin/scripts/ground-ruvnet.sh b/plugin/scripts/ground-ruvnet.sh index cb05265c..03d9a9e7 100755 --- a/plugin/scripts/ground-ruvnet.sh +++ b/plugin/scripts/ground-ruvnet.sh @@ -392,7 +392,7 @@ if [ "$DRIFT" -eq 1 ]; then cat <<'EOF' [RuvNet Brain — STOP: you're reaching for a classical default] You named a generic, training-prior tool. In a RuvNet workflow there's almost always a sharper rUv-native primitive — use it, don't talk the user back to the old way. Confirm the specific capability (AND any numbers) via `search_ruvnet` before you write code — do NOT assert rUv specs from memory. Direction: -- vector DB / embeddings (Pinecone, pgvector, Chroma, Weaviate, FAISS, Milvus, Qdrant, hnswlib) → RuVector (.rvf single-file HNSW); branchable agent memory → agenticow; provenance cache → RuLake +- Take the wheel: vector DB / embeddings (Pinecone, pgvector, Chroma, Weaviate, FAISS, Milvus, Qdrant, hnswlib) → RuVector (.rvf single-file HNSW); branchable agent memory → agenticow; provenance cache → RuLake - embedding APIs (OpenAI text-embedding, Cohere, Voyage) → local ONNX MiniLM/bge via RVF (offline, free) - RAG / agent frameworks (LangChain, LlamaIndex, AutoGen, CrewAI, Semantic Kernel) → Ruflo + agentic-flow + FACT - agent memory (Redis/SQLite glue) → AgentDB · token/prompt compression → SynthLang · quantum-safe messaging → QuDAG @@ -410,6 +410,7 @@ This is a build / change request — run THE PLAYBOOK (the standing build playbo - If memory looks broken, use the exact-path store → exact-key retrieve → exact SQL proof; never diagnose from semantic search, mtime, daemon startup, or a success message. ⛔ NO SILENT SUBSTITUTION: use the real RuvNet tool, or say out loud that you're hand-rolling and why. Senior partner: one plan, momentum, end with real work. +Build contract: take the wheel; use SPARC with DDD + ADR; dispatch a PARALLEL Ruflo swarm; put a QA gate between phases; for UI use frontend-design plus image generation; ask once for a missing API key; finish with a PROVEN result scored to ≥98. EOF fi diff --git a/plugin/scripts/hook-shim.mjs b/plugin/scripts/hook-shim.mjs index 9c7dc7cd..dada03ad 100644 --- a/plugin/scripts/hook-shim.mjs +++ b/plugin/scripts/hook-shim.mjs @@ -13,8 +13,8 @@ // hook-shim-bash.mjs (bash-interpreter resolution, issue #38) — colocated in this same // boot-frozen scripts/ dir, never resolved from the spine. No imports from the hook BODY. // • Typed dispatch table (red-team findings 15/16/30): each hook declares its file, interpreter, -// and mode. `blocking` hooks propagate their exact exit code (route-dispatch's deliberate -// exit-2 wall survives by CONTRACT); `advisory` hooks can never block a turn — any failure, +// and mode. `blocking` hooks propagate their exact exit code; `advisory` hooks can never block +// a turn — any failure, // including a missing file, exits 0. // • Containment (finding 13): a codeRoot is honored ONLY if it resolves under // ~/.cache/ruvnet-brain/versions/ — or is the explicit dev-mode checkout declared in @@ -71,9 +71,9 @@ catch (e) { BRAIN_OFF = !(e && (e.code === 'ENOENT' || e.code === 'ENOTDIR')); } // // 'silence' — this hook exists to advertise, ground, or learn. Off means it does not run at all // and writes ZERO bytes. Nothing downstream can tell it apart from not being installed. -// 'run' — this is a SAFETY WALL that guards money or honesty, not retrieval. route-dispatch -// stops a subagent fan-out inheriting an expensive model; design-wall stops an -// ungraded surface shipping; protect-state guards the user's own consent record. +// 'run' — this protection/audit remains relevant without retrieval. route-dispatch records +// inherited-model fan-out; design-wall stops an ungraded surface shipping; +// protect-state guards the user's own consent record. // None becomes acceptable because retrieval is off. // 'partial' — the hook splits INTERNALLY. session-start-core still runs the auto-updater heartbeat, the // GONG health alarm and the SLA banner (an off machine must still receive fixes, @@ -84,7 +84,9 @@ const TABLE = { 'session-start': { file: 'session-start-core.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'partial' }, 'ground-ruvnet': { file: 'ground-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence', stdinBytes: 32768 }, 'hijack-ruvnet': { file: 'hijack-ruvnet.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' }, - 'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 }, + // Claude Code 2.1.220 consumes Agent/Task PreToolUse results after tool_dispatch_end (#84). + // A refusal here would be late and therefore false enforcement; retain only bounded audit. + 'route-dispatch': { file: 'route-dispatch.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 }, 'ground-before-write': { file: 'ground-before-write.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 }, 'grounding-stamp': { file: 'grounding-stamp.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' }, 'verify-interface': { file: 'verify-interface.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' }, @@ -94,6 +96,7 @@ const TABLE = { 'protect-state': { file: 'protect-brain-state.sh', interpreter: 'bash', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 }, 'learn-capture': { file: 'learn-capture.sh', interpreter: 'bash', mode: 'advisory', offBehavior: 'silence' }, 'learn-flush': { file: 'learn-flush.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' }, + 'session-snapshot': { file: 'session-snapshot-hook.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run', stdinBytes: 65536 }, 'md-stamp': { file: 'md-stamp.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' }, // THE EXTERNAL-SIGNAL WATCH PLANE, W1 OBSERVED (ADR-058 §D3; DDD-0013 Context 2). PostToolUse, // matcher ^Bash$ (anchored — an unanchored matcher is F3/F4). Classifies gh/vercel/netlify/npm @@ -105,6 +108,10 @@ const TABLE = { // session-start-core.mjs, and that surfacing already lives under SessionStart's 'partial' contract. 'signal-watch': { file: 'signal-watch.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'silence' }, 'routing-outcome': { file: 'routing-outcome-capture.mjs', interpreter: 'node', mode: 'advisory', offBehavior: 'run' }, + // Claude Code's synchronous TeammateIdle boundary can prevent a worker slot from going unused + // while its host-owned shared ledger has ready work. The body is read-only and fail-open; exit 2 + // is reserved for one proved, unassigned, dependency-ready task. Codex has no equivalent event. + 'swarm-slot-recycler': { file: 'swarm-slot-recycler.mjs', interpreter: 'node', mode: 'blocking', offBehavior: 'run', stdinBytes: 65536 }, // The unprompted-speech chokepoint (ADR-040 / DDD-0004). ONE runtime is the sole writer of // user-facing bytes for every unprompted hook: it spawns the real producers (anticipate, lesson) // in candidate mode, applies the per-channel policy, and writes the final envelope itself. `channel` diff --git a/plugin/scripts/lesson-provenance.mjs b/plugin/scripts/lesson-provenance.mjs new file mode 100644 index 00000000..888a74d7 --- /dev/null +++ b/plugin/scripts/lesson-provenance.mjs @@ -0,0 +1,21 @@ +export const SOURCE_CLASS = Object.freeze({ + CURRENT_USER: 'current-user', + IMPORTED_OWNER: 'imported-owner', + MODEL_INFERRED: 'model-inferred', + DEMONSTRATION: 'demonstration', +}); + +export const BUNDLED_OWNER_SEED_IDS = new Set([ + 'L01-verify-with-a-capable-channel', + 'L02-check-before-you-assert', + 'L03-research-before-recommending', + 'L04-never-relay-a-number', + 'L05-version-is-the-update-signal', + 'L06-use-the-real-tool', + 'L07-blast-radius-not-social-comfort', + 'L08-status-is-a-table', + 'L09-gradeable-is-not-valuable', + 'L10-under-enumeration-is-a-tell', + 'L11-retrieval-without-volition-is-broken', + 'L12-efficiency-seeking-is-the-tell', +]); diff --git a/plugin/scripts/lesson-store.mjs b/plugin/scripts/lesson-store.mjs index 6114d069..478464d8 100644 --- a/plugin/scripts/lesson-store.mjs +++ b/plugin/scripts/lesson-store.mjs @@ -23,6 +23,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs'; + +export { BUNDLED_OWNER_SEED_IDS, SOURCE_CLASS } from './lesson-provenance.mjs'; /** Resolve fixture/plugin configuration without mutating the child process account HOME. */ export function resolveConfigRoot(env = process.env, home = os.homedir()) { @@ -99,6 +102,8 @@ export const ORIGIN = Object.freeze({ }); const ORIGIN_VALUES = new Set(Object.values(ORIGIN)); +const SOURCE_CLASS_VALUES = new Set(Object.values(SOURCE_CLASS)); + /** * STATUS — the ratification ladder. A lesson does not become policy by existing. * candidate → ratified (a human agreed) → active (in force at its trigger). @@ -128,6 +133,11 @@ export function makeLesson(spec) { severity = 'normal', // 'normal' | 'high' — see weightOf() intendedEnforcement = null, // what it should become once a human ratifies it ratifiedBy = null, + sourceClass = origin === ORIGIN.USER_STATED + ? SOURCE_CLASS.CURRENT_USER + : origin === ORIGIN.MODEL_INFERRED + ? SOURCE_CLASS.MODEL_INFERRED + : SOURCE_CLASS.IMPORTED_OWNER, } = spec; const err = (m) => { throw new Error(`Lesson "${id ?? '?'}" invalid: ${m}`); }; @@ -151,6 +161,10 @@ export function makeLesson(spec) { } if (!ORIGIN_VALUES.has(origin)) err(`origin must be one of: ${[...ORIGIN_VALUES].join(', ')}`); + if (!SOURCE_CLASS_VALUES.has(sourceClass)) err(`sourceClass must be one of: ${[...SOURCE_CLASS_VALUES].join(', ')}`); + if (sourceClass === SOURCE_CLASS.CURRENT_USER && origin !== ORIGIN.USER_STATED) { + err('sourceClass:current-user requires origin:user-stated'); + } if (!STATUS_VALUES.has(status)) err(`status must be one of: ${[...STATUS_VALUES].join(', ')}`); // THE TRUST BOUNDARY. A lesson the model wrote about itself, or one imported from a repo, cannot @@ -166,7 +180,7 @@ export function makeLesson(spec) { return Object.freeze({ id, statement, trigger, enforcement, evidence, - surface, origin, status, severity, + surface, origin, sourceClass, status, severity, intendedEnforcement: intendedEnforcement ?? null, ratifiedBy: ratifiedBy ?? null, projects: [...projects], @@ -255,7 +269,12 @@ export function loadLessons(file = STORE_PATH) { // to edit and delete these); a malformed entry must be dropped loudly rather than acted upon. const out = []; const dropped = []; - for (const l of raw.lessons || []) { + const rows = Array.isArray(raw.lessons) ? raw.lessons : []; + const ids = new Set(rows.map((lesson) => lesson?.id)); + const legacyOwnerSeed = rows.length === BUNDLED_OWNER_SEED_IDS.size + && ids.size === BUNDLED_OWNER_SEED_IDS.size + && [...BUNDLED_OWNER_SEED_IDS].every((id) => ids.has(id)); + for (const stored of rows) { // SKIP THE BAD ROW, BUT NEVER SILENTLY. An adversarial review proved that a schema change // (ADR-035 proposes new enforcement values the current enum rejects) would take this store // from 16 lessons to 0 with NO error and exit 0 — output indistinguishable from "no lessons @@ -264,6 +283,14 @@ export function loadLessons(file = STORE_PATH) { // // A store that empties itself quietly is the worst possible failure here, because the whole // product promise is "you should never have to tell me twice." + const l = legacyOwnerSeed ? { + ...stored, + origin: ORIGIN.IMPORTED, + sourceClass: SOURCE_CLASS.IMPORTED_OWNER, + status: STATUS.CANDIDATE, + demoted: true, + ratifiedBy: null, + } : stored; try { out.push(makeLesson(l)); } catch (e) { dropped.push({ id: l && l.id, why: String(e && e.message || e) }); } @@ -435,6 +462,7 @@ export function restore(id, lessons) { export function ratify(id, lessons, { by = 'user' } = {}) { return lessons.map((l) => { if (l.id !== id) return l; + if (l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION) return l; const target = l.intendedEnforcement || l.enforcement; const canBlock = l.origin === ORIGIN.USER_STATED; return makeLesson({ @@ -448,5 +476,7 @@ export function ratify(id, lessons, { by = 'user' } = {}) { /** Lessons awaiting a human decision — what the management surface must show first. */ export function pending(lessons) { - return lessons.filter((l) => l.status === STATUS.CANDIDATE && !l.demoted); + return lessons.filter((l) => l.status === STATUS.CANDIDATE && !l.demoted + && l.sourceClass !== SOURCE_CLASS.IMPORTED_OWNER + && l.sourceClass !== SOURCE_CLASS.DEMONSTRATION); } diff --git a/plugin/scripts/route-dispatch.sh b/plugin/scripts/route-dispatch.sh index 978f695d..866bfc54 100755 --- a/plugin/scripts/route-dispatch.sh +++ b/plugin/scripts/route-dispatch.sh @@ -1,5 +1,5 @@ #!/bin/bash -# route-dispatch.sh — PreToolUse gate on subagent dispatch. Ends model-inheritance-by-omission. +# route-dispatch.sh — bounded PreToolUse audit of subagent model selection. # # ───────────────────────────────────────────────────────────────────────────────────────────────── # THE LEAK (2026-07-13). Stuart: "What happens when I'm right here in Opus 4.8 and it has 10 things @@ -8,8 +8,12 @@ # A SUBAGENT INHERITS THE MAIN-LOOP MODEL UNLESS `model` IS EXPLICITLY PASSED. # # Ten agents on a Fable session = ten agents at $10/$50 per Mtok, ~10x Haiku for identical mechanical -# work. The router existed; the rule to use it existed; the router's ENTIRE LIFETIME OUTPUT was 3 test -# pings and $0.018 saved — because the rule was ADVISORY. So this is a wall, not advice. +# work. This hook records declared and inherited dispatches so the leak remains measurable. +# +# HOST LIMITATION (#84): Claude Code 2.1.220 registers Agent/Task PreToolUse hooks asynchronously, +# completes the subagent dispatch, and only then consumes the hook result. An exit-2 refusal is +# therefore too late to block and must not be represented as enforcement. The hook is intentionally +# silent and advisory until the host provides a synchronous pre-dispatch decision boundary. # # ───────────────────────────────────────────────────────────────────────────────────────────────── # THREE DEFECTS IN MY OWN FIRST VERSION, caught by asking the questions Stuart would have asked @@ -21,14 +25,10 @@ # (a model-router profile.json exists = they answered the two subscription questions). Everyone # else gets NOTHING — not even a warning. Consent is the default. # 2. IT REQUIRED python3. The other three plugin hooks are pure bash. A hard dependency inside a -# BLOCKING hook is how you brick someone's session. Now pure bash — no interpreters. -# 3. IT COULD FAIL CLOSED. A blocking hook that errors must never take the session with it. Every -# unparseable/ambiguous case now FAILS OPEN (exit 0). A gate that breaks your tools is worse -# than the leak it prevents. +# hook is how you brick someone's session. Now pure bash — no interpreters. +# 3. IT COULD FAIL CLOSED. Every unparseable/ambiguous case fails open (exit 0). # -# CONTRACT (verified against this machine's live hook config): -# exit 0 → allow -# exit 2 + stderr → BLOCK, and stderr comes back to the model as the reason (so it retries correctly) +# CONTRACT: always exit 0 and emit no user-facing bytes. Audit receipts are best-effort only. # ───────────────────────────────────────────────────────────────────────────────────────────────── set -uo pipefail @@ -93,9 +93,10 @@ TOOL_USE_ID=$(field tool_use_id) SESSION_ID=$(field session_id) DESC="${DESC// /_}"; DESC="${DESC:0:40}" # builtin substitution — no `tr`, no `cut` -if [ -n "$MODEL" ]; then - # Declared. Log it so routing is AUDITABLE, not merely claimed — a growing ledger is evidence; - # a promise is not. (This log is how the $0.018-lifetime failure became visible in the first place.) +log_dispatch() { + local selected_model="$1" + local enforcement="$2" + # Log so routing is AUDITABLE, not merely claimed — a growing ledger is evidence; a promise is not. # `date` is the ONE external command left, and only on the ALLOW path — so its absence must be # silent, not a stderr spew from a hook that just said "yes". (bash's printf %()T would avoid it # entirely, but macOS still ships bash 3.2, which does not support it.) @@ -105,44 +106,17 @@ if [ -n "$MODEL" ]; then { TS=$(date -u +%FT%TZ) || TS="unknown" # the one external command, and only on the allow path mkdir -p "$HOME/.claude/metaharness" - printf '{"ts":"%s","event":"dispatch","model":"%s","agent":"%s","task":"%s","toolUseId":"%s","sessionId":"%s"}\n' \ - "$TS" "$MODEL" "${SUBTYPE:-unknown}" "${DESC:-unlabeled}" "${TOOL_USE_ID:-}" "${SESSION_ID:-}" \ + printf '{"ts":"%s","event":"dispatch","model":"%s","enforcement":"%s","agent":"%s","task":"%s","toolUseId":"%s","sessionId":"%s"}\n' \ + "$TS" "$selected_model" "$enforcement" "${SUBTYPE:-unknown}" "${DESC:-unlabeled}" "${TOOL_USE_ID:-}" "${SESSION_ID:-}" \ >> "$HOME/.claude/metaharness/dispatch-log.jsonl" } 2>/dev/null || true +} + +if [ -n "$MODEL" ]; then + log_dispatch "$MODEL" "declared" exit 0 fi -# ── BLOCKED: no model declared → it would silently inherit the session model. ── -# `read` + `printf` are BUILTINS. The original used `cat >&2 <" --json - -Then log the receipt when it returns, so the saving is visible instead of asserted: - - node scripts/dispatch-receipt.mjs --model --inherited \ - --task "" --total-tokens - -Deliberate exception (rare — and say WHY out loud): RUVNET_ALLOW_INHERITED_MODEL=1 -EOF -bash "$(dirname "${BASH_SOURCE[0]}")/gate-receipt.sh" route-dispatch "subagent" "would inherit the session model instead of routing to a cheaper one" 2>/dev/null || true -printf '%s\n' "$BLOCK_MSG" >&2 -exit 2 +# Missing model: record the inheritance leak, but do not emit a late refusal the host cannot enforce. +log_dispatch "inherited" "advisory-host-timing" +exit 0 diff --git a/plugin/scripts/session-snapshot-contract.mjs b/plugin/scripts/session-snapshot-contract.mjs new file mode 100644 index 00000000..964becaf --- /dev/null +++ b/plugin/scripts/session-snapshot-contract.mjs @@ -0,0 +1,100 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export const SNAPSHOT_SCHEMA = 'ruvnet-brain.session-snapshot'; +export const SNAPSHOT_VERSION = 1; +export const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; + +const EVENTS = new Set(['PreCompact', 'PostCompact', 'SessionEnd']); + +export function createSessionSnapshot({ event, capturedAt = new Date().toISOString() }) { + if (!EVENTS.has(event)) throw new Error(`unsupported snapshot event: ${event}`); + if (!Number.isFinite(Date.parse(capturedAt))) throw new Error('capturedAt must be an ISO-8601 timestamp'); + return { + schema: SNAPSHOT_SCHEMA, + version: SNAPSHOT_VERSION, + capturedAt, + boundary: { event }, + privacy: { rawTranscriptStored: false, credentialValuesStored: false }, + }; +} + +export function validateSessionSnapshot(value) { + return Boolean(value) + && typeof value === 'object' + && !Array.isArray(value) + && value.schema === SNAPSHOT_SCHEMA + && value.version === SNAPSHOT_VERSION + && EVENTS.has(value.boundary?.event) + && Number.isFinite(Date.parse(value.capturedAt)) + && value.privacy?.rawTranscriptStored === false + && value.privacy?.credentialValuesStored === false; +} + +function freshness(capturedAt, now) { + const age = now - Date.parse(capturedAt); + return age >= 0 && age <= SNAPSHOT_MAX_AGE_MS; +} + +function canonical(projectDir, now) { + const file = path.join(projectDir, '.swarm', 'agentdb-sessions.jsonl'); + if (!fs.existsSync(file)) return { values: [], malformed: false }; + try { + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) { + return { values: [], malformed: true }; + } + const lines = fs.readFileSync(file, 'utf8').trim().split('\n').filter(Boolean).slice(-128); + const values = []; + let malformed = false; + for (const line of lines) { + try { + const value = JSON.parse(line); + if (validateSessionSnapshot(value)) values.push({ kind: 'canonical', fresh: freshness(value.capturedAt, now), capturedAt: value.capturedAt }); + else malformed = true; + } catch { malformed = true; } + } + return { values, malformed }; + } catch { return { values: [], malformed: true }; } +} + +function legacy(projectDir, now) { + const values = []; + let malformed = false; + for (const root of ['.claude', '.claude-flow']) { + const directory = path.join(projectDir, root, 'sessions'); + if (!fs.existsSync(directory)) continue; + let names; + try { + const stat = fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) return { values: [], malformed: true }; + names = fs.readdirSync(directory).filter((name) => /^session-.*\.json$/.test(name)).slice(-64); + } catch { malformed = true; continue; } + for (const name of names) { + try { + const file = path.join(directory, name); + const stat = fs.lstatSync(file); + if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 1024 * 1024) { malformed = true; continue; } + const value = JSON.parse(fs.readFileSync(file, 'utf8')); + const capturedAt = value.endedAt || value.startedAt; + if (typeof value.id !== 'string' || !value.id || !value.context || !value.metrics || !Number.isFinite(Date.parse(capturedAt))) { + malformed = true; + continue; + } + values.push({ kind: 'legacy', fresh: freshness(capturedAt, now), capturedAt }); + } catch { malformed = true; } + } + } + return { values, malformed }; +} + +export function inspectSessionSnapshots(projectDir, { now = Date.now() } = {}) { + const results = [canonical(projectDir, now), legacy(projectDir, now)]; + const values = results.flatMap((result) => result.values); + const priority = { canonical: 0, legacy: 1 }; + const fresh = values.filter((value) => value.fresh).sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0]; + if (fresh) return fresh; + if (values.length) return values.sort((a, b) => priority[a.kind] - priority[b.kind] || Date.parse(b.capturedAt) - Date.parse(a.capturedAt))[0]; + if (results.some((result) => result.malformed)) return { kind: 'malformed', fresh: false }; + return { kind: 'absent', fresh: false }; +} diff --git a/plugin/scripts/session-snapshot-hook.mjs b/plugin/scripts/session-snapshot-hook.mjs new file mode 100644 index 00000000..2a89d1b0 --- /dev/null +++ b/plugin/scripts/session-snapshot-hook.mjs @@ -0,0 +1,34 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createSessionSnapshot } from './session-snapshot-contract.mjs'; + +function regularOrAbsent(file) { + try { + const stat = fs.lstatSync(file); + return stat.isFile() && !stat.isSymbolicLink(); + } catch (error) { + return error?.code === 'ENOENT'; + } +} + +export function writeSessionSnapshot(projectDir, event) { + const swarm = path.join(projectDir, '.swarm'); + const target = path.join(swarm, 'agentdb-sessions.jsonl'); + try { + if (fs.existsSync(swarm)) { + const stat = fs.lstatSync(swarm); + if (!stat.isDirectory() || stat.isSymbolicLink()) return false; + } else { + fs.mkdirSync(swarm, { recursive: false, mode: 0o700 }); + } + if (!regularOrAbsent(target)) return false; + fs.appendFileSync(target, `${JSON.stringify(createSessionSnapshot({ event }))}\n`, { mode: 0o600 }); + return true; + } catch { + return false; + } +} + +if (process.argv[1] && path.resolve(process.argv[1]).endsWith('session-snapshot-hook.mjs')) { + writeSessionSnapshot(process.env.CLAUDE_PROJECT_DIR || process.cwd(), process.argv[2] || 'SessionEnd'); +} diff --git a/plugin/scripts/session-start-core.mjs b/plugin/scripts/session-start-core.mjs index b048f2ab..f431079f 100755 --- a/plugin/scripts/session-start-core.mjs +++ b/plugin/scripts/session-start-core.mjs @@ -62,9 +62,30 @@ const dispatchDetached = (hookDir, ttl, log, command, args = [], env = process.e String(ttl), log, command, ...args, ], { env, stdio: 'ignore', timeout: 2000 })?.status === 0; -const surfaceIssues = (stateDir, emit, now) => { +export const maintainerIssueEntitlement = (env, home, repo, platform = process.platform) => { + const file = env.RUVNET_BRAIN_MAINTAINER_ISSUES_FILE + || path.join(home, '.config', 'ruvnet-brain', 'maintainer-issues.json'); + // Windows ACL ownership is not available through this dependency-free hot path. Fail closed + // instead of weakening an owner-only promise into "any local user who can write the file". + if (platform === 'win32') return false; + let stat; + try { stat = fs.lstatSync(file); } catch { return false; } + if (!stat.isFile() || stat.isSymbolicLink()) return false; + // This is maintainer-only operational data. Refuse group/world-readable opt-ins on POSIX so a + // shared machine cannot turn a private maintainer signal into a terminal banner for other users. + if ((stat.mode & 0o077) !== 0) return false; + if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return false; + const entitlement = json(file); + return entitlement?.enabled === true + && Array.isArray(entitlement.repos) + && entitlement.repos.includes(repo); +}; + +const surfaceIssues = (stateDir, emit, now, env, home, platform) => { const status = json(path.join(stateDir, 'open-issues.json')); - if (!status?.at || now - new Date(status.at).getTime() > 6 * 3600_000) return; + const observedAt = Date.parse(status?.at || ''); + if (!Number.isFinite(observedAt) || observedAt > now + 5 * 60_000 || now - observedAt > 6 * 3600_000) return; + if (!maintainerIssueEntitlement(env, home, status.repo, platform)) return; const open = Array.isArray(status.issues) ? status.issues : []; if (!open.length) return; const breaches = open.filter((issue) => issue.breach).sort((a, b) => b.ageHours - a.ageHours); @@ -383,7 +404,7 @@ export async function runSessionStart({ emit(`Offer ONCE: "Want to see your whole RuvNet stack on one page?" — installed parts, learned project knowledge and reversible fixes, read-only until clicked; later it's ${consoleInvoke}. On yes invoke ${consoleInvoke}; on no, don't re-offer.`); } - surfaceIssues(stateDir, emit, now); + surfaceIssues(stateDir, emit, now, env, home, platform); surfaceSignals({ env, cwd, stateDir, hookDir, emit, now }); const routerProfile = path.join(home, '.claude', 'model-router', 'profile.json'); @@ -476,10 +497,7 @@ export async function runSessionStart({ const playbook = `${hookDir}${path.sep}..${path.sep}skills${path.sep}ruvnet-brain${path.sep}PLAYBOOK.md`; emit('[RuvNet Brain — standing build playbook for this session (referenced by later turns as THE PLAYBOOK)]'); - emit(`Full text: ${playbook} — read it before your first build response this session. Condensed:`); - emit('Every build/change request: take the wheel. FIRST, silently — read the files this touches in THEIR repo; search_ruvnet what the feature technically DOES; check project memory.'); - emit('⛔ NO SILENT SUBSTITUTION (#1 trust-killer): never hand-roll, or aim a generic Task subagent at, work a RuvNet tool owns — QE=agentic-qe, swarms=ruflo, routing=agentic-flow, vectors=RuVector, memory=AgentDB, red/blue=@metaharness/redblue. Use the real one; if absent offer the exact install; if unusable say so out loud, every time. Never give your own code its name.'); - emit('Beats A-D are in that file, in full. In short: A RESPOND in one voice (hear them; THE ATTACK as one lettered plan over their real files; why it holds; what you checked; "Build it now?") · B ON A YES EXECUTE END-TO-END (SPARC with a QA gate per phase, DDD, ADRs, PARALLEL Ruflo swarm work, AgentDB persistence, frontend-design + real image generation, a PROVEN result scored to >=98, ONE ask for a missing API key) · C TAKE OVER what you do well · D keep them oriented. RUN THE PROCESS.'); + emit(`Read ${playbook} before the first build response. It requires source inspection, search_ruvnet grounding, project-memory recall, and the real owning rUv tool—never a silent hand-roll or generic substitute.`); } } catch (error) { if (env.RUVNET_SESSION_TRACE === '1') stderr.write(`SESSION_TRACE native-fail-open ${error?.message || error}\n`); diff --git a/plugin/scripts/swarm-slot-recycler.mjs b/plugin/scripts/swarm-slot-recycler.mjs new file mode 100644 index 00000000..69e434a6 --- /dev/null +++ b/plugin/scripts/swarm-slot-recycler.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +// swarm-slot-recycler.mjs — Claude Code TeammateIdle recycling boundary. +// +// Claude owns the shared task files and their claim locks. This hook never edits them. It reads the +// team ledger at the synchronous TeammateIdle boundary and refuses idling only when it can prove an +// unassigned pending task is ready. Claude then performs the normal locked TaskUpdate claim. Missing, +// malformed, dependency-blocked, or ambiguous state fails open: a scheduler must not invent work. +// +// Host boundary: Claude Code exposes TeammateIdle; Codex 0.146.0 exposes SubagentStop but no +// TeammateIdle/TaskCompleted event or equivalent shared-task ledger. Do not register this body in the +// Codex manifest and do not claim Codex recycling is hook-enforced. + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { readStdinBounded } from './hook-input.mjs'; + +const MAX_INPUT_BYTES = 64 * 1024; +const MAX_TASK_BYTES = 256 * 1024; +const SAFE_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/; + +function allowIdle() { process.exit(0); } + +let raw = Buffer.alloc(0); +try { + raw = await readStdinBounded({ maxBytes: MAX_INPUT_BYTES, idleMs: 50, emptyMs: 250 }); +} catch { allowIdle(); } + +let event; +try { + event = JSON.parse(raw.toString('utf8')); +} catch { allowIdle(); } + +if (!event || typeof event !== 'object' || Array.isArray(event)) allowIdle(); +if (event.hook_event_name !== 'TeammateIdle') allowIdle(); + +const teamName = String(event.team_name || ''); +const teammateName = String(event.teammate_name || ''); +if (!SAFE_NAME.test(teamName) || !SAFE_NAME.test(teammateName)) allowIdle(); + +const tasksRoot = process.env.RUVNET_CLAUDE_TASKS_DIR + || path.join(os.homedir(), '.claude', 'tasks'); +const teamDir = path.resolve(tasksRoot, teamName); +const root = path.resolve(tasksRoot); +if (path.dirname(teamDir) !== root) allowIdle(); + +let files; +try { + files = fs.readdirSync(teamDir, { withFileTypes: true }) + .filter((entry) => entry.isFile() && /^\d+\.json$/.test(entry.name)) + .map((entry) => entry.name); +} catch { allowIdle(); } + +const tasks = new Map(); +for (const file of files) { + try { + const taskPath = path.join(teamDir, file); + const stat = fs.statSync(taskPath); + if (!stat.isFile() || stat.size > MAX_TASK_BYTES) continue; + const task = JSON.parse(fs.readFileSync(taskPath, 'utf8')); + if (!task || typeof task !== 'object' || Array.isArray(task)) continue; + const id = String(task.id || path.basename(file, '.json')); + if (!/^\d+$/.test(id)) continue; + tasks.set(id, task); + } catch { /* one bad task cannot make another look ready */ } +} + +const completed = new Set( + [...tasks].filter(([, task]) => task.status === 'completed').map(([id]) => id), +); + +const ready = [...tasks] + .filter(([, task]) => task.status === 'pending') + .filter(([, task]) => !String(task.owner || '').trim()) + .filter(([, task]) => { + if (!Array.isArray(task.blockedBy)) return false; + return task.blockedBy.every((id) => completed.has(String(id))); + }) + .sort(([left], [right]) => Number(left) - Number(right)); + +if (!ready.length) allowIdle(); + +const [id, task] = ready[0]; +const subject = String(task.subject || 'untitled task').replace(/[\r\n]+/g, ' ').slice(0, 160); +process.stderr.write( + `Ready work remains. Claim task ${id} (${subject}) now with TaskUpdate owner=${teammateName} ` + + 'and status=in_progress, then execute it. Do not go idle while an unassigned, unblocked pending task exists.\n', +); +process.exit(2); diff --git a/plugin/skills/release-proof/SKILL.md b/plugin/skills/release-proof/SKILL.md index a63673bb..a937ccd0 100644 --- a/plugin/skills/release-proof/SKILL.md +++ b/plugin/skills/release-proof/SKILL.md @@ -30,10 +30,10 @@ green. ## Candidate seal Generate the receipt from commands in the protected candidate workflow. Do not hand-author it. -For generation 4.0.4, dispatch `.github/workflows/protected-release.yml` only with the full candidate -SHA, sealed artifact SHA-256, exact version `4.0.4`, and the successful exact-SHA CI run ID whose -named `release-qe` job produced `release-evidence-`. The workflow checks every binding before -creating its sealed handoff and again after the production reviewer approves. Missing artifacts, +Dispatch `.github/workflows/protected-release.yml` only with the full candidate SHA, the exact +current version, and the successful exact-SHA CI run ID whose named `release-qe` job produced +`release-evidence-`. The workflow derives the artifact digest from those sealed bytes and checks +every binding before creating its handoff and again at the Production boundary. Missing artifacts, pending/red jobs, malformed inputs, version splits, and byte mismatches stop before the publisher. Validate it from the repository with: diff --git a/plugin/skills/ruvnet-brain/PLAYBOOK.md b/plugin/skills/ruvnet-brain/PLAYBOOK.md index 47f166fa..2baa1855 100644 --- a/plugin/skills/ruvnet-brain/PLAYBOOK.md +++ b/plugin/skills/ruvnet-brain/PLAYBOOK.md @@ -1,6 +1,6 @@ # THE PLAYBOOK — the standing build playbook, in full -Updated: 2026-07-30 | Version 1.0.1 +Updated: 2026-08-02 | Version 1.1.0 Created: 2026-07-27 **Read this before your first build response in a session.** `plugin/scripts/session-start.sh` @@ -87,10 +87,25 @@ rule-compliance, cite a source the tools didn't return, or claim a check that di Completion, with a QA gate between phases. - For a non-trivial domain, model it first (DDD: bounded contexts, aggregates, domain events) and capture key decisions as ADRs — design before code. -- Spin up PARALLEL work where it helps (a Ruflo swarm / multiple agents) instead of serial drudgery. +- **Parallel-by-default state machine for multi-part work.** Before the first spawn, decompose the + whole request, create the complete shared task list/ledger, record dependencies and give every + writing task its own worktree. Ruflo coordinates roles and state; the native host executes. Fill + available executor slots with independent ready work immediately, without asking, up to the + host's configured capacity; never oversubscribe and never put more than one writer in a worktree. + A completion moves that task to completed, unblocks its dependents, and the freed slot claims the + first unassigned, unblocked pending task immediately. Only allow a slot to idle when no such task + exists. Keep dependent integration with the designated integration owner. + - **Claude Code:** its shared task ledger and `TeammateIdle` hook make recycling enforceable: the + shipped recycler refuses idle while a ready unassigned task exists, then Claude's locked + `TaskUpdate` claim performs the transition. + - **Codex:** Codex 0.146.0 exposes no `TeammateIdle` or `TaskCompleted` hook and no equivalent + shared-task hook ledger. Initial fan-out and completion-notification recycling are guidance, + not hook enforcement: the lead must immediately dispatch the next ready ledger item when a + collaboration slot completes. State this degraded boundary if it affects the run; never call it + enforced. If Ruflo / RuVector MCP tools aren't available in this environment, DON'T block or stall — degrade - gracefully to Claude Code's native subagents (Task) and local .rvf, and briefly note the tool that - would make it better + how to add it. Never demand a tool the user doesn't have. + gracefully to the native host's agents and local .rvf, and briefly note the tool that would make + it better + how to add it. Never demand a tool the user doesn't have. - Persist decisions + state to AgentDB memory so nothing is lost across sessions or compaction. - If it has a UI, treat design as a BUILD STEP, not a coat of paint: apply the frontend-design discipline and GENERATE the visuals (AI image generation for UI mockups / diagrams / the explainer diff --git a/primer/ruvnet-primer.md b/primer/ruvnet-primer.md index c1d82675..c4e972e0 100644 --- a/primer/ruvnet-primer.md +++ b/primer/ruvnet-primer.md @@ -1,6 +1,6 @@ # The RuvNet Primer — the building blocks, on one page -`Brain version: v4.0.6 · Built: 2026-07-30 · Covers: 69/192 repos built @ pinned SHAs (see data/manifest.json)` +`Brain version: v4.0.7 · Built: 2026-07-30 · Covers: 69/192 repos built @ pinned SHAs (see data/manifest.json)` > **What this is:** a portable, source-grounded "brain" over the reusable RuvNet building blocks by > **Reuven Cohen (rUv)**. It ships as a **Claude Code plugin** so your assistant answers from Ruv's real diff --git a/scripts/capability-registry.mjs b/scripts/capability-registry.mjs index 8c26c5ef..44612514 100644 --- a/scripts/capability-registry.mjs +++ b/scripts/capability-registry.mjs @@ -515,9 +515,9 @@ export const CAPABILITIES = [ // router was in fact never being consulted at all. A quiet week and a severed wire look identical // from the receipt file, so read the wire directly. // - // Two things must both be true for anything to route: a PreToolUse gate on subagent dispatch - // (plugin/scripts/route-dispatch.sh, which is what turns "declare a model" from advice into a - // wall), and the opt-in profile it refuses to act without (route-dispatch.sh:46 exits 0 when + // Two things must both be true for the host-limited dispatch audit to record anything: a + // PreToolUse hook on subagent dispatch and the opt-in profile it refuses to act without + // (route-dispatch.sh exits 0 when // profile.json is absent). Either missing ⇒ the router cannot fire, regardless of how healthy // the receipt ledger looks. const profile = fs.existsSync(path.join(HOME, '.claude/model-router/profile.json')); diff --git a/scripts/lesson-lifecycle.mjs b/scripts/lesson-lifecycle.mjs index 4466ac0f..112f5692 100644 --- a/scripts/lesson-lifecycle.mjs +++ b/scripts/lesson-lifecycle.mjs @@ -43,7 +43,7 @@ import fs from 'node:fs'; import path from 'node:path'; import crypto from 'node:crypto'; -import { ORIGIN, STATUS, ENFORCEMENT, loadLessons } from './lesson-store.mjs'; +import { ORIGIN, SOURCE_CLASS, STATUS, ENFORCEMENT, loadLessons } from './lesson-store.mjs'; // ── The bars, in one place, as numbers a human can argue with ──────────────────────────────────── export const RETIREMENT = Object.freeze({ @@ -385,6 +385,7 @@ export function explainGeneralization(lesson, otherProjects = [], { minProjects // `{...lesson, ...proposal}`, so the result cannot block even if the source lesson was ratified // and blocking — the trust boundary is enforced by the data, not by the caller remembering. origin: ORIGIN.MODEL_INFERRED, + sourceClass: SOURCE_CLASS.MODEL_INFERRED, status: STATUS.CANDIDATE, enforcement: ENFORCEMENT.CHECKLIST, intendedEnforcement: null, diff --git a/scripts/lesson-ratify.mjs b/scripts/lesson-ratify.mjs index 709eba09..185a045e 100644 --- a/scripts/lesson-ratify.mjs +++ b/scripts/lesson-ratify.mjs @@ -26,7 +26,7 @@ * correctly, stops using it. */ import os from 'node:os'; -import { loadLessons, saveLessons, ratify, demote, weightOf, pending, ENFORCEMENT, STATUS, ORIGIN, TRIGGERS } from './lesson-store.mjs'; +import { loadLessons, saveLessons, ratify, demote, weightOf, pending, ENFORCEMENT, STATUS, ORIGIN, SOURCE_CLASS, TRIGGERS } from './lesson-store.mjs'; const argv = process.argv.slice(2); const arg = (f) => { const i = argv.indexOf(f); return i >= 0 && argv[i + 1] ? argv[i + 1] : null; }; @@ -34,7 +34,7 @@ const has = (f) => argv.includes(f); const lessons = loadLessons(); if (!lessons.length) { - console.log('\n No lessons stored yet. Seed them with: node scripts/lesson-seed.mjs --apply\n'); + console.log('\n No personal lessons stored yet. They appear after an explicit user correction is captured.\n'); process.exit(0); } @@ -86,7 +86,10 @@ if (has('--ratify')) { // Bulk convenience, deliberately scoped: it can only touch lessons the USER stated. Model-inferred // lessons are never swept up by a bulk action — that would be exactly the hole the boundary closes. let next = lessons; - const targets = lessons.filter((l) => l.origin === ORIGIN.USER_STATED && l.status === STATUS.CANDIDATE && !l.demoted); + const targets = lessons.filter((l) => l.origin === ORIGIN.USER_STATED + && l.sourceClass === SOURCE_CLASS.CURRENT_USER + && l.status === STATUS.CANDIDATE + && !l.demoted); for (const l of targets) next = ratify(l.id, next); saveLessons(next); const nowBlocking = next.filter((l) => l.enforcement === ENFORCEMENT.BLOCK).length; diff --git a/scripts/lesson-seed.mjs b/scripts/lesson-seed.mjs index 3e790ffa..eaf42b36 100644 --- a/scripts/lesson-seed.mjs +++ b/scripts/lesson-seed.mjs @@ -17,17 +17,22 @@ import os from 'node:os'; import { makeLesson, saveLessons, loadLessons, lessonsFor, unenforceable, pending, weightOf, - TRIGGERS as T, ENFORCEMENT as E, ORIGIN as O, + TRIGGERS as T, ENFORCEMENT as E, ORIGIN as O, SOURCE_CLASS, } from './lesson-store.mjs'; // Shipped at CHECKLIST; `intendedEnforcement` records what it becomes once a human ratifies it. const blocking = { enforcement: E.CHECKLIST, intendedEnforcement: E.BLOCK }; +const ownerImport = { + origin: O.IMPORTED, + sourceClass: SOURCE_CLASS.IMPORTED_OWNER, + demoted: true, +}; export const SEED = [ makeLesson({ id: 'L01-verify-with-a-capable-channel', ...blocking, - origin: O.USER_STATED, + ...ownerImport, severity: 'high', statement: 'Before claiming something works, verify through a channel CAPABLE of observing the change — an independent tool, a re-measurement, a read-write connection. Never the exit code of the thing being tested.', trigger: T.CLAIM_DONE.key, @@ -46,7 +51,7 @@ export const SEED = [ makeLesson({ id: 'L02-check-before-you-assert', ...blocking, - origin: O.USER_STATED, + ...ownerImport, severity: 'high', statement: 'Before stating any fact about the world — a version, an API, what a tool does, how an architecture works — read a live source THIS TURN and name it. Recalling is not checking. The urge to skip the check IS the signal you are about to be wrong.', trigger: T.ASSERT_FACT.key, @@ -64,7 +69,7 @@ export const SEED = [ makeLesson({ id: 'L03-research-before-recommending', enforcement: E.CHECKLIST, - origin: O.USER_STATED, + ...ownerImport, statement: 'Before recommending an architecture, research it: compare at least three real options with tradeoffs, and check whether the ecosystem already ships it. Pattern-matching from training data is not a recommendation.', trigger: T.RECOMMEND_ARCH.key, evidence: [ @@ -78,7 +83,7 @@ export const SEED = [ makeLesson({ id: 'L04-never-relay-a-number', enforcement: E.CHECKLIST, - origin: O.USER_STATED, + ...ownerImport, severity: 'high', statement: 'Never repeat a score, benchmark, or subagent result without re-checking the underlying artifact yourself. A number you did not measure is a claim you cannot defend.', trigger: T.RELAY_NUMBER.key, @@ -93,7 +98,7 @@ export const SEED = [ makeLesson({ id: 'L05-version-is-the-update-signal', ...blocking, - origin: O.USER_STATED, + ...ownerImport, severity: 'high', statement: 'Any behaviour-changing push bumps the version IN THE SAME COMMIT, and the release narrative is updated to match. A fix label on a new subsystem is a lie about what changed.', trigger: T.SHIP.key, @@ -109,7 +114,7 @@ export const SEED = [ makeLesson({ id: 'L06-use-the-real-tool', ...blocking, - origin: O.USER_STATED, + ...ownerImport, severity: 'high', statement: 'Before writing code in the RuvNet domain, search for the tool that already implements it. If you still disagree after genuinely looking, say so OUT LOUD, cite the source path, and name the hand-roll as a hand-roll. Never silently.', trigger: T.WRITE_CODE.key, @@ -127,7 +132,7 @@ export const SEED = [ makeLesson({ id: 'L07-blast-radius-not-social-comfort', ...blocking, - origin: O.USER_STATED, + ...ownerImport, severity: 'high', statement: 'Gate on blast radius, not on how awkward an action feels. Ask: is it reversible, and is it outward-facing? A silent irreversible change is worse than an awkward reversible one.', trigger: T.MUTATE_MACHINE.key, @@ -143,7 +148,7 @@ export const SEED = [ makeLesson({ id: 'L08-status-is-a-table', enforcement: E.CHECKLIST, - origin: O.USER_STATED, + ...ownerImport, statement: 'Report status as a structured table with an explicit shipped/tested column, never as narrative. Prose lets unfinished work live inside sentences about progress.', trigger: T.REPORT_STATUS.key, evidence: [ @@ -159,7 +164,7 @@ export const SEED = [ makeLesson({ id: 'L09-gradeable-is-not-valuable', enforcement: E.CHECKLIST, - origin: O.MODEL_INFERRED, + ...ownerImport, statement: 'When choosing what to work on, name the ungradeable items explicitly and commit to them. The instinct to pick the task with a green test routes systematically away from the user\'s actual value.', trigger: T.CHOOSE_WORK.key, evidence: [ @@ -172,7 +177,7 @@ export const SEED = [ makeLesson({ id: 'L10-under-enumeration-is-a-tell', enforcement: E.CHECKLIST, - origin: O.MODEL_INFERRED, + ...ownerImport, statement: 'When producing a list of failure modes, options, or requirements, state what was left out and why. A satisfyingly round number is evidence of rounding, not of completeness.', trigger: T.REPORT_STATUS.key, evidence: [ @@ -185,7 +190,7 @@ export const SEED = [ makeLesson({ id: 'L11-retrieval-without-volition-is-broken', enforcement: E.REVIEW, - origin: O.MODEL_INFERRED, + ...ownerImport, statement: 'A surface that CAN detect something useful and stays silent is broken. Judge every feature by whether it volunteers what it knows, not by whether it can answer when asked.', trigger: T.CHOOSE_WORK.key, evidence: [ @@ -199,7 +204,7 @@ export const SEED = [ makeLesson({ id: 'L12-efficiency-seeking-is-the-tell', enforcement: E.REVIEW, - origin: O.USER_STATED, + ...ownerImport, statement: 'Treat the impulse to save a step as a defect signal, not a virtue. Skipping a check to save a turn is the specific mechanism that produces wrong answers — effectiveness first, always.', trigger: T.CHOOSE_WORK.key, evidence: [ @@ -228,14 +233,14 @@ if (invokedDirectly) { for (const l of ls) { const shown = l.intendedEnforcement ? `${l.enforcement}→${l.intendedEnforcement}` : l.enforcement; console.log(` ${shown.padEnd(18)} ${l.id}`); - console.log(` ${''.padEnd(18)} ${l.origin === 'user-stated' ? 'you said it' : 'MODEL-INFERRED (quarantined — can never block)'} · taught ${l.repeatCount}× · weight ${weightOf(l)}`); + console.log(` ${''.padEnd(18)} IMPORTED MAINTAINER HISTORY (quarantined — never personal policy) · observed ${l.repeatCount}× · weight ${weightOf(l)}`); } console.log(''); } const un = unenforceable(SEED); const pend = pending(SEED); - console.log(` ${pend.length} awaiting YOUR ratification. Nothing here blocks until you agree to it —`); - console.log(` the model does not get to ratify its own rules.`); + console.log(` ${pend.length} awaiting ratification. Bundled maintainer history is quarantined and cannot`); + console.log(` become the installing user's personal policy.`); if (un.length) console.log(` ${un.length} are declared unenforceable (no hook can observe them); checked at review instead.`); console.log(''); diff --git a/scripts/model-router-catalog.mjs b/scripts/model-router-catalog.mjs new file mode 100644 index 00000000..c2551f92 --- /dev/null +++ b/scripts/model-router-catalog.mjs @@ -0,0 +1,16 @@ +// Managed facts are additive for existing users. Their existing row is the user overlay: it wins +// byte-for-byte, including disablement, tier/priority changes, and local candidates. Newly shipped +// metered rows are not auto-added; that would silently expand spend authority. +export function mergeManagedCatalog(existing, managed) { + const current = Array.isArray(existing?.candidates) ? existing.candidates : []; + const seen = new Set(current.map((candidate) => candidate?.id).filter(Boolean)); + const additions = (managed?.candidates || []).filter((candidate) => candidate?.id + && !seen.has(candidate.id) + && ((candidate.subscription || []).length > 0 || candidate.provider === 'local')); + const next = { + ...existing, + managedVersion: managed?.managedVersion || managed?.updated || null, + candidates: [...current, ...additions], + }; + return JSON.stringify(next) === JSON.stringify(existing) ? existing : next; +} diff --git a/scripts/model-router-engine.mjs b/scripts/model-router-engine.mjs index d99dbaed..336828aa 100644 --- a/scripts/model-router-engine.mjs +++ b/scripts/model-router-engine.mjs @@ -113,10 +113,13 @@ export function loadCatalog() { } catch { /* fall through to a minimal built-in so the engine still answers */ } - // Built-in fallback (verified OpenRouter prices from route-cheap; Anthropic frontier from same). + // Built-in fallback. Claude launchability was verified against Claude Code 2.1.220 on 2026-08-02; + // prices remain null where the subscription host, rather than a metered API, is authoritative. return [ { id: 'deepseek/deepseek-chat', provider: 'openrouter', harness: ['claude-code', 'codex'], tier: 'cheap', costPerMTok: { in: 0.2, out: 0.8 }, verified: '2026-07-07' }, { id: 'claude-opus-4-8', provider: 'anthropic', harness: ['claude-code'], tier: 'frontier', costPerMTok: { in: 5.0, out: 25.0 }, verified: '2026-07-07' }, + { id: 'claude-opus-5', provider: 'anthropic', harness: ['claude-code'], subscription: ['claude-code'], tier: 'frontier', costPerMTok: null, verified: '2026-08-02 Claude Code 2.1.220 launch' }, + { id: 'claude-fable-5', provider: 'anthropic', harness: ['claude-code'], subscription: ['claude-code'], tier: 'frontier', costPerMTok: null, verified: '2026-08-02 Claude Code 2.1.220 launch' }, { id: 'gpt-5.5', provider: 'openai', harness: ['codex'], tier: 'frontier', costPerMTok: null, verified: null }, ]; } diff --git a/scripts/onboarding-console.mjs b/scripts/onboarding-console.mjs index 633cc6bf..16403336 100644 --- a/scripts/onboarding-console.mjs +++ b/scripts/onboarding-console.mjs @@ -41,6 +41,8 @@ import { loadCatalog as engineCatalog, catalogSource as engineCatalogSource, loa import { effectivePrices, loadLabelledRows, MIN_LABELS, OUTCOMES } from './metaharness-router.mjs'; import { utilization } from './router-utilization.mjs'; import { loadCatalog, detectProvider, frontierFor } from './model-catalog.mjs'; +import { providerAvailability } from './provider-availability.mjs'; +import { inspectSessionSnapshots } from './session-snapshot-contract.mjs'; import { learnings } from './learnings.mjs'; import { gatesSurvey } from './gates.mjs'; // The write-safety primitives, borrowed rather than re-implemented. See saveConfig for why. @@ -59,7 +61,7 @@ import { } from '../kb/brain-profile.mjs'; // Lessons: read model + the two user verbs. Every mutation goes through lesson-store's own // updateLessons/ratify/demote/restore — this file adds a SURFACE, never a second writer. -import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, STATUS } from './lesson-store.mjs'; +import { loadLessons, updateLessons, ratify, demote, restore, pending, weightOf, TRIGGERS, ENFORCEMENT, ORIGIN, SOURCE_CLASS, STATUS } from './lesson-store.mjs'; import { openRouterCredentialStatus, saveOpenRouterCredential, @@ -421,8 +423,19 @@ function probeMemory(projectDir) { const db = path.join(projectDir, '.swarm/memory.db'); const probes = {}; // compaction survival + session surfacing are filesystem facts, always checkable - const snap = fs.existsSync(path.join(projectDir, 'agentdb-sessions.jsonl')) || fs.existsSync(path.join(projectDir, '.swarm/agentdb-sessions.jsonl')); - probes.compactionSurvival = snap ? { status: 'ok', detail: 'a PreCompact snapshot file is present' } : { status: 'warn', detail: 'no PreCompact snapshot found for this project yet' }; + const snapshot = inspectSessionSnapshots(projectDir); + const snapshotDetail = snapshot.kind === 'canonical' + ? 'a fresh versioned PreCompact snapshot is present in .swarm/agentdb-sessions.jsonl' + : snapshot.kind === 'legacy' + ? 'a fresh supported Ruflo session snapshot is present (legacy path; migration recommended)' + : snapshot.kind === 'malformed' + ? 'snapshot files were found but none matched the supported schema' + : snapshot.kind !== 'absent' + ? 'the newest supported snapshot is stale' + : 'no supported PreCompact snapshot found for this project yet'; + probes.compactionSurvival = snapshot.fresh + ? { status: 'ok', detail: snapshotDetail, artifact: snapshot.kind } + : { status: 'warn', detail: snapshotDetail, artifact: snapshot.kind }; probes.sessionSurfacing = sessionHookExists() ? { status: 'ok', detail: 'the global SessionStart hook surfaces project state at launch' } : { status: 'warn', detail: 'no SessionStart recall hook found' }; // recall quality: honestly NOT probed at render (a true probe needs an embedding query; left for an explicit deep test) probes.recallQuality = { status: 'notTested', detail: 'not checked this session — a real recall probe needs an embedding round-trip, which render deliberately avoids' }; @@ -989,7 +1002,15 @@ function gatherLessons() { const lessons = all.map((l) => { const trig = TRIGGER_BY_KEY.get(l.trigger); const meaning = ENFORCEMENT_MEANING[l.enforcement] || { label: l.enforcement, detail: '' }; - const userStated = l.origin === ORIGIN.USER_STATED; + const userStated = l.origin === ORIGIN.USER_STATED && l.sourceClass === SOURCE_CLASS.CURRENT_USER; + const quarantined = l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || l.sourceClass === SOURCE_CLASS.DEMONSTRATION; + const origin = l.sourceClass === SOURCE_CLASS.CURRENT_USER + ? 'you taught me this' + : l.sourceClass === SOURCE_CLASS.IMPORTED_OWNER + ? 'imported maintainer history — not yours' + : l.sourceClass === SOURCE_CLASS.DEMONSTRATION + ? 'demonstration data — not personal policy' + : 'I inferred this from what happened'; return { id: l.id, statement: l.statement, @@ -1001,8 +1022,10 @@ function gatherLessons() { enforcementLabel: meaning.label, enforcementDetail: meaning.detail, // Provenance drives trust, so it is stated plainly and never flattened into a badge colour. - origin: userStated ? 'you taught me this' : 'I inferred this from what happened', + origin, + sourceClass: l.sourceClass, userStated, + quarantined, taughtCount: l.repeatCount || 0, projects: Array.isArray(l.projects) ? l.projects : [], evidence: l.evidence || null, @@ -1011,10 +1034,11 @@ function gatherLessons() { ratified: l.status === STATUS.RATIFIED || l.status === STATUS.ACTIVE, demoted: !!l.demoted, // The one thing the user is being ASKED, as opposed to merely shown. - awaitingYou: l.status === STATUS.CANDIDATE && !l.demoted, + awaitingYou: l.status === STATUS.CANDIDATE && !l.demoted && !quarantined, // Honest ceiling: ratifying a model-inferred lesson can NOT raise it to block // (lesson-store.mjs:380). Say so before they click, not after. canReachBlock: userStated, + canRatify: !quarantined, intendedEnforcement: l.intendedEnforcement || null, }; }); @@ -1038,6 +1062,7 @@ function gatherLessons() { active: lessons.filter((l) => l.ratified && !l.demoted).length, awaitingYou: lessons.filter((l) => l.awaitingYou).length, off: lessons.filter((l) => l.demoted).length, + quarantined: lessons.filter((l) => l.quarantined).length, blocking: lessons.filter((l) => l.enforcement === 'block' && l.ratified && !l.demoted).length, }, // TASK 2: this endpoint bypasses serveCached entirely and had NO timestamp of any kind. It is @@ -1068,6 +1093,9 @@ function setLesson(body) { } const before = loadLessons().find((l) => l.id === id); if (!before) return { ok: false, log: `nothing changed — no lesson with id ${id}` }; + if (action === 'ratify' && (before.sourceClass === SOURCE_CLASS.IMPORTED_OWNER || before.sourceClass === SOURCE_CLASS.DEMONSTRATION)) { + return { ok: false, log: 'nothing changed — imported or demonstration history cannot become personal policy' }; + } try { updateLessons((fresh) => spec.fn(id, fresh)); @@ -1695,7 +1723,10 @@ function gatherRouterEngine() { // user's Settings choice is now the single source of truth, via the SAME detectProvider() the // savings.utilization frontier calc already uses (config → env → catalog default) — so this and // the frontier calc can never disagree either. - let house, providerKeys = {}; + const subscriptions = detectSubscriptions(); + let house; + let providerKeys = providerAvailability(null, subscriptions); + let providerCatalog = { status: 'degraded', detail: 'provider catalog was not loaded; native boolean detections are shown' }; try { const hcat = loadCatalog(); house = detectProvider(hcat, { provider: cfg.provider }); @@ -1704,11 +1735,12 @@ function gatherRouterEngine() { // key found". Read each provider's real detect_env vars — minus the CLAUDECODE / CLAUDE_CODE_ENTRYPOINT // run-context markers (which are not credentials), exactly as detectProvider() itself filters them — // so the UI's "key found / not found" is now true instead of decorative. - const IGNORE_ENV = new Set(['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT']); - for (const [name, p] of Object.entries(hcat.providers || {})) { - providerKeys[name] = (p.detect_env || []).some((k) => !IGNORE_ENV.has(k) && !!process.env[k]); - } - } catch { house = { provider: cfg.provider && cfg.provider !== 'auto' ? cfg.provider : 'anthropic', source: 'default' }; } + providerKeys = providerAvailability(hcat, subscriptions); + providerCatalog = { status: 'ok', detail: 'verified provider catalog loaded' }; + } catch (error) { + house = { provider: cfg.provider && cfg.provider !== 'auto' ? cfg.provider : 'anthropic', source: 'default' }; + providerCatalog = { status: 'degraded', detail: `provider catalog unavailable: ${String(error?.message || error)}` }; + } return { engine: { package: '@metaharness/router', installed, @@ -1717,12 +1749,13 @@ function gatherRouterEngine() { outcomesLog: OUTCOMES.replace(os.homedir(), '~'), }, keys: { openrouter: openrouterKey, ...providerKeys }, + providerCatalog, // Paid seats, found at USER level. `keys` above is env-var API keys only, which is exactly why a // user with ChatGPT Max and Claude Max read as "auto" — neither plan puts a key in the // environment. These two fields are what let the UI say "you already have this" instead of // asking someone to paste a credential they are already paying not to need. - subscriptions: detectSubscriptions(), - preferredSeat: preferredSeat(detectSubscriptions()), + subscriptions, + preferredSeat: preferredSeat(subscriptions), profile: { present: !!profile, path: PROFILE_PATH.replace(os.homedir(), '~') }, catalogSource: engineCatalogSource(), // 'catalog' | 'built-in-fallback' — so the UI never calls the stub a real catalog house, @@ -1833,6 +1866,10 @@ async function fetchReleaseDigest() { // in the heading of the console"). Plugin-cache dir first — the truth on installed machines — then // the repo's plugin.json for dev checkouts. null hides the chip rather than guessing. function brainVersionOnDisk() { + const runtimeIdentity = readJSON(path.join(REPO, 'runtime-identity.json')); + if (typeof runtimeIdentity?.runtimeVersion === 'string' && runtimeIdentity.runtimeVersion) { + return runtimeIdentity.runtimeVersion.replace(/^v/, ''); + } try { const v = readInstallChannel().version; if (v) return String(v).replace(/^v/, ''); } catch { /* fall through */ } try { return getVersion(); } catch { return null; } } @@ -2948,6 +2985,7 @@ export { saveBrainPower, gatherBrainProfile, saveBrainProfile, + gatherRouterEngine, autoEligibleIds, }; // Exported for the cross-project cache-isolation test (console-cache-scope.test.mjs). serveCached's diff --git a/scripts/provider-availability.mjs b/scripts/provider-availability.mjs new file mode 100644 index 00000000..389e5e64 --- /dev/null +++ b/scripts/provider-availability.mjs @@ -0,0 +1,12 @@ +const IGNORED_ENV = new Set(['CLAUDECODE', 'CLAUDE_CODE_ENTRYPOINT']); + +// Boolean-only by construction: no credential value can cross the Console API boundary. +export function providerAvailability(catalog, subscriptions = {}, env = process.env) { + if (catalog?.providers && typeof catalog.providers === 'object') { + return Object.fromEntries(Object.entries(catalog.providers).map(([name, provider]) => [ + name, + (provider?.detect_env || []).some((key) => !IGNORED_ENV.has(key) && Boolean(env[key])), + ])); + } + return Object.fromEntries(Object.entries(subscriptions).map(([name, value]) => [name, Boolean(value?.apiKey)])); +} diff --git a/scripts/release-authority.mjs b/scripts/release-authority.mjs index 24114615..2c25b333 100644 --- a/scripts/release-authority.mjs +++ b/scripts/release-authority.mjs @@ -7,7 +7,10 @@ import path from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const CANONICAL_PUBLISHER = 'scripts/release.mjs'; +const CANONICAL_PUBLISHERS = new Set([ + 'scripts/release.mjs', + 'scripts/release-transaction-provider.mjs', +]); const SOURCE_EXTENSIONS = new Set(['.mjs', '.js', '.cjs', '.sh']); function executableSource(source) { @@ -27,6 +30,14 @@ const ACTIONS = [ ], shellPatterns: [/^\s*gh\s+release\s+create\b/m], }, + { + action: 'github-release-update', + jsPatterns: [ + /['"]PATCH['"][\s\S]{0,120}releases\//, + /\[\s*['"]release['"]\s*,\s*['"]edit['"]/, + ], + shellPatterns: [/^\s*gh\s+(?:release\s+edit|api\s+.*releases\/.*PATCH)\b/m], + }, { action: 'npm-publish', jsPatterns: [ @@ -47,7 +58,7 @@ const ACTIONS = [ export function detectPublisherActions(file, source) { const relative = file.split(path.sep).join('/'); - if (relative === CANONICAL_PUBLISHER) return []; + if (CANONICAL_PUBLISHERS.has(relative)) return []; const executable = executableSource(source); const kind = path.extname(relative) === '.sh' ? 'shellPatterns' : 'jsPatterns'; return ACTIONS @@ -67,6 +78,7 @@ function sourceFiles(root) { }; visit(path.join(root, 'scripts')); visit(path.join(root, 'deploy')); + visit(path.join(root, 'bin')); return files; } diff --git a/scripts/release-transaction-provider.mjs b/scripts/release-transaction-provider.mjs new file mode 100644 index 00000000..5512b43f --- /dev/null +++ b/scripts/release-transaction-provider.mjs @@ -0,0 +1,236 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { RECEIPT_PREFIX, TERMINAL_STATES } from './release-transaction.mjs'; + +const REPO = 'stuinfla/ruvnet-brain'; +const PACKAGE = 'ruvnet-brain'; + +const command = (name, args, options = {}) => execFileSync(name, args, { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options, +}).trim(); +const json = (name, args, options) => JSON.parse(command(name, args, options)); +const maybe = (callback, fallback = null) => { + try { return callback(); } catch { return fallback; } +}; + +const tagSha = (tag, root) => { + const rows = command('git', ['ls-remote', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`], { cwd: root }) + .split('\n').filter(Boolean).map((line) => line.split(/\s+/)); + return rows.find(([, ref]) => ref?.endsWith('^{}'))?.[0] || rows[0]?.[0] || ''; +}; + +const assetBytes = (asset) => { + const result = spawnSync('gh', ['api', asset.url, '-H', 'Accept: application/octet-stream'], { + encoding: 'buffer', stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.error || result.status !== 0) throw new Error(`cannot download transaction asset ${asset.name}`); + return Buffer.from(result.stdout); +}; +const assetReceipt = (asset) => JSON.parse(assetBytes(asset).toString('utf8')); +const sha256 = (bytes) => crypto.createHash('sha256').update(bytes).digest('hex'); + +export function liveReleaseProvider({ + root = process.cwd(), candidateReceipt = null, publicationReceipt = null, +} = {}) { + let releases = []; + let activeDraft = null; + const refresh = () => { + releases = json('gh', ['api', `repos/${REPO}/releases?per_page=100`]); + return releases; + }; + const releaseById = (id) => json('gh', ['api', `repos/${REPO}/releases/${id}`]); + const receiptsFor = (release) => (release?.assets || []) + .filter(({ name }) => name.startsWith(RECEIPT_PREFIX) && name.endsWith('.json')) + .map(assetReceipt); + const materializeRemoteAssets = (draft, identity) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-release-staged-')); + try { + const release = releaseById(draft.id); + const required = [ + ['bundlePath', 'ruvnet-brain.zip'], + ['bundleSignaturePath', 'ruvnet-brain.zip.sig'], + ['bundleDigestPath', 'ruvnet-brain.zip.sha256'], + ]; + const assets = {}; + for (const [key, name] of required) { + const remote = release.assets?.find((asset) => asset.name === name); + if (!remote) throw new Error(`staged GitHub asset missing: ${name}`); + const file = path.join(temp, name); + fs.writeFileSync(file, assetBytes(remote), { flag: 'wx', mode: 0o600 }); + assets[key] = file; + } + if (sha256(fs.readFileSync(assets.bundlePath)) !== identity.bundleSha256) { + throw new Error('staged GitHub bundle digest does not match transaction identity'); + } + const packed = json('npm', [ + 'pack', `${PACKAGE}@candidate-v${identity.version}`, '--json', '--pack-destination', temp, + ]); + if (!Array.isArray(packed) || packed.length !== 1 || !packed[0].filename) { + throw new Error('npm candidate pack did not return exactly one artifact'); + } + assets.packagePath = path.join(temp, packed[0].filename); + const integrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(assets.packagePath)).digest('base64')}`; + if (integrity !== identity.packageIntegrity) throw new Error('staged npm package integrity mismatch'); + return { assets, cleanup: () => fs.rmSync(temp, { recursive: true, force: true }) }; + } catch (error) { + fs.rmSync(temp, { recursive: true, force: true }); + throw error; + } + }; + + return { + async discover(identity) { + const all = refresh(); + const matchingDrafts = all.filter((release) => release.tag_name === identity.tag + && release.target_commitish === identity.candidateSha).map((release) => ({ + id: release.id, tag: release.tag_name, sha: release.target_commitish, draft: release.draft, + })); + activeDraft = matchingDrafts[0] || null; + const receipts = matchingDrafts.length === 1 ? receiptsFor(releaseById(matchingDrafts[0].id)) : []; + const latestByTransaction = new Map(); + for (const receipt of all.flatMap((release) => receiptsFor(release))) { + const prior = latestByTransaction.get(receipt.transactionId); + if (!prior || receipt.sequence > prior.sequence) latestByTransaction.set(receipt.transactionId, receipt); + } + const pending = [...latestByTransaction.values()] + .filter((receipt) => !TERMINAL_STATES.has(receipt.state)); + const npmLatest = maybe(() => command('npm', ['view', `${PACKAGE}@latest`, 'version']), null); + const githubLatest = maybe(() => json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name, null); + return { matchingDrafts, receipts, pending, prior: { npmLatest, githubLatest } }; + }, + + async createDraft(identity) { + const release = json('gh', [ + 'api', '-X', 'POST', `repos/${REPO}/releases`, + '-f', `tag_name=${identity.tag}`, '-f', `target_commitish=${identity.candidateSha}`, + '-f', `name=${identity.tag} staged candidate`, '-F', 'draft=true', '-F', 'prerelease=false', + ]); + activeDraft = { id: release.id, tag: release.tag_name, sha: release.target_commitish }; + return activeDraft; + }, + + async appendReceipt(draft, receipt, name) { + const anchor = draft || activeDraft; + if (!anchor) throw new Error('no draft anchor for transaction receipt'); + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-release-receipt-')); + const file = path.join(temp, name); + try { + fs.writeFileSync(file, `${JSON.stringify(receipt, null, 2)}\n`, { flag: 'wx', mode: 0o600 }); + command('gh', ['release', 'upload', anchor.tag, file, '--repo', REPO]); + } finally { + fs.rmSync(temp, { recursive: true, force: true }); + } + }, + + async readReceipt(draft, sequence) { + const name = `${RECEIPT_PREFIX}${String(sequence).padStart(4, '0')}.json`; + const release = releaseById((draft || activeDraft).id); + const asset = release.assets?.find((item) => item.name === name); + if (!asset) throw new Error(`remote receipt ${name} is missing after upload`); + return assetReceipt(asset); + }, + + async uploadAssets(draft, assets) { + const paths = [assets.bundlePath, assets.bundleSignaturePath, assets.bundleDigestPath, assets.packagePath]; + for (const file of paths) { + const release = releaseById(draft.id); + const existing = release.assets?.find(({ name }) => name === path.basename(file)); + if (existing) { + if (sha256(assetBytes(existing)) !== sha256(fs.readFileSync(file))) { + throw new Error(`refusing to replace staged asset with different bytes: ${existing.name}`); + } + continue; + } + command('gh', ['release', 'upload', draft.tag, file, '--repo', REPO]); + } + }, + + async materializeStagedAssets(draft, identity) { + return materializeRemoteAssets(draft, identity); + }, + + async stageNpm(identity, packagePath) { + const existing = maybe(() => command('npm', ['view', `${PACKAGE}@${identity.version}`, 'dist.integrity']), null); + if (existing) return; + command('npm', ['publish', packagePath, '--tag', `candidate-v${identity.version}`]); + }, + + async observeNpmCandidate(identity) { + const metadata = json('npm', ['view', `${PACKAGE}@candidate-v${identity.version}`, '--json']); + return { version: metadata.version, integrity: metadata.dist?.integrity, tag: `candidate-v${identity.version}` }; + }, + + async publishDraftNonLatest(draft) { + command('gh', ['api', '-X', 'PATCH', `repos/${REPO}/releases/${draft.id}`, '-F', 'draft=false', '-f', 'make_latest=false']); + }, + + async observeGithub(identity) { + const release = json('gh', ['api', `repos/${REPO}/releases/tags/${identity.tag}`]); + const latest = maybe(() => json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name, null); + return { tag: release.tag_name, sha: tagSha(identity.tag, root), latest: latest === identity.tag }; + }, + + async promoteNpm(identity) { + command('npm', ['dist-tag', 'add', `${PACKAGE}@${identity.version}`, 'latest']); + }, + async observeNpmLatest() { + return { version: command('npm', ['view', `${PACKAGE}@latest`, 'version']) }; + }, + async makeGithubLatest(draft) { + command('gh', ['api', '-X', 'PATCH', `repos/${REPO}/releases/${draft.id}`, '-f', 'make_latest=true']); + }, + async observeGithubLatest() { + return { tag: json('gh', ['api', `repos/${REPO}/releases/latest`]).tag_name }; + }, + async restoreNpmLatest(prior, expected) { + const current = command('npm', ['view', `${PACKAGE}@latest`, 'version']); + if (current !== expected) throw new Error(`refusing compensation: npm latest is ${current}, expected ${expected}`); + command('npm', ['dist-tag', 'add', `${PACKAGE}@${prior}`, 'latest']); + }, + async finalize(identity, receipt, hostVerifier) { + const staged = materializeRemoteAssets(activeDraft, identity); + let hosts; + try { + hosts = await hostVerifier.verify({ source: 'final', identity, assets: staged.assets }); + } finally { + staged.cleanup(); + } + if (hosts.verdict !== 'PASS') return { verdict: 'FAIL', hosts }; + if (!candidateReceipt || !publicationReceipt) { + throw new Error('final convergence requires candidate and publication receipt paths'); + } + if (!fs.existsSync(path.resolve(root, publicationReceipt))) { + const publication = spawnSync(process.execPath, [ + 'scripts/publication-receipt.mjs', '--candidate', candidateReceipt, '--out', publicationReceipt, + ], { cwd: root, encoding: 'utf8', timeout: 1_200_000 }); + if (publication.error || publication.status !== 0) { + return { verdict: 'FAIL', hosts, publicationError: String(publication.stderr || publication.error?.message) }; + } + } + const seal = spawnSync(process.execPath, [ + 'scripts/release-proof.mjs', '--candidate', candidateReceipt, '--publication', publicationReceipt, + ], { cwd: root, encoding: 'utf8', timeout: 300_000 }); + if (seal.error || seal.status !== 0) { + return { verdict: 'FAIL', hosts, sealError: String(seal.stderr || seal.error?.message) }; + } + const channels = spawnSync(process.execPath, ['scripts/verify-channels.mjs'], { + cwd: root, encoding: 'utf8', timeout: 600_000, + }); + if (channels.error || channels.status !== 0) { + return { verdict: 'FAIL', hosts, channelError: String(channels.stderr || channels.error?.message) }; + } + const probe = spawnSync(process.execPath, ['scripts/published-surface-probe.mjs', '--json'], { + cwd: root, encoding: 'utf8', timeout: 600_000, + }); + let observed = null; + try { observed = JSON.parse(probe.stdout || ''); } catch {} + return { + verdict: probe.status === 0 && observed?.verdict === 'PASS' ? 'PASS' : 'FAIL', + hosts, surface: observed, publicationReceipt, previousReceiptDigest: receipt.receiptDigest, + }; + }, + }; +} diff --git a/scripts/release-transaction.mjs b/scripts/release-transaction.mjs new file mode 100644 index 00000000..7148a258 --- /dev/null +++ b/scripts/release-transaction.mjs @@ -0,0 +1,214 @@ +#!/usr/bin/env node +import crypto from 'node:crypto'; + +export const RECEIPT_PREFIX = 'release-transaction-'; +export const TERMINAL_STATES = new Set(['channels-converged', 'aborted']); + +const canonical = (value) => { + if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`; + if (value && typeof value === 'object') { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonical(value[key])}`).join(',')}}`; + } + return JSON.stringify(value); +}; + +export const receiptPayload = (receipt) => { + const { signature: _signature, receiptDigest: _digest, ...payload } = receipt; + return canonical(payload); +}; + +export const digestReceipt = (receipt) => crypto.createHash('sha256') + .update(receiptPayload(receipt)).digest('hex'); + +export const transactionIdFor = (identity) => crypto.createHash('sha256').update(canonical({ + repository: identity.repository, + package: identity.package, + version: identity.version, + tag: identity.tag, + candidateSha: identity.candidateSha, + packageIntegrity: identity.packageIntegrity, + bundleSha256: identity.bundleSha256, +})).digest('hex'); + +export function signReceipt(receipt, privateKey) { + const unsigned = { ...receipt, receiptDigest: digestReceipt(receipt) }; + return { + ...unsigned, + signature: crypto.sign(null, Buffer.from(canonical(unsigned)), privateKey).toString('base64'), + }; +} + +export function verifyReceipt(receipt, publicKey) { + if (!receipt || receipt.schemaVersion !== 1 || !/^[a-f0-9]{64}$/.test(receipt.transactionId || '')) { + throw new Error('invalid release transaction receipt'); + } + const { signature, receiptDigest, ...unsigned } = receipt; + if (digestReceipt(unsigned) !== receiptDigest) throw new Error('release receipt digest mismatch'); + if (!crypto.verify(null, Buffer.from(canonical({ ...unsigned, receiptDigest })), publicKey, Buffer.from(signature || '', 'base64'))) { + throw new Error('release receipt signature mismatch'); + } + return receipt; +} + +export function validateReceiptChain(receipts, identity, publicKey) { + const expectedId = transactionIdFor(identity); + const sorted = [...receipts].sort((a, b) => a.sequence - b.sequence); + let previous = null; + for (let index = 0; index < sorted.length; index += 1) { + const receipt = verifyReceipt(sorted[index], publicKey); + if (receipt.transactionId !== expectedId || canonical(receipt.identity) !== canonical(identity)) { + throw new Error('release receipt identity conflict'); + } + if (receipt.sequence !== index) throw new Error('release receipt sequence gap or replay'); + if ((receipt.previousReceiptDigest || null) !== (previous?.receiptDigest || null)) { + throw new Error('release receipt chain conflict'); + } + previous = receipt; + } + return sorted; +} + +const stateReceipt = ({ identity, prior, state, fence, observation = {}, privateKey }) => signReceipt({ + schemaVersion: 1, + transactionId: transactionIdFor(identity), + sequence: prior ? prior.sequence + 1 : 0, + previousReceiptDigest: prior?.receiptDigest || null, + state, + fence, + identity, + observation, + createdAt: new Date().toISOString(), +}, privateKey); + +const exact = (actual, expected, label) => { + if (actual !== expected) throw new Error(`${label} mismatch: ${actual ?? '(missing)'} != ${expected}`); +}; + +export async function runReleaseTransaction({ identity, assets, adapter, privateKey, publicKey, hostVerifier }) { + const expectedId = transactionIdFor(identity); + const discovered = await adapter.discover(identity); + const competing = discovered.pending?.filter((item) => item.transactionId !== expectedId) || []; + if (competing.length) throw new Error(`pending release ${competing[0].transactionId} blocks ${expectedId}`); + if ((discovered.matchingDrafts || []).length > 1) throw new Error('duplicate matching drafts require reconciliation'); + + let chain = validateReceiptChain(discovered.receipts || [], identity, publicKey); + let current = chain.at(-1) || null; + const fence = current?.fence || discovered.fence || crypto.randomUUID(); + const draft = discovered.matchingDrafts?.[0] || await adapter.createDraft(identity, fence); + const completed = new Set(chain.map(({ state }) => state)); + const append = async (state, observation = {}) => { + const receipt = stateReceipt({ identity, prior: current, state, fence, observation, privateKey }); + await adapter.appendReceipt(draft, receipt, `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`); + const observed = await adapter.readReceipt(draft, receipt.sequence); + verifyReceipt(observed, publicKey); + exact(observed.receiptDigest, receipt.receiptDigest, 'remote receipt'); + current = receipt; + completed.add(state); + return receipt; + }; + const intend = async (state, observation = {}) => { + if (!completed.has(state)) await append(state, observation); + }; + + if (!current) await append('remote-prepared', { draftId: draft.id, prior: discovered.prior }); + if (TERMINAL_STATES.has(current.state)) return current; + if (current.fence !== fence) throw new Error('stale release transaction fence'); + + if (!completed.has('local-hosts-verified')) { + await intend('asset-upload-intent'); + await adapter.uploadAssets(draft, assets, identity); + await intend('host-verification-intent', { source: 'sealed-local-assets' }); + const localHosts = await hostVerifier.verify({ source: 'local', identity, assets }); + if (localHosts.verdict !== 'PASS') throw new Error('local staged host verification failed'); + await append('local-hosts-verified', { hosts: localHosts }); + } + + if (!completed.has('npm-candidate-staged')) { + await intend('npm-stage-intent'); + await adapter.stageNpm(identity, assets.packagePath); + const stagedNpm = await adapter.observeNpmCandidate(identity); + exact(stagedNpm.version, identity.version, 'npm candidate version'); + exact(stagedNpm.integrity, identity.packageIntegrity, 'npm candidate integrity'); + await append('npm-candidate-staged', { npm: stagedNpm }); + } + + if (!completed.has('prepared')) { + await intend('remote-host-verification-intent'); + const staged = await adapter.materializeStagedAssets(draft, identity); + try { + const remoteHosts = await hostVerifier.verify({ source: 'staged', identity, assets: staged.assets, draft }); + if (remoteHosts.verdict !== 'PASS') throw new Error('remote staged host verification failed'); + await append('prepared', { hosts: remoteHosts }); + } finally { + staged.cleanup(); + } + } + + if (!completed.has('github-promoted-nonlatest')) { + await intend('github-promote-intent'); + await adapter.publishDraftNonLatest(draft, identity); + const github = await adapter.observeGithub(identity); + exact(github.sha, identity.candidateSha, 'GitHub candidate SHA'); + if (github.latest) throw new Error('GitHub candidate advanced latest before npm convergence'); + await append('github-promoted-nonlatest', { github }); + } + + if (!completed.has('npm-promoted')) { + await intend('npm-promote-intent'); + try { + await adapter.promoteNpm(identity); + } catch (error) { + throw new Error(`npm promotion pending for ${identity.version}: ${error.message}`); + } + const npmLatest = await adapter.observeNpmLatest(); + exact(npmLatest.version, identity.version, 'npm latest'); + await append('npm-promoted', { npm: npmLatest }); + } + + // A failed GitHub-latest promotion is compensated back to A. The historical npm-promoted + // receipt remains true, but is no longer the current external state, so a clean-runner retry + // must explicitly re-promote B before it may retry GitHub. + if (current.state === 'compensated') { + await append('npm-repromote-intent'); + await adapter.promoteNpm(identity); + const npmLatest = await adapter.observeNpmLatest(); + exact(npmLatest.version, identity.version, 'npm latest after compensation retry'); + await append('npm-repromoted', { npm: npmLatest }); + } + + if (!completed.has('defaults-promoted')) { + await intend('github-latest-intent'); + try { + await adapter.makeGithubLatest(draft, identity); + } catch (error) { + const observed = await adapter.observeNpmLatest(); + if (observed.version !== identity.version) throw new Error('npm latest changed during compensation'); + await intend('compensation-intent', { restore: discovered.prior?.npmLatest }); + await adapter.restoreNpmLatest(discovered.prior?.npmLatest, identity.version); + exact((await adapter.observeNpmLatest()).version, discovered.prior?.npmLatest, 'npm compensation'); + await append('compensated', { reason: error.message }); + throw new Error(`GitHub latest promotion failed; npm compensated: ${error.message}`); + } + const githubLatest = await adapter.observeGithubLatest(); + exact(githubLatest.tag, identity.tag, 'GitHub latest'); + await append('defaults-promoted', { github: githubLatest }); + } + + await intend('finalize-intent'); + const final = await adapter.finalize(identity, current, hostVerifier); + if (final.verdict !== 'PASS') throw new Error('final release convergence failed'); + return append('channels-converged', final); +} + +export async function abortReleaseTransaction({ identity, receipts, reason, authorized, adapter, privateKey, publicKey }) { + if (!authorized) throw new Error('release abort requires explicit human authorization'); + const chain = validateReceiptChain(receipts, identity, publicKey); + const current = chain.at(-1); + if (!current || TERMINAL_STATES.has(current.state)) throw new Error('release transaction is not abortable'); + const receipt = stateReceipt({ + identity, prior: current, state: 'aborted', fence: current.fence, + observation: { reason, authorized: true }, privateKey, + }); + await adapter.appendReceipt(null, receipt, `${RECEIPT_PREFIX}${String(receipt.sequence).padStart(4, '0')}.json`); + return receipt; +} diff --git a/scripts/release.mjs b/scripts/release.mjs index 9fd972f4..91684d8d 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -8,22 +8,21 @@ // and only prints "SHIPPED" when every channel a user touches is proven current and working. There is // no "I think it's fine" — there is pass or fail. // -// It is idempotent and safe to re-run. Each step verifies the REAL artifact (registry, live URL, the -// actual command), never the repo state. Repo state != user experience (the whole lesson). +// Check-only mode evaluates source. Publish mode consumes a CI-sealed package and receipt, then +// performs only the staged transaction and public verification. It never rebuilds or retests the +// source: the immutable artifact is the evidence boundary. // // Usage: // node scripts/release.mjs --check # run every gate READ-ONLY (no publish) — the pre-flight -// node scripts/release.mjs --publish # sync version, npm publish, then run every gate +// node scripts/release.mjs --publish # publish the exact CI-sealed artifact // node scripts/release.mjs # same as --check // // The gates, in order (fail fast): // A. version single-source-of-truth agrees (sync-version --check) // B. full test suite green (npm test — the 60/60) // C. narrative + unit gates (vitest) incl. the tag/entity-aware "What's new" check -// C+. [--publish only] push to origin/main — ONLY now that A–C are green (a red tree can't reach GitHub) -// D. [--publish only] build + sign bundle, create/update the exact-SHA GitHub Release, -// then npm publish + force `latest` to the shipping version -// E. verify-channels — the LIVE walk of npm / self-update manifest / release bundle+sig / explainer / git +// D. [--publish only] stage and promote the exact package plus signed RVF bundle +// E. [--check only] verify current public channels; publish verifies them inside transaction finalization import path from 'node:path'; import { fileURLToPath } from 'node:url'; @@ -31,6 +30,9 @@ import { spawnSync, execFileSync } from 'node:child_process'; import fs from 'node:fs'; import crypto from 'node:crypto'; import { validateProtectedPublishInvocation } from './protected-release-invocation.mjs'; +import { runReleaseTransaction } from './release-transaction.mjs'; +import { liveReleaseProvider } from './release-transaction-provider.mjs'; +import { stagedHostVerifier } from './staged-host-verifier.mjs'; const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url))); const PUBLISH = process.argv.includes('--publish'); @@ -51,36 +53,6 @@ function runOrDie(label, cmd, args, opts = {}) { } } -function remoteTagCommit(tag) { - const out = execFileSync('git', [ - 'ls-remote', 'origin', `refs/tags/${tag}`, `refs/tags/${tag}^{}`, - ], { cwd: ROOT, encoding: 'utf8' }).trim(); - if (!out) return ''; - const rows = out.split('\n').map((line) => line.trim().split(/\s+/)); - return rows.find(([, ref]) => ref?.endsWith('^{}'))?.[0] || rows[0]?.[0] || ''; -} - -function recordReleaseTransaction(state, data) { - const file = path.join(ROOT, 'dist', 'release-transaction.json'); - fs.mkdirSync(path.dirname(file), { recursive: true }); - const next = { - state, - updatedAt: new Date().toISOString(), - ...data, - }; - const tmp = `${file}.tmp-${process.pid}`; - fs.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`); - fs.renameSync(tmp, file); -} - -function readReleaseTransaction() { - try { - return JSON.parse(fs.readFileSync(path.join(ROOT, 'dist', 'release-transaction.json'), 'utf8')); - } catch { - return null; - } -} - console.log(`\n${c.b('RuvNet Brain — release / definition-of-done')} ${c.dim('· ' + (PUBLISH ? 'PUBLISH' : 'check-only') + ' · shipping ' + V())}\n`); // The local CLI remains useful as a read-only preflight, but publication authority lives only in @@ -115,10 +87,12 @@ if (initialDirty) { process.exit(1); } -// A. version single source of truth -step('A', 'version single-source-of-truth agrees across every surface'); -runOrDie('version sync', process.execPath, ['scripts/sync-version.mjs', '--check']); -runOrDie('one protected publisher', process.execPath, ['scripts/release-authority.mjs']); +if (!PUBLISH) { + // Source gates belong to candidate CI/check-only mode. The protected publisher has already + // validated their exact-SHA receipt and the sealed bytes before reaching this process. + step('A', 'version single-source-of-truth agrees across every surface'); + runOrDie('version sync', process.execPath, ['scripts/sync-version.mjs', '--check']); + runOrDie('one protected publisher', process.execPath, ['scripts/release-authority.mjs']); // WIRED-CHECK — refuses to ship a module with zero callers. // @@ -130,23 +104,23 @@ runOrDie('one protected publisher', process.execPath, ['scripts/release-authorit // // Seven repetitions of one mistake is not a discipline problem; discipline is what failed. So it // becomes a gate, on the ship path, where this repo's gates run 8/8 against prose's 0/6. -runOrDie('wired (no orphan modules)', process.execPath, ['scripts/wired-check.mjs', '--check']); + runOrDie('wired (no orphan modules)', process.execPath, ['scripts/wired-check.mjs', '--check']); // THE NORTH-STAR PROMOTION VECTOR — strict/check-only releases may not average one broken or // unknown invariant into a pass. The separately authorized stabilization class makes no 95 claim; // it retains every safety, test, artifact, publication, and post-publication gate below while the // promotion program remains open. Derive this only from the already-validated sealed receipt, never // from a free-standing environment toggle. -if (protectedReleaseMode === 'strict') { - runOrDie('release vector (all critical invariants PASS)', process.execPath, ['scripts/release-vector.mjs']); + if (protectedReleaseMode === 'strict') { + runOrDie('release vector (all critical invariants PASS)', process.execPath, ['scripts/release-vector.mjs']); // The Top-100 corpus spans naive through expert prompts and grades semantic clauses, citations, // abstention, and latency. A manual-only benchmark is a report; a strict release-path benchmark // is a guarantee. The benchmark itself fails closed unless all 100 canonical questions run. - runOrDie('Top-100 source-grounded recall contract', process.execPath, ['scripts/top100-benchmark.mjs', '--no-write']); -} else { - console.log(c.y(' strict >=95 promotion gates: NOT CLAIMED (sealed stabilization; scoreClaimed:false)')); -} + runOrDie('Top-100 source-grounded recall contract', process.execPath, ['scripts/top100-benchmark.mjs', '--no-write']); + } else { + console.log(c.y(' strict >=95 promotion gates: NOT CLAIMED (sealed stabilization; scoreClaimed:false)')); + } // A2. Stable Spine restart classifier (ADR-023, red-team finding 18): diff the boot-frozen SHELL // (hooks.json, hook-shim, MCP server, .mcp.json, skills/, commands/) against the previous release @@ -154,8 +128,8 @@ if (protectedReleaseMode === 'strict') { // remembered — the same shellDiff logic runs client-side in update-apply.mjs at every flip, so the // user-facing nag stays honest even if this print is ignored. Informational at ship time; the // releasing human sees exactly which shell files changed. -step('A2', 'Stable Spine — does this release change the boot-frozen shell? (requiresRestart classifier)'); -{ + step('A2', 'Stable Spine — does this release change the boot-frozen shell? (requiresRestart classifier)'); + { const { execFileSync } = await import('node:child_process'); const SHELL = ['plugin/hooks/hooks.json', 'plugin/scripts/hook-shim.mjs', 'plugin/mcp/server.mjs', 'plugin/.mcp.json', 'plugin/skills', 'plugin/commands']; let prevTag = ''; @@ -176,98 +150,36 @@ step('A2', 'Stable Spine — does this release change the boot-frozen shell? (re console.log(` ${c.g('requiresRestart: false')} — no shell change vs ${prevTag}; this release goes fully live with zero restarts.`); } } -} + } // B. the full brain test suite (the 60/60) -step('B', 'full test suite (npm test)'); -runOrDie('npm test', 'npm', ['test']); + step('B', 'full test suite (npm test)'); + runOrDie('npm test', 'npm', ['test']); // C. unit gates — narrative-version (tag/entity aware), claims, etc. -step('C', 'unit gates (vitest) — narrative version, claims, guards'); -runOrDie('vitest unit', 'npx', ['vitest', 'run', 'tests/unit']); - -// C+. PUSH — only now that A–C are green (publish only). Pushing AFTER the local gates is the fix -// for the drift that bit on 2026-07-18: a commit was pushed FIRST, then release.mjs's gate B caught a -// failing plugin-battery test, leaving GitHub at 3.4.10-dev while npm sat at 3.4.9-dev — the exact -// "pushed but didn't finish" split. The pre-push git hook only checks version/manifest (fast, always), -// so tests must gate the push HERE. A red tree can no longer reach origin ahead of npm. -if (PUBLISH) { - // C++. REMOTE CI IS A SHIP GATE (ADR-053 §5). Between 2026-07-21 and 07-26 the `ci` workflow was - // red for ~70 consecutive runs — six releases shipped right past it, because nothing on the ship - // path ever ASKED the remote verdict. Local gates prove this machine; only CI proves ubuntu and - // windows. So the latest COMPLETED run on origin/main must be green before we add commits on top - // and publish. (The current commit's own run starts after the push — this gate is "never build on - // a known-broken main", not "wait for my own run".) Escape hatch for a genuine hotfix: - // --ci-override "" — printed into the release log, never silent. - step('C++', 'remote CI on origin/main is green (the ubuntu+windows verdict this machine cannot produce)'); - { - const { fetchLatestCiVerdict, assessCiGate } = await import('./ci-verdict.mjs'); - const OVERRIDE_IX = process.argv.indexOf('--ci-override'); - const overrideReason = OVERRIDE_IX >= 0 ? (process.argv[OVERRIDE_IX + 1] || '(no reason given)') : null; - const { verdict, sha } = await fetchLatestCiVerdict(); - const gate = assessCiGate(verdict, overrideReason); - if (gate === 'ship') { - console.log(c.dim(` latest completed ci run on origin/main: success (${sha})`)); - } else if (gate === 'override') { - console.log(` ${c.y('! CI gate OVERRIDDEN')} — verdict was ${verdict ?? 'unknown'} (${sha || 'no run found'}); reason: ${overrideReason}`); - } else { - console.error(`\n${c.r('✗ GATE FAILED: remote CI on origin/main is ' + (verdict ?? 'unknown'))} ${c.dim('(' + (sha || 'no completed run found') + ')')}`); - console.error(`${c.r(' A red or unknown main does not get shipped on top of. Fix CI first (gh run list --workflow ci.yml),')}`); - console.error(`${c.r(' or for a genuine hotfix: --ci-override "" (the reason is printed into the release log).')}\n`); - process.exit(1); - } - } - - step('C+', 'push to origin/main — safe now that A–C passed'); - let ahead = '0'; - try { ahead = execFileSync('git', ['-C', ROOT, 'rev-list', '--count', 'origin/main..HEAD'], { encoding: 'utf8' }).trim(); } catch { /* origin/main ref missing — push will resolve */ ahead = '?'; } - if (ahead === '0') console.log(c.dim(' nothing to push — HEAD already on origin/main')); - else runOrDie('git push', 'git', ['-C', ROOT, 'push', 'origin', 'main']); + step('C', 'unit gates (vitest) — narrative version, claims, guards'); + runOrDie('vitest unit', 'npx', ['vitest', 'run', 'tests/unit']); } -// D. Publish BOTH delivery channels. This used to advance npm without creating the GitHub Release -// that verify-channels immediately required, making the sanctioned manual ship path impossible to -// complete. Build and sign first, then create/update the exact-SHA Release before npm advances. -// Re-runs are idempotent: a matching Release gets its three assets replaced; a tag bound to any -// other commit is a hard provenance failure. +// D. One remotely durable, staged release transaction (ADR-062 / DDD-0015). GitHub remains a draft +// and npm remains on a non-default candidate tag until exact bytes and all host fixtures pass. if (PUBLISH) { const v = V(); const tag = `v${v}`; const zip = path.join(ROOT, 'dist', 'ruvnet-brain.zip'); - const assets = [zip, `${zip}.sig`, `${zip}.sha256`, sealedPackageArtifact]; const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); - const priorTxn = readReleaseTransaction(); - const unfinished = priorTxn && priorTxn.state !== 'channels-converged'; - const samePendingCandidate = unfinished && priorTxn.tag === tag && priorTxn.head === head; - - step('D', 'build + sign bundle and publish matching GitHub/npm channels'); - if (unfinished && !samePendingCandidate) { - console.error(`\n${c.r('✗ GATE FAILED: unfinished release transaction requires reconciliation')}`); - console.error(c.dim(` ${priorTxn.state}: ${priorTxn.tag || '?'} @ ${priorTxn.head || '?'}; refusing to overwrite it with ${tag} @ ${head}`)); - process.exit(1); - } - if (samePendingCandidate) { - if (!assets.every((asset) => fs.existsSync(asset))) { - console.error(`\n${c.r('✗ GATE FAILED: pending release assets are missing; reconcile before retrying')}`); - process.exit(1); - } - const declared = fs.readFileSync(`${zip}.sha256`, 'utf8').trim().split(/\s+/)[0]; - const actual = crypto.createHash('sha256').update(fs.readFileSync(zip)).digest('hex'); - const verify = spawnSync(process.execPath, ['scripts/verify-bundle.mjs', zip, `${zip}.sig`], { - cwd: ROOT, encoding: 'utf8', - }); - if (declared !== priorTxn.bundleSha256 || actual !== declared || verify.status !== 0) { - console.error(`\n${c.r('✗ GATE FAILED: pending release assets do not match their signed transaction')}`); - process.exit(1); - } - console.log(c.dim(' resume existing signed release assets for the pending candidate')); - } else { - const buildArgs = ['scripts/build-bundle.mjs', '--version', tag]; - if (process.env.RUVNET_RELEASE_ASSETS) buildArgs.push('--assets', process.env.RUVNET_RELEASE_ASSETS); - runOrDie('build release bundle', process.execPath, buildArgs); - runOrDie('sign release bundle', process.execPath, ['scripts/sign-bundle.mjs', '--bundle', zip]); - } - for (const asset of assets) { + const buildArgs = ['scripts/build-bundle.mjs', '--version', tag]; + if (process.env.RUVNET_RELEASE_ASSETS) buildArgs.push('--assets', process.env.RUVNET_RELEASE_ASSETS); + step('D', 'prepare, stage, promote, and reconcile one signed remote transaction'); + runOrDie('build release bundle', process.execPath, buildArgs); + runOrDie('sign release bundle', process.execPath, ['scripts/sign-bundle.mjs', '--bundle', zip]); + const assets = { + bundlePath: zip, + bundleSignaturePath: `${zip}.sig`, + bundleDigestPath: `${zip}.sha256`, + packagePath: sealedPackageArtifact, + }; + for (const asset of Object.values(assets)) { if (!fs.existsSync(asset)) { console.error(`\n${c.r('✗ GATE FAILED: signed release asset missing')} ${c.dim(asset)}`); process.exit(1); @@ -279,157 +191,33 @@ if (PUBLISH) { process.exit(1); } - let remoteTagSha = ''; - try { - remoteTagSha = remoteTagCommit(tag); - } catch (e) { - console.error(`\n${c.r('✗ GATE FAILED: could not verify remote release tag')} ${c.dim(String(e.message || e).split('\n')[0])}`); - process.exit(1); - } - if (remoteTagSha && remoteTagSha !== head) { - console.error(`\n${c.r('✗ GATE FAILED: release tag already identifies different bytes')}`); - console.error(c.dim(` ${tag} -> ${remoteTagSha}; candidate HEAD -> ${head}`)); - process.exit(1); - } - - // Cross-provider publication cannot be truly atomic. Persist the exact convergence state before - // the first remote mutation so a failed npm publish is recoverable and the next run can converge - // the SAME tag/HEAD instead of guessing which channel moved. - if (priorTxn && !['channels-converged'].includes(priorTxn.state)) { - const sameCandidate = priorTxn.tag === tag - && priorTxn.head === head - && priorTxn.bundleSha256 === bundleSha256; - if (!sameCandidate) { - console.error(`\n${c.r('✗ GATE FAILED: unfinished release transaction requires reconciliation')}`); - if (priorTxn.tag === tag && priorTxn.head === head && priorTxn.bundleSha256 !== bundleSha256) { - console.error(c.dim(' release transaction artifact digest changed for the same tag and HEAD')); - } - console.error(c.dim(` ${priorTxn.state}: ${priorTxn.tag || '?'} @ ${priorTxn.head || '?'}; refusing to overwrite it with ${tag} @ ${head}`)); - process.exit(1); - } - } - recordReleaseTransaction('prepared', { version: v, tag, head, bundleSha256 }); - - let releaseExists = false; - try { - execFileSync('gh', ['release', 'view', tag, '--repo', 'stuinfla/ruvnet-brain'], { - cwd: ROOT, stdio: ['ignore', 'ignore', 'ignore'], - }); - releaseExists = true; - } catch { /* absent is the expected first-publish state */ } - - if (releaseExists) { - if (!remoteTagSha) { - console.error(`\n${c.r('✗ GATE FAILED: GitHub Release exists without a verifiable matching tag')} ${c.dim(tag)}`); - process.exit(1); - } - runOrDie('replace signed GitHub Release assets', 'gh', [ - 'release', 'upload', tag, ...assets, '--clobber', '--repo', 'stuinfla/ruvnet-brain', - ]); - } else { - runOrDie('create signed GitHub Release', 'gh', [ - 'release', 'create', tag, ...assets, - '--repo', 'stuinfla/ruvnet-brain', - '--target', head, - '--title', `${tag} — verified release`, - '--generate-notes', - '--latest', - ]); - } - - // Confirm the tag GitHub created/retained points to the candidate before touching npm. - let publishedTagSha = ''; - try { - publishedTagSha = remoteTagCommit(tag); - } catch { /* handled by the mismatch below */ } - if (publishedTagSha !== head) { - console.error(`\n${c.r('✗ GATE FAILED: published GitHub Release tag is not candidate HEAD')}`); - console.error(c.dim(` ${tag} -> ${publishedTagSha || '(missing)'}; candidate HEAD -> ${head}`)); - process.exit(1); - } - recordReleaseTransaction('github-published-npm-pending', { version: v, tag, head, bundleSha256 }); - - let already = ''; - try { already = execFileSync('npm', ['view', `ruvnet-brain@${v}`, 'version'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim(); } catch { /* not published yet */ } - if (already === v) console.log(c.dim(` ${v} already on npm — skipping publish, just re-asserting the tag`)); - // npm requires an explicit tag for prerelease versions. This project intentionally serves its - // `-dev` release from `latest`, so make that policy explicit on the publish command itself. - else runOrDie('npm publish', 'npm', ['publish', sealedPackageArtifact, '--tag', 'latest']); - // npm does NOT auto-move `latest` to a prerelease (x.y.z-dev) — force it, or `@latest` stays stale. - runOrDie('npm dist-tag latest', 'npm', ['dist-tag', 'add', `ruvnet-brain@${v}`, 'latest']); - recordReleaseTransaction('publish-complete-verification-pending', { version: v, tag, head, bundleSha256 }); + const packageIntegrity = `sha512-${crypto.createHash('sha512').update(fs.readFileSync(sealedPackageArtifact)).digest('base64')}`; + const identity = { + repository: 'stuinfla/ruvnet-brain', package: 'ruvnet-brain', version: v, tag, + candidateSha: head, packageIntegrity, bundleSha256, + }; + const privatePem = process.env.RUVNET_SIGNING_KEY; + if (!privatePem) throw new Error('RUVNET_SIGNING_KEY is required for signed transaction receipts'); + const finalReceipt = await runReleaseTransaction({ + identity, assets, adapter: liveReleaseProvider({ + root: ROOT, + candidateReceipt: process.env.RUVNET_CANDIDATE_RECEIPT, + publicationReceipt: process.env.RUVNET_PUBLICATION_RECEIPT, + }), + privateKey: crypto.createPrivateKey(privatePem), + publicKey: crypto.createPublicKey(fs.readFileSync(path.join(ROOT, 'keys/ruvnet-brain-signing.pub.pem'), 'utf8')), + hostVerifier: stagedHostVerifier({ assets, identity }), + }); + if (finalReceipt.state !== 'channels-converged') throw new Error(`release transaction stopped at ${finalReceipt.state}`); } else { - step('D', 'GitHub Release + npm publish — SKIPPED (check-only; pass --publish to publish)'); + step('D', 'remote staged release transaction — SKIPPED (check-only; pass --publish to publish)'); } -// D+. THE DEPLOY-SURFACE SWEEP (owner standing order, 2026-07-27): "ALWAYS check GitHub CLI and -// Vercel CLI for gotchas with anything you're pushing. This needs to be part of the protocol you use -// whenever you deploy. I don't want to have to tell you this again." -// -// Gate C++ already asks whether CI passed. That is one surface. This asks the two CLIs what the -// PLATFORMS think — failing workflows other than our own ci, security advisories, and whether the -// production deployment that serves the explainer is actually Ready. Each is a question a human -// would otherwise have to remember to ask, which is the definition of a check that eventually -// doesn't happen. -// -// ADVISORY BY DESIGN, LOUD BY CONTRACT: this prints findings and does not exit non-zero, because a -// GitHub-side hiccup must not wedge a correct release — EXCEPT where it overlaps a hard gate that -// already exists (C++ for ci, E for the live explainer). Anything it finds is printed in full so it -// cannot be a diagnostic nobody reads. -step('D+', 'deploy-surface sweep — what GitHub and Vercel think about what we are pushing'); -{ - const sh = (cmd, args) => { try { return execFileSync(cmd, args, { encoding: 'utf8', stdio: ['ignore','pipe','ignore'], timeout: 45000 }); } catch { return null; } }; - - // 1. Failing workflow runs that are NOT our ci (ci is gate C++'s job). issue-watch exits 1 BY - // DESIGN on an SLA breach, so it is reported as an SLA signal, never as a broken pipeline — - // conflating the two is how a permanently-red workflow trains everyone to ignore red. - const runs = sh('gh', ['run','list','--repo','stuinfla/ruvnet-brain','--limit','15','--json','name,conclusion,headBranch']); - if (runs) { - let bad = []; - try { bad = JSON.parse(runs).filter((r) => r.conclusion && r.conclusion !== 'success' && r.name !== 'ci'); } catch { /* unparseable — reported below */ } - const sla = bad.filter((r) => r.name === 'issue-watch'); - const real = bad.filter((r) => r.name !== 'issue-watch'); - if (sla.length) console.log(` ${c.y('! issue-watch red x' + sla.length)} ${c.dim('— by design: an open issue is past its 4h SLA. Answer the issue, do not fix the workflow.')}`); - if (real.length) console.log(` ${c.y('! non-ci workflows failing:')} ${real.map((r) => r.name).join(', ')}`); - if (!sla.length && !real.length) console.log(c.dim(' no failing workflows outside ci')); - } else console.log(c.dim(' gh unavailable — workflow sweep SKIPPED (not a pass)')); - - // 2. Security advisories against what we ship. - const dep = sh('gh', ['api','repos/stuinfla/ruvnet-brain/dependabot/alerts','--jq','[.[]|select(.state=="open")]|length']); - if (dep !== null) { - const n = parseInt(dep.trim(), 10); - console.log(n > 0 ? ` ${c.r('! ' + n + ' open dependabot alert(s)')}` : c.dim(' 0 open dependabot alerts')); - } else console.log(c.dim(' dependabot query unavailable — SKIPPED (not a pass)')); - - // 3. Vercel: the explainer is a shipped surface; a Ready production deployment is the precondition - // for gate E's live check meaning anything. - const vc = sh('vercel', ['ls','--yes']); - if (vc) { - const prod = vc.split('\n').find((l) => l.includes('Production')); - const ready = prod && /●\s*Ready/.test(prod); - console.log(ready ? c.dim(' vercel: latest production deployment Ready') : ` ${c.y('! vercel: latest production deployment is NOT Ready')} ${c.dim((prod||'').trim().slice(0,90))}`); - } else console.log(c.dim(' vercel CLI unavailable/not logged in — SKIPPED (not a pass)')); -} - -// E. the live channel walk — THE gate that would have caught the stale-2.9.1 + 404 -step('E', 'verify-channels — the live walk of every user path'); -runOrDie('verify-channels', process.execPath, ['scripts/verify-channels.mjs']); -if (PUBLISH) { - runOrDie('publication receipt', process.execPath, [ - 'scripts/publication-receipt.mjs', '--candidate', process.env.RUVNET_CANDIDATE_RECEIPT, - '--out', process.env.RUVNET_PUBLICATION_RECEIPT, - ]); - runOrDie('publication seal', process.execPath, [ - 'scripts/release-proof.mjs', '--candidate', process.env.RUVNET_CANDIDATE_RECEIPT, - '--publication', process.env.RUVNET_PUBLICATION_RECEIPT, - ]); - const txn = readReleaseTransaction(); - recordReleaseTransaction('channels-converged', { - version: V(), - tag: `v${V()}`, - head: execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(), - bundleSha256: txn?.bundleSha256 ?? null, - }); +// Check-only diagnoses the currently public channels. During publication, transaction finalization +// performs this walk once, then creates and verifies the publication receipt before convergence. +if (!PUBLISH) { + step('E', 'verify-channels — the live walk of every user path'); + runOrDie('verify-channels', process.execPath, ['scripts/verify-channels.mjs']); } if (PUBLISH) { diff --git a/scripts/session-snapshot-contract.mjs b/scripts/session-snapshot-contract.mjs new file mode 100644 index 00000000..c8c50f3b --- /dev/null +++ b/scripts/session-snapshot-contract.mjs @@ -0,0 +1 @@ +export * from '../plugin/scripts/session-snapshot-contract.mjs'; diff --git a/scripts/staged-host-verifier.mjs b/scripts/staged-host-verifier.mjs new file mode 100644 index 00000000..4d285352 --- /dev/null +++ b/scripts/staged-host-verifier.mjs @@ -0,0 +1,106 @@ +import { execFileSync, spawnSync } from 'node:child_process'; +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const sha256 = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +const locate = (name) => { + try { return execFileSync('which', [name], { encoding: 'utf8' }).trim(); } catch { return null; } +}; + +const run = (name, args, options) => { + const result = spawnSync(name, args, { encoding: 'utf8', ...options }); + if (result.error || result.status !== 0) { + const detail = String(result.stderr || result.stdout || result.error?.message); + throw new Error(`${path.basename(name)} ${args.join(' ')} failed: ${detail.slice(-5000)}`); + } + return result; +}; + +export function classifyDoctorResult(result) { + const output = `${result.stdout || ''}\n${result.stderr || ''}`; + if (!result.error && result.status === 0) return { accepted: true, status: 'PASS', output }; + const pendingReview = result.status === 1 + && /Codex installed the Brain, but \d+ lifecycle hooks await review/i.test(output) + && /Grounding PROVEN/i.test(output) + && !/reader MISSING|search_ruvnet MISSING|host convergence incomplete|receipt is invalid/i.test(output); + return { accepted: pendingReview, status: pendingReview ? 'PENDING_REVIEW' : 'FAIL', output }; +} + +const preparePackage = ({ packagePath, bundlePath }) => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-staged-host-')); + run('tar', ['-xzf', packagePath, '-C', temp]); + const packageRoot = path.join(temp, 'package'); + const bundleRoot = path.join(packageRoot, 'dist', 'ruvnet-brain'); + fs.mkdirSync(bundleRoot, { recursive: true }); + run('unzip', ['-q', bundlePath, '-d', bundleRoot]); + const nested = path.join(bundleRoot, 'ruvnet-brain'); + if (fs.existsSync(nested)) { + for (const name of fs.readdirSync(nested)) fs.renameSync(path.join(nested, name), path.join(bundleRoot, name)); + fs.rmdirSync(nested); + } + return { temp, packageRoot }; +}; + +const fixturePath = (mode, temp) => { + const bin = path.join(temp, `bin-${mode}`); + fs.mkdirSync(bin); + const hosts = mode === 'claude' ? ['claude'] : mode === 'codex' ? ['codex'] : ['claude', 'codex']; + const desired = ['node', 'npm', ...hosts]; + for (const name of desired) { + const target = locate(name); + if (!target) throw new Error(`${name} CLI unavailable for ${mode} host fixture`); + fs.symlinkSync(target, path.join(bin, name)); + } + return `${bin}:/usr/bin:/bin`; +}; + +export function stagedHostVerifier({ assets, identity }) { + return { + async verify({ source, assets: observedAssets = assets }) { + const prepared = preparePackage(observedAssets); + const results = {}; + try { + for (const mode of ['claude', 'codex', 'dual']) { + const home = path.join(prepared.temp, `home-${mode}`); + const brainHome = path.join(home, '.cache', 'ruvnet-brain'); + fs.mkdirSync(path.join(home, '.claude'), { recursive: true }); + if (mode !== 'claude') fs.mkdirSync(path.join(home, '.codex'), { recursive: true }); + const env = { + ...process.env, + HOME: home, + CODEX_HOME: path.join(home, '.codex'), + RUVNET_BRAIN_HOME: brainHome, + RUVNET_BRAIN_KB: path.join(brainHome, 'kb'), + RUVNET_CLAUDE_MARKETPLACE_SOURCE: prepared.packageRoot, + CI: 'true', + PATH: fixturePath(mode, prepared.temp), + }; + const installer = path.join(prepared.packageRoot, 'bin', 'install.mjs'); + run(process.execPath, [installer, '--local', '--yes', '--force', '--no-nightly-prompt', + '--no-telemetry', '--no-stack', '--no-enhance', '--no-statusline', '--no-selfcheck'], { + cwd: prepared.packageRoot, env, timeout: 1_200_000, + }); + const doctor = spawnSync(process.execPath, [installer, '--doctor'], { + cwd: prepared.packageRoot, env, timeout: 180_000, + }); + const classified = classifyDoctorResult(doctor); + if (!classified.accepted) { + throw new Error(`doctor failed for ${mode}: ${classified.output.slice(-5000) || doctor.error?.message}`); + } + results[mode] = { + status: classified.status, + doctorExit: doctor.status, + version: identity.version, + }; + } + return { verdict: 'PASS', source, artifactSha256: sha256(observedAssets.packagePath), fixtures: results }; + } catch (error) { + return { verdict: 'FAIL', error: error.message, fixtures: results }; + } finally { + fs.rmSync(prepared.temp, { recursive: true, force: true }); + } + }, + }; +} diff --git a/tests/acceptance/helpers/packed-console-fixture.mjs b/tests/acceptance/helpers/packed-console-fixture.mjs new file mode 100644 index 00000000..9829f6df --- /dev/null +++ b/tests/acceptance/helpers/packed-console-fixture.mjs @@ -0,0 +1,140 @@ +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; + +export const REPO = path.resolve(import.meta.dirname, '../../..'); + +function checked(command, args, options = {}) { + const result = spawnSync(command, args, { encoding: 'utf8', timeout: 120_000, ...options }); + if (result.error || result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} failed (${result.status}): ${result.stderr || result.error?.message}`); + } + return result; +} + +async function freePort() { + const server = http.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return port; +} + +async function waitFor(check, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await check(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('timed out waiting for the packed Console'); +} + +function waitForExit(child, timeoutMs = 5_000) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL'); + resolve(); + }, timeoutMs); + child.once('exit', () => { clearTimeout(timer); resolve(); }); + }); +} + +export async function installPackedConsole({ prefix, catalog = null, profile = null, decisions = [] } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix || 'brain-packed-console-')); + const home = path.join(root, 'home'); + const project = path.join(root, 'unrelated-project'); + const packDir = path.join(root, 'pack'); + for (const directory of [home, project, packDir]) fs.mkdirSync(directory, { recursive: true }); + + const packageRoot = process.env.RUVNET_ACCEPTANCE_PACKAGE_ROOT || REPO; + const packed = checked('npm', ['pack', '--json', '--pack-destination', packDir], { cwd: packageRoot }); + const tarball = path.join(packDir, JSON.parse(packed.stdout)[0].filename); + checked('tar', ['-xzf', tarball, '-C', packDir]); + const payload = path.join(packDir, 'package'); + + const routerDir = path.join(home, '.claude', 'model-router'); + if (catalog || profile) fs.mkdirSync(routerDir, { recursive: true }); + if (catalog) fs.writeFileSync(path.join(routerDir, 'catalog.json'), `${JSON.stringify(catalog, null, 2)}\n`); + if (profile) fs.writeFileSync(path.join(routerDir, 'profile.json'), `${JSON.stringify(profile, null, 2)}\n`); + if (decisions.length) { + const decisionDir = path.join(home, '.claude', 'metaharness'); + fs.mkdirSync(decisionDir, { recursive: true }); + fs.writeFileSync(path.join(decisionDir, 'routing-decisions.jsonl'), `${decisions.map((row) => JSON.stringify(row)).join('\n')}\n`); + } + + const cache = path.join(home, '.cache', 'ruvnet-brain'); + const installScript = [ + "import path from 'node:path';", + "import { pathToFileURL } from 'node:url';", + "process.env.RUVNET_BRAIN_IMPORT_ONLY = '1';", + 'const [payload, cache, installRouter] = process.argv.slice(1);', + "const installer = await import(pathToFileURL(path.join(payload, 'bin', 'install.mjs')).href);", + 'installer.installConsoleRuntime(cache, payload);', + "if (installRouter === '1') await installer.offerRouterProfile();", + ].join(''); + checked(process.execPath, ['--input-type=module', '-e', installScript, payload, cache, catalog ? '1' : '0'], { + cwd: project, + env: { ...process.env, HOME: home, USERPROFILE: home }, + }); + + const entry = path.join(cache, '.console-runtime', 'scripts', 'onboarding-console.mjs'); + const children = new Set(); + let output = ''; + const baseEnv = { + ...process.env, + HOME: home, + USERPROFILE: home, + RUVNET_CONSOLE_ROOT: home, + RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH: '1', + }; + + return { + root, + home, + project, + payload, + entry, + installedCatalog: path.join(routerDir, 'catalog.json'), + measureState() { + checked(process.execPath, [entry, '--print-state'], { cwd: project, env: baseEnv }); + }, + async start() { + const port = await freePort(); + const child = spawn(process.execPath, [entry, '--serve'], { + cwd: project, + env: { ...baseEnv, CONSOLE_PORT: String(port) }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + children.add(child); + child.stdout.on('data', (chunk) => { output += chunk; }); + child.stderr.on('data', (chunk) => { output += chunk; }); + child.once('exit', () => children.delete(child)); + await waitFor(async () => { + try { + const response = await fetch(`http://127.0.0.1:${port}/api/runtime`); + return response.ok ? response.json() : null; + } catch { return null; } + }); + return { child, port, url: `http://127.0.0.1:${port}/` }; + }, + output: () => output, + async cleanup() { + const running = [...children]; + for (const child of running) if (child.exitCode === null) child.kill('SIGTERM'); + await Promise.all(running.map((child) => waitForExit(child))); + fs.rmSync(root, { recursive: true, force: true }); + }, + }; +} + +export function chromeExecutable(chromium) { + return [ + process.env.PLAYWRIGHT_CHROME_PATH, + chromium.executablePath(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ].find((candidate) => candidate && fs.existsSync(candidate)); +} diff --git a/tests/acceptance/issue-76-codex-whats-new.acceptance.test.mjs b/tests/acceptance/issue-76-codex-whats-new.acceptance.test.mjs new file mode 100644 index 00000000..c2cc9d86 --- /dev/null +++ b/tests/acceptance/issue-76-codex-whats-new.acceptance.test.mjs @@ -0,0 +1,84 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); +const CODEX = process.env.RUVNET_CODEX_BIN || 'codex'; +const temps = []; + +function tempDir(label) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), label)); + temps.push(dir); + return dir; +} + +function runCodex(home, args, cwd = ROOT) { + return spawnSync(CODEX, args, { + cwd, + env: { ...process.env, CODEX_HOME: home }, + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 20 * 1024 * 1024, + }); +} + +function findFile(root, suffix) { + const pending = [root]; + while (pending.length) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const child = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(child); + else if (entry.isFile() && child.endsWith(suffix)) return child; + } + } + return null; +} + +afterEach(() => { + for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('issue #76 real Codex installed boundary', () => { + const available = spawnSync(CODEX, ['--version'], { encoding: 'utf8' }).status === 0; + const auth = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'auth.json'); + const test = available && fs.existsSync(auth) ? it : it.skip; + + test('loads the installed skill and runs its cached executable after marketplace source removal', () => { + const codexHome = tempDir('brain-issue76-codex-'); + const marketplace = tempDir('brain-issue76-market-'); + const unrelatedCwd = tempDir('brain-issue76-cwd-'); + fs.cpSync(path.join(ROOT, 'plugin'), marketplace, { recursive: true }); + + const added = runCodex(codexHome, ['plugin', 'marketplace', 'add', marketplace, '--json']); + expect(added.status, added.stderr || added.stdout).toBe(0); + const installed = runCodex(codexHome, ['plugin', 'add', 'ruvnet-brain@ruvnet-brain', '--json']); + expect(installed.status, installed.stderr || installed.stdout).toBe(0); + fs.rmSync(marketplace, { recursive: true, force: true }); + + const rendered = runCodex(codexHome, ['debug', 'prompt-input', '$ruvnet-brain:whats-new'], unrelatedCwd); + expect(rendered.status, rendered.stderr || rendered.stdout).toBe(0); + expect(rendered.stdout).toContain('ruvnet-brain:whats-new'); + + const executable = findFile(path.join(codexHome, 'plugins', 'cache'), path.join('scripts', 'whats-new.mjs')); + expect(executable).toBeTruthy(); + const manifestFile = findFile(path.dirname(path.dirname(executable)), path.join('.codex-plugin', 'plugin.json')); + expect(manifestFile).toBeTruthy(); + const version = JSON.parse(fs.readFileSync(manifestFile, 'utf8')).version; + const invoked = spawnSync(process.execPath, [executable], { cwd: unrelatedCwd, encoding: 'utf8' }); + expect(invoked.status, invoked.stderr).toBe(0); + expect(invoked.stdout).toContain(`RuvNet Brain ${version}`); + expect(invoked.stdout).toContain("# RuvNet-Brain 4.0 line — what's new"); + + fs.symlinkSync(auth, path.join(codexHome, 'auth.json')); + const host = runCodex(codexHome, [ + 'exec', '--ephemeral', '--skip-git-repo-check', '--ignore-rules', '--sandbox', 'read-only', '--json', + '$ruvnet-brain:whats-new Run the installed skill, report its exact Brain version, then end with HOST_ACCEPTED.', + ], unrelatedCwd); + expect(host.status, host.stderr || host.stdout).toBe(0); + expect(host.stdout).toContain('HOST_ACCEPTED'); + expect(host.stdout).toContain(version); + }, 60_000); +}); diff --git a/tests/acceptance/issue-77-host-convergence.acceptance.test.mjs b/tests/acceptance/issue-77-host-convergence.acceptance.test.mjs new file mode 100644 index 00000000..d0518d78 --- /dev/null +++ b/tests/acceptance/issue-77-host-convergence.acceptance.test.mjs @@ -0,0 +1,91 @@ +import { afterEach, beforeAll, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); +const VERSION = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version; +const temps = []; +let install; + +beforeAll(async () => { + process.env.RUVNET_BRAIN_IMPORT_ONLY = '1'; + install = await import('../../bin/install.mjs'); +}); + +afterEach(() => { + for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('issue #77 installed host convergence boundary', () => { + it('activates Stable Spine and Console runtime as one exact candidate receipt', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-issue77-host-')); + temps.push(home); + const kb = path.join(home, 'kb'); + const brainHome = path.join(home, 'brain'); + fs.mkdirSync(kb, { recursive: true }); + + const result = install.syncHostsAfterUpdate(kb, { + sourceRoot: ROOT, + brainHome, + wireClaude: () => ({ host: true, wired: true, version: VERSION }), + wireCodexHost: () => ({ host: true, wired: true }), + wireCodexPlugin: () => ({ action: 'updated', installed: true, enabled: true, version: VERSION }), + runStableSpine: () => ({ status: 0, error: undefined }), + }); + + expect(result.ok).toBe(true); + const receipt = JSON.parse(fs.readFileSync(path.join(brainHome, 'host-convergence.json'), 'utf8')); + expect(receipt).toMatchObject({ + desiredVersion: VERSION, + hosts: { + claude: { state: 'ready', version: VERSION }, + codex: { state: 'ready', version: VERSION }, + }, + consoleRuntime: { runtimeVersion: VERSION, state: 'ready' }, + }); + const runtimeManifest = JSON.parse(fs.readFileSync( + path.join(kb, '.console-runtime', 'package.json'), 'utf8', + )); + expect(runtimeManifest.version).toBe(VERSION); + expect(fs.existsSync(path.join(kb, '.console-runtime', 'scripts', 'onboarding-console.mjs'))).toBe(true); + }); + + it('rolls back the staged Console candidate when either host cannot converge', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-issue77-rollback-')); + temps.push(home); + const kb = path.join(home, 'kb'); + fs.mkdirSync(kb, { recursive: true }); + const active = path.join(kb, '.console-runtime'); + fs.mkdirSync(active, { recursive: true }); + fs.writeFileSync(path.join(active, 'sentinel.txt'), 'candidate-a'); + + const result = install.syncHostsAfterUpdate(kb, { + sourceRoot: ROOT, + brainHome: path.join(home, 'brain'), + wireClaude: () => ({ host: true, wired: true, version: VERSION }), + wireCodexHost: () => ({ host: true, wired: true }), + wireCodexPlugin: () => ({ action: 'verification-failed', version: 'candidate-a' }), + runStableSpine: () => ({ status: 0, error: undefined }), + }); + + expect(result.ok).toBe(false); + expect(fs.readFileSync(path.join(active, 'sentinel.txt'), 'utf8')).toBe('candidate-a'); + expect(fs.existsSync(path.join(home, 'brain', 'host-convergence.json'))).toBe(false); + }); + + it('keeps a running stale Console explicitly non-converged until restart', () => { + const receipt = { + desiredVersion: VERSION, + hosts: { + claude: { state: 'ready', version: VERSION }, + codex: { state: 'disabled', version: VERSION }, + }, + consoleRuntime: { state: 'pending-console-restart', runtimeVersion: VERSION }, + }; + expect(install.classifyHostConvergence(receipt)).toMatchObject({ + healthy: false, + state: 'pending-console-restart', + }); + }); +}); diff --git a/tests/acceptance/issue-78-codex-cold-mcp.acceptance.test.mjs b/tests/acceptance/issue-78-codex-cold-mcp.acceptance.test.mjs new file mode 100644 index 00000000..e1f91a7c --- /dev/null +++ b/tests/acceptance/issue-78-codex-cold-mcp.acceptance.test.mjs @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); +const CODEX = process.env.RUVNET_CODEX_BIN || 'codex'; +const temps = []; + +afterEach(() => { + for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('issue #78 real Codex cold MCP discovery', () => { + const available = spawnSync(CODEX, ['--version'], { encoding: 'utf8' }).status === 0; + const auth = path.join(process.env.CODEX_HOME || path.join(os.homedir(), '.codex'), 'auth.json'); + const test = available && fs.existsSync(auth) ? it : it.skip; + + test('keeps Brain managed-CLI tools callable while worker warmup exceeds the host deadline', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-issue78-codex-')); + temps.push(root); + const codexHome = path.join(root, 'codex'); + const kb = path.join(root, 'kb'); + const brainHome = path.join(root, 'brain'); + fs.mkdirSync(codexHome, { recursive: true }); + fs.mkdirSync(kb, { recursive: true }); + fs.writeFileSync(path.join(kb, 'forge-mcp-all.mjs'), ` +import readline from 'node:readline'; +const rl = readline.createInterface({ input: process.stdin }); +rl.on('line', (line) => { + const msg = JSON.parse(line); + const reply = (result) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }) + '\\n'); + if (msg.method === 'initialize') return reply({ protocolVersion: '2024-11-05', capabilities: {} }); + if (msg.method === 'brain/warmup') return setTimeout(() => reply({ ready: true }), 60_000); + if (msg.method === 'tools/call') return reply({ content: [{ type: 'text', text: 'cold-ready' }] }); +}); +`); + const server = path.join(ROOT, 'plugin', 'mcp', 'server.mjs'); + const tomlPath = (value) => JSON.stringify(value); + fs.writeFileSync(path.join(codexHome, 'config.toml'), [ + '[mcp_servers.ruvnet-brain]', + `command = ${tomlPath(process.execPath)}`, + `args = [${tomlPath(server)}]`, + 'startup_timeout_sec = 10', + '[mcp_servers.ruvnet-brain.env]', + `RUVNET_BRAIN_HOME = ${tomlPath(brainHome)}`, + `RUVNET_BRAIN_KB = ${tomlPath(kb)}`, + `RUVNET_BRAIN_PROJECT_SETTINGS_FILE = ${tomlPath(path.join(root, 'absent.json'))}`, + '', + ].join('\n')); + + fs.symlinkSync(auth, path.join(codexHome, 'auth.json')); + const started = performance.now(); + const result = spawnSync(CODEX, [ + 'exec', '--ephemeral', '--skip-git-repo-check', '--ignore-rules', '--sandbox', 'read-only', '--json', + 'Call ruvnet-brain ruvnet_cli_help with executable agentic-qe and empty argv. Do not call search. End with HOST_ACCEPTED.', + ], { + cwd: root, + env: { ...process.env, CODEX_HOME: codexHome }, + encoding: 'utf8', + timeout: 55_000, + maxBuffer: 20 * 1024 * 1024, + }); + const elapsedMs = performance.now() - started; + + expect(result.status, result.stderr || result.stdout).toBe(0); + expect(elapsedMs).toBeLessThan(55_000); + expect(result.stdout).toContain('HOST_ACCEPTED'); + expect(result.stdout).toContain('Agentic QE'); + }, 60_000); +}); diff --git a/tests/acceptance/issue-79-browser-console-lifecycle.acceptance.test.mjs b/tests/acceptance/issue-79-browser-console-lifecycle.acceptance.test.mjs new file mode 100644 index 00000000..f776d84a --- /dev/null +++ b/tests/acceptance/issue-79-browser-console-lifecycle.acceptance.test.mjs @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { chromium } from 'playwright'; +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { installConsoleRuntime } from '../../bin/install.mjs'; +import { getVersion } from '../../scripts/version.mjs'; +import { consoleFixtureEnvironment } from '../helpers/console-fixture-environment.mjs'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); +const SOURCE = path.join(ROOT, 'scripts', 'onboarding-console.mjs'); +const children = new Set(); +const temps = []; + +async function freePort() { + const server = http.createServer(); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + await new Promise((resolve) => server.close(resolve)); + return port; +} + +function runtimeEnv(home, port) { + return consoleFixtureEnvironment(home, { + extras: { + CONSOLE_PORT: String(port), + RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH: '1', + }, + }); +} + +function start(script, home, cwd, port) { + const child = spawn(process.execPath, [script, '--serve'], { + cwd, + env: runtimeEnv(home, port), + stdio: ['ignore', 'pipe', 'pipe'], + }); + child.testStdout = ''; + child.testStderr = ''; + child.stdout.on('data', (chunk) => { child.testStdout += String(chunk); }); + child.stderr.on('data', (chunk) => { child.testStderr += String(chunk); }); + children.add(child); + child.once('exit', () => children.delete(child)); + return child; +} + +async function waitFor(check, timeoutMs = 20_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const value = await check(); + if (value) return value; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('timed out waiting for Console lifecycle transition'); +} + +afterEach(async () => { + const live = [...children].filter((child) => child.exitCode === null); + for (const child of live) child.kill('SIGTERM'); + await Promise.all(live.map((child) => new Promise((resolve, reject) => { + if (child.exitCode !== null) return resolve(); + const hardKill = setTimeout(() => child.kill('SIGKILL'), 2_000); + const failed = setTimeout(() => reject(new Error(`Console child ${child.pid} did not exit after SIGKILL`)), 4_000); + child.once('exit', () => { clearTimeout(hardKill); clearTimeout(failed); resolve(); }); + }))); + children.clear(); + for (const dir of temps.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +describe('issue #79 installed browser lifecycle', () => { + const chrome = [ + process.env.PLAYWRIGHT_CHROME_PATH, + chromium.executablePath(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ].find((candidate) => candidate && fs.existsSync(candidate)); + const test = chrome ? it : it.skip; + + test('a real browser reconnects from owned candidate A to candidate B on the same URL', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-issue79-browser-')); + temps.push(root); + const home = path.join(root, 'home'); + const cwd = path.join(root, 'project'); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(cwd, { recursive: true }); + // Build both candidates through the same transaction and file manifest the installer uses. + // This keeps the acceptance boundary tied to the shipped runtime instead of a second fixture list. + const aScript = installConsoleRuntime(path.join(root, 'candidate-a-cache'), ROOT); + const bScript = installConsoleRuntime(path.join(root, 'candidate-b-cache'), ROOT); + fs.appendFileSync(aScript, '\n// issue-79 candidate A digest\n'); + const port = await freePort(); + const first = start(aScript, home, cwd, port); + + const firstIdentity = await waitFor(async () => { + try { + const response = await fetch(`http://127.0.0.1:${port}/api/runtime`); + return response.ok ? response.json() : null; + } catch { return null; } + }); + expect(firstIdentity.pid).toBe(first.pid); + + const browser = await chromium.launch({ executablePath: chrome, headless: true }); + try { + const page = await browser.newPage(); + await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'domcontentloaded' }); + expect(await page.title()).toMatch(/RuvNet Brain/i); + + const statusProbe = spawnSync(process.execPath, [bScript, '--runtime-status'], { + cwd, + env: runtimeEnv(home, port), + encoding: 'utf8', + timeout: 5_000, + }); + expect(statusProbe.status, statusProbe.stderr).toBe(0); + expect(JSON.parse(statusProbe.stdout).state).toBe('stale-running'); + + const second = start(bScript, home, cwd, port); + let secondIdentity; + try { + secondIdentity = await waitFor(async () => { + try { + const value = await page.evaluate(async () => (await fetch('/api/runtime')).json()); + return value.pid === second.pid ? value : null; + } catch { return null; } + }); + } catch (error) { + throw new Error(`${error.message}\nfirst stdout: ${first.testStdout}\nfirst stderr: ${first.testStderr}\nsecond stdout: ${second.testStdout}\nsecond stderr: ${second.testStderr}`); + } + expect(secondIdentity.pid).toBe(second.pid); + expect(secondIdentity.runtimeVersion).toBe(getVersion()); + expect(secondIdentity.sourceSha256).not.toBe(firstIdentity.sourceSha256); + expect(await page.evaluate(async () => (await fetch('/api/capabilities')).status)).toBe(200); + await page.reload({ waitUntil: 'domcontentloaded' }); + expect(await page.title()).toMatch(/RuvNet Brain/i); + } finally { + await browser.close(); + } + }, 60_000); +}); diff --git a/tests/acceptance/issue-83-installed-lesson-provenance.acceptance.test.mjs b/tests/acceptance/issue-83-installed-lesson-provenance.acceptance.test.mjs new file mode 100644 index 00000000..98b411f8 --- /dev/null +++ b/tests/acceptance/issue-83-installed-lesson-provenance.acceptance.test.mjs @@ -0,0 +1,70 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { chromium } from 'playwright'; +import fs from 'node:fs'; +import path from 'node:path'; +import { chromeExecutable, installPackedConsole } from './helpers/packed-console-fixture.mjs'; + +const fixtures = []; +const browsers = []; +const chrome = chromeExecutable(chromium); + +const lesson = (id, statement, origin, sourceClass, extra = {}) => ({ + id, + statement, + trigger: 'claim-done', + enforcement: 'review', + evidence: [{ observed: `fixture evidence for ${id}` }], + origin, + sourceClass, + status: 'candidate', + projects: [], + repeatCount: 1, + demoted: false, + ...extra, +}); + +afterEach(async () => { + await Promise.all(browsers.splice(0).map((browser) => browser.close())); + await Promise.all(fixtures.splice(0).map((fixture) => fixture.cleanup())); +}); + +describe('issue #83 packed unrelated-user Console provenance', () => { + const test = chrome ? it : it.skip; + + test('renders four provenance classes without laundering imported ownership', async () => { + const fixture = await installPackedConsole({ prefix: 'brain-issue83-installed-' }); + fixtures.push(fixture); + const store = path.join(fixture.home, '.config', 'ruvnet-brain', 'lessons.json'); + fs.mkdirSync(path.dirname(store), { recursive: true }); + fs.writeFileSync(store, `${JSON.stringify({ version: 1, lessons: [ + lesson('personal', 'The current user stated this personal verification rule.', 'user-stated', 'current-user'), + lesson('owner-import', 'A maintainer imported this unrelated owner rule.', 'imported', 'imported-owner', { demoted: true }), + lesson('model', 'The model inferred this behavior from observed history.', 'model-inferred', 'model-inferred'), + lesson('demo', 'This demonstration record is never personal policy.', 'imported', 'demonstration', { demoted: true }), + ] }, null, 2)}\n`); + + const server = await fixture.start(); + const response = await fetch(`${server.url}api/lessons`); + expect(response.status).toBe(200); + const api = await response.json(); + expect(api.lessons.map(({ id, origin, sourceClass, userStated, quarantined }) => ( + { id, origin, sourceClass, userStated, quarantined } + )).sort((a, b) => a.id.localeCompare(b.id))).toEqual([ + { id: 'demo', origin: 'demonstration data — not personal policy', sourceClass: 'demonstration', userStated: false, quarantined: true }, + { id: 'model', origin: 'I inferred this from what happened', sourceClass: 'model-inferred', userStated: false, quarantined: false }, + { id: 'owner-import', origin: 'imported maintainer history — not yours', sourceClass: 'imported-owner', userStated: false, quarantined: true }, + { id: 'personal', origin: 'you taught me this', sourceClass: 'current-user', userStated: true, quarantined: false }, + ]); + + const browser = await chromium.launch({ executablePath: chrome, headless: true }); + browsers.push(browser); + const page = await browser.newPage(); + await page.goto(server.url, { waitUntil: 'domcontentloaded' }); + await expect.poll(() => page.locator('.lesson-row').count()).toBe(4); + const rendered = await page.locator('.lesson-row').evaluateAll((rows) => rows.map((row) => row.textContent)); + expect(rendered.filter((text) => text.includes('you taught me this'))).toHaveLength(1); + expect(rendered.find((text) => text.includes('unrelated owner rule'))).toContain('imported maintainer history — not yours'); + expect(rendered.find((text) => text.includes('model inferred'))).toContain('I inferred this from what happened'); + expect(rendered.find((text) => text.includes('demonstration record'))).toContain('demonstration data — not personal policy'); + }, 60_000); +}); diff --git a/tests/acceptance/issue-86-packed-provider-state.acceptance.test.mjs b/tests/acceptance/issue-86-packed-provider-state.acceptance.test.mjs new file mode 100644 index 00000000..7ed0d2dd --- /dev/null +++ b/tests/acceptance/issue-86-packed-provider-state.acceptance.test.mjs @@ -0,0 +1,185 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execFileSync, spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import http from 'node:http'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { consoleFixtureEnvironment } from '../helpers/console-fixture-environment.mjs'; + +// Issue #86 crossed two release boundaries that source-checkout tests cannot cover: npm's packed +// allow-list and the installer's persistent `.console-runtime` transaction. Keep both boundaries +// real here; the only synthetic inputs are unmistakable dummy credentials. +const ROOT = path.resolve(import.meta.dirname, '../..'); +const secrets = { + OPENAI_API_KEY: 'issue86-openai-secret-value', + GOOGLE_API_KEY: 'issue86-google-secret-value', + GEMINI_API_KEY: 'issue86-gemini-secret-value', +}; +const temps = []; +const children = new Set(); +let runtimeScript; +let runtimeKb; + +function temporary(prefix) { + const value = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), prefix))); + temps.push(value); + return value; +} + +function scrubbedEnvironment() { + const env = { ...process.env }; + for (const key of Object.keys(env)) { + if (/(?:API_KEY|TOKEN|SECRET|PASSWORD)$/.test(key)) delete env[key]; + } + for (const key of [ + 'RUVNET_MODEL_CATALOG', + 'RUVNET_BRAIN_COMPLETE_SOURCE', + ...Object.keys(secrets), + ]) delete env[key]; + return env; +} + +function runtimeEnvironment(root, port, credentials = {}) { + return { + ...consoleFixtureEnvironment(root, { + baseEnv: scrubbedEnvironment(), + extras: { + CONSOLE_PORT: String(port), + RUVNET_CONSOLE_DISABLE_BACKGROUND_REFRESH: '1', + }, + }), + HOME: root, + USERPROFILE: root, + RUVNET_BRAIN_KB: runtimeKb, + ...credentials, + }; +} + +function freePort() { + return new Promise((resolve, reject) => { + const server = http.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const port = server.address().port; + server.close(() => resolve(port)); + }); + }); +} + +async function waitForState(port, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + try { + const response = await fetch(`http://127.0.0.1:${port}/api/state`); + if (response.ok) return response.json(); + } catch { /* server not listening yet */ } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error('timed out waiting for the staged Console /api/state'); +} + +async function stopChild(child) { + if (!child || child.exitCode !== null) return; + await new Promise((resolve, reject) => { + let force; + let deadline; + const done = () => { + clearTimeout(force); + clearTimeout(deadline); + resolve(); + }; + child.once('exit', done); + child.kill('SIGTERM'); + force = setTimeout(() => child.kill('SIGKILL'), 2_000); + deadline = setTimeout(() => { + reject(new Error(`Console process ${child.pid} did not exit after SIGKILL`)); + }, 5_000); + }); +} + +beforeAll(async () => { + const artifactRoot = temporary('brain-issue86-packed-'); + const packDir = path.join(artifactRoot, 'pack'); + const installRoot = path.join(artifactRoot, 'install'); + fs.mkdirSync(packDir, { recursive: true }); + const packed = JSON.parse(execFileSync('npm', [ + 'pack', '--json', '--pack-destination', packDir, + ], { cwd: ROOT, encoding: 'utf8' })); + const tarball = path.join(packDir, packed[0].filename); + execFileSync('npm', [ + 'install', '--ignore-scripts', '--no-audit', '--no-fund', '--prefix', installRoot, tarball, + ], { cwd: artifactRoot, encoding: 'utf8' }); + + const installedPackage = path.join(installRoot, 'node_modules', 'ruvnet-brain'); + process.env.RUVNET_BRAIN_IMPORT_ONLY = '1'; + const installedEntrypoint = path.join(installedPackage, 'bin', 'install.mjs'); + const installer = await import(`${pathToFileURL(installedEntrypoint).href}?issue86=${Date.now()}`); + delete process.env.RUVNET_BRAIN_IMPORT_ONLY; + runtimeKb = path.join(installRoot, 'kb'); + runtimeScript = installer.installConsoleRuntime(runtimeKb, installedPackage); +}, 120_000); + +afterAll(async () => { + await Promise.all([...children].map(stopChild)); + for (const value of temps.splice(0)) fs.rmSync(value, { recursive: true, force: true }); + delete process.env.RUVNET_BRAIN_IMPORT_ONLY; +}); + +describe('issue #86 packed and staged provider availability', () => { + it('serves OpenAI and both Google aliases as booleans without exposing credentials', async () => { + const cases = [ + { + name: 'OpenAI', credentials: { OPENAI_API_KEY: secrets.OPENAI_API_KEY }, + want: { openai: true, google: false }, + }, + { + name: 'Google', credentials: { GOOGLE_API_KEY: secrets.GOOGLE_API_KEY }, + want: { openai: false, google: true }, + }, + { + name: 'Gemini alias', credentials: { GEMINI_API_KEY: secrets.GEMINI_API_KEY }, + want: { openai: false, google: true }, + }, + { name: 'unset', credentials: {}, want: { openai: false, google: false } }, + ]; + + for (const scenario of cases) { + const home = temporary(`brain-issue86-${scenario.name.toLowerCase().replaceAll(' ', '-')}-`); + const project = path.join(home, 'project'); + fs.mkdirSync(project, { recursive: true }); + const port = await freePort(); + const env = runtimeEnvironment(home, port, scenario.credentials); + const warm = spawnSync(process.execPath, [runtimeScript, '--print-state'], { + cwd: project, + env, + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 20 * 1024 * 1024, + }); + expect(warm.status, `${scenario.name} cache warm failed: ${warm.stderr}`).toBe(0); + + const child = spawn(process.execPath, [runtimeScript, '--serve'], { + cwd: project, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + children.add(child); + child.once('exit', () => children.delete(child)); + let state; + try { + state = await waitForState(port); + } finally { + await stopChild(child); + } + + const router = state.sections.savings.routerEngine; + expect(router.providerCatalog, scenario.name).toMatchObject({ status: 'ok' }); + expect(router.keys, scenario.name).toMatchObject(scenario.want); + expect(router.subscriptions.openai.apiKey, scenario.name).toBe(scenario.want.openai); + expect(router.subscriptions.google.apiKey, scenario.name).toBe(scenario.want.google); + const serialized = JSON.stringify(state); + for (const value of Object.values(secrets)) expect(serialized).not.toContain(value); + } + }, 120_000); +}); diff --git a/tests/acceptance/issue-87-installed-router-inventory.acceptance.test.mjs b/tests/acceptance/issue-87-installed-router-inventory.acceptance.test.mjs new file mode 100644 index 00000000..81b5b7ba --- /dev/null +++ b/tests/acceptance/issue-87-installed-router-inventory.acceptance.test.mjs @@ -0,0 +1,91 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { chromium } from 'playwright'; +import fs from 'node:fs'; +import { chromeExecutable, installPackedConsole } from './helpers/packed-console-fixture.mjs'; + +const fixtures = []; +const browsers = []; +const chrome = chromeExecutable(chromium); +const profile = { harnesses: { 'claude-code': { available: true, subscription: true } } }; +const decision = { + ts: '2026-08-02T18:30:00.000Z', + model: 'claude-fable-5', + tier: 'frontier', + routedBy: '@metaharness/router', + reason: 'policy-backed fixture receipt', +}; +const opus = { + id: 'claude-opus-4-8', provider: 'anthropic', harness: ['claude-code'], + subscription: ['claude-code'], tier: 'frontier', costPerMTok: null, verified: 'reviewed old row', +}; +const fable = { + id: 'claude-fable-5', provider: 'anthropic', harness: ['claude-code'], + subscription: ['claude-code'], tier: 'frontier', costPerMTok: null, verified: 'reviewed old row', +}; + +afterEach(async () => { + await Promise.all(browsers.splice(0).map((browser) => browser.close())); + await Promise.all(fixtures.splice(0).map((fixture) => fixture.cleanup())); +}); + +async function installedInventory(candidates) { + const fixture = await installPackedConsole({ + prefix: 'brain-issue87-installed-', + catalog: { updated: '2026-07-12 old user catalog', candidates }, + profile, + decisions: [decision], + }); + fixtures.push(fixture); + fixture.measureState(); + const server = await fixture.start(); + const stateResponse = await fetch(`${server.url}api/state`); + expect(stateResponse.status).toBe(200); + const state = await stateResponse.json(); + + const browser = await chromium.launch({ executablePath: chrome, headless: true }); + browsers.push(browser); + const page = await browser.newPage(); + await page.goto(server.url, { waitUntil: 'domcontentloaded' }); + const development = page.locator('.rp-profile').filter({ hasText: 'Development' }); + await expect.poll(() => development.locator('tbody tr').count()).toBe(5); + const rows = await development.locator('tbody tr').evaluateAll((items) => items.map((row) => ({ + model: row.cells[1].textContent.trim(), + routing: row.cells[3].textContent.trim(), + }))); + return { fixture, state, rows }; +} + +describe('issue #87 packed installed Console inventory', () => { + const test = chrome ? it : it.skip; + + test('merges additions and renders an order-invariant full inventory with a receipt marker', async () => { + const first = await installedInventory([fable, opus]); + const second = await installedInventory([opus, fable]); + const expectedIds = [ + 'claude-fable-5', + 'claude-haiku-4-5-20251001', + 'claude-opus-4-8', + 'claude-opus-5', + 'claude-sonnet-5', + ]; + + for (const result of [first, second]) { + const router = result.state.sections.savings.routerEngine; + expect(router.catalogSource).toBe('catalog'); + expect(router.pool.filter((row) => row.provider === 'anthropic').map((row) => row.id).sort()).toEqual(expectedIds); + expect(router.pool.find((row) => row.id === 'claude-opus-5').verified).toMatch(/2026-08-02.*launch/i); + expect(router.pool.find((row) => row.id === 'claude-fable-5').verified).toBeTruthy(); + expect(router.decisions[0]).toMatchObject({ model: 'claude-fable-5', reason: 'policy-backed fixture receipt' }); + + const installed = JSON.parse(fs.readFileSync(result.fixture.installedCatalog, 'utf8')); + expect(installed.managedVersion).toBe(2); + expect(installed.updated).toBe('2026-07-12 old user catalog'); + expect(result.rows.filter((row) => row.routing === 'last selected')).toEqual([ + expect.objectContaining({ model: expect.stringMatching(/Fable 5/i) }), + ]); + } + + expect(first.rows.map((row) => row.model)).toEqual(second.rows.map((row) => row.model)); + expect(first.rows.map((row) => row.model).join(' ')).toMatch(/Haiku.*Sonnet.*Fable.*Opus 4\.8.*Opus 5/i); + }, 120_000); +}); diff --git a/tests/helpers/release-transaction-fixture.mjs b/tests/helpers/release-transaction-fixture.mjs new file mode 100644 index 00000000..468e338a --- /dev/null +++ b/tests/helpers/release-transaction-fixture.mjs @@ -0,0 +1,110 @@ +import crypto from 'node:crypto'; +import { + runReleaseTransaction, + transactionIdFor, +} from '../../scripts/release-transaction.mjs'; + +export const keys = crypto.generateKeyPairSync('ed25519'); +export const identity = { + repository: 'stuinfla/ruvnet-brain', + package: 'ruvnet-brain', + version: '9.9.9', + tag: 'v9.9.9', + candidateSha: 'a'.repeat(40), + packageIntegrity: `sha512-${Buffer.alloc(64, 7).toString('base64')}`, + bundleSha256: 'b'.repeat(64), +}; +export const assets = { packagePath: '/sealed/package.tgz', bundlePath: '/sealed/brain.zip' }; + +export class FakeReleaseProvider { + constructor({ fault = null, prior = '9.9.8' } = {}) { + this.fault = fault; + this.receipts = []; + this.calls = []; + this.draft = null; + this.npmLatest = prior; + this.githubLatest = `v${prior}`; + this.prior = prior; + } + + hit(name) { + this.calls.push(name); + if (this.fault === name) throw new Error(`injected ${name}`); + } + + async discover(candidate) { + this.hit('discover'); + return { + pending: this.pending || [], + matchingDrafts: this.draft ? [this.draft] : [], + receipts: [...this.receipts], + prior: { npmLatest: this.prior, githubLatest: `v${this.prior}` }, + }; + } + + async createDraft(candidate, fence) { + this.hit('createDraft'); + this.draft = { id: 77, tag: candidate.tag, sha: candidate.candidateSha, fence }; + return this.draft; + } + + async appendReceipt(_draft, receipt) { + this.hit(`append:${receipt.state}`); + if (this.receipts.some(({ sequence }) => sequence === receipt.sequence)) throw new Error('duplicate receipt sequence'); + this.receipts.push(structuredClone(receipt)); + } + + async readReceipt(_draft, sequence) { + this.hit('readReceipt'); + return structuredClone(this.receipts.find((receipt) => receipt.sequence === sequence)); + } + + async uploadAssets() { this.hit('uploadAssets'); } + async materializeStagedAssets() { + this.hit('materializeStagedAssets'); + return { assets, cleanup: () => this.hit('cleanupStagedAssets') }; + } + async stageNpm() { this.hit('stageNpm'); } + async observeNpmCandidate() { + this.hit('observeNpmCandidate'); + return { version: identity.version, integrity: identity.packageIntegrity, tag: `candidate-v${identity.version}` }; + } + async publishDraftNonLatest() { this.hit('publishDraftNonLatest'); } + async observeGithub() { + this.hit('observeGithub'); + return { sha: identity.candidateSha, latest: false, tag: identity.tag }; + } + async promoteNpm() { this.hit('promoteNpm'); this.npmLatest = identity.version; } + async observeNpmLatest() { this.hit('observeNpmLatest'); return { version: this.npmLatest }; } + async makeGithubLatest() { this.hit('makeGithubLatest'); this.githubLatest = identity.tag; } + async observeGithubLatest() { this.hit('observeGithubLatest'); return { tag: this.githubLatest }; } + async restoreNpmLatest(prior, expected) { + this.hit('restoreNpmLatest'); + if (this.npmLatest !== expected) throw new Error('compensation compare failed'); + this.npmLatest = prior; + } + async finalize(identityValue, _receipt, hostVerifier) { + this.hit('finalize'); + const hosts = await hostVerifier.verify({ source: 'final', identity: identityValue, assets }); + return { verdict: hosts.verdict, hosts }; + } +} + +export const passingHosts = { + calls: [], + async verify(input) { + this.calls.push(input.source); + return { verdict: 'PASS', claude: 'PASS', codex: 'PASS', dual: 'PASS' }; + }, +}; + +export const execute = (adapter, hostVerifier = { ...passingHosts, calls: [] }) => runReleaseTransaction({ + identity, + assets, + adapter, + hostVerifier, + privateKey: keys.privateKey, + publicKey: keys.publicKey, +}); + +export const transactionId = transactionIdFor(identity); diff --git a/tests/integration/codex-skill-discovery.test.mjs b/tests/integration/codex-skill-discovery.test.mjs index a54a644e..cfa58a12 100644 --- a/tests/integration/codex-skill-discovery.test.mjs +++ b/tests/integration/codex-skill-discovery.test.mjs @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -7,6 +7,12 @@ import { spawnSync } from 'node:child_process'; const ROOT = path.resolve(import.meta.dirname, '../..'); const PLUGIN = path.join(ROOT, 'plugin'); const CODEX = process.env.RUVNET_CODEX_BIN || 'codex'; +let wireCodexPlugin; + +beforeAll(async () => { + process.env.RUVNET_BRAIN_IMPORT_ONLY = '1'; + ({ wireCodexPlugin } = await import('../../bin/install.mjs')); +}); function run(home, args) { return spawnSync(CODEX, args, { @@ -45,4 +51,109 @@ describe('installed Codex skill discovery', () => { fs.rmSync(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); } }); + + test.each(['missing', 'malformed'])( + 'repairs a %s Brain-owned marketplace snapshot before Codex reads plugin state', + (snapshotState) => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-codex-stale-market-')); + const brainHome = path.join(home, 'brain-home'); + const marketplace = path.join(brainHome, 'codex-marketplace'); + const priorBrainHome = process.env.RUVNET_BRAIN_HOME; + try { + fs.mkdirSync(path.join(marketplace, '.claude-plugin'), { recursive: true }); + fs.copyFileSync( + path.join(ROOT, '.claude-plugin', 'marketplace.json'), + path.join(marketplace, '.claude-plugin', 'marketplace.json'), + ); + fs.cpSync(PLUGIN, path.join(marketplace, 'plugin'), { recursive: true }); + + const market = run(home, ['plugin', 'marketplace', 'add', marketplace, '--json']); + expect(market.status, market.stderr || market.stdout).toBe(0); + const install = run(home, ['plugin', 'add', 'ruvnet-brain@ruvnet-brain', '--json']); + expect(install.status, install.stderr || install.stdout).toBe(0); + + if (snapshotState === 'missing') { + fs.rmSync(marketplace, { recursive: true, force: true }); + } else { + fs.writeFileSync(path.join(marketplace, '.claude-plugin', 'marketplace.json'), '{'); + } + process.env.RUVNET_BRAIN_HOME = brainHome; + const repaired = wireCodexPlugin({ + codexDir: home, + codexHome: home, + expectedVersion: JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version, + announce: false, + }); + + expect(repaired.action).not.toBe('codex-unavailable'); + expect(repaired).toMatchObject({ installed: true, enabled: true }); + expect(fs.existsSync(path.join(marketplace, '.claude-plugin', 'marketplace.json'))).toBe(true); + const listed = run(home, ['plugin', 'list', '--json']); + expect(listed.status, listed.stderr || listed.stdout).toBe(0); + } finally { + if (priorBrainHome === undefined) delete process.env.RUVNET_BRAIN_HOME; + else process.env.RUVNET_BRAIN_HOME = priorBrainHome; + fs.rmSync(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } + }, + ); + + test('repairs the snapshot without re-enabling an explicitly disabled Codex plugin', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-codex-disabled-market-')); + const brainHome = path.join(home, 'brain-home'); + const marketplace = path.join(brainHome, 'codex-marketplace'); + const priorBrainHome = process.env.RUVNET_BRAIN_HOME; + try { + fs.mkdirSync(path.join(marketplace, '.claude-plugin'), { recursive: true }); + fs.copyFileSync( + path.join(ROOT, '.claude-plugin', 'marketplace.json'), + path.join(marketplace, '.claude-plugin', 'marketplace.json'), + ); + fs.cpSync(PLUGIN, path.join(marketplace, 'plugin'), { recursive: true }); + expect(run(home, ['plugin', 'marketplace', 'add', marketplace, '--json']).status).toBe(0); + expect(run(home, ['plugin', 'add', 'ruvnet-brain@ruvnet-brain', '--json']).status).toBe(0); + + const configPath = path.join(home, 'config.toml'); + const config = fs.readFileSync(configPath, 'utf8').replace( + /(\[plugins\."ruvnet-brain@ruvnet-brain"\]\nenabled = )true/, + '$1false', + ); + fs.writeFileSync(configPath, config); + fs.rmSync(marketplace, { recursive: true, force: true }); + process.env.RUVNET_BRAIN_HOME = brainHome; + + const repaired = wireCodexPlugin({ codexDir: home, codexHome: home, announce: false }); + expect(repaired).toMatchObject({ action: 'disabled', installed: true, enabled: false }); + expect(fs.readFileSync(configPath, 'utf8')).toContain( + '[plugins."ruvnet-brain@ruvnet-brain"]\nenabled = false', + ); + expect(fs.existsSync(path.join(marketplace, '.claude-plugin', 'marketplace.json'))).toBe(true); + } finally { + if (priorBrainHome === undefined) delete process.env.RUVNET_BRAIN_HOME; + else process.env.RUVNET_BRAIN_HOME = priorBrainHome; + fs.rmSync(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } + }); + + test('reports a marketplace preparation failure without changing Codex configuration', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'ruvnet-codex-market-failure-')); + const brainHome = path.join(home, 'not-a-directory'); + const configPath = path.join(home, 'config.toml'); + const priorBrainHome = process.env.RUVNET_BRAIN_HOME; + try { + fs.writeFileSync(brainHome, 'occupied'); + fs.writeFileSync(configPath, '[features]\njs_repl = false\n'); + const before = fs.readFileSync(configPath, 'utf8'); + process.env.RUVNET_BRAIN_HOME = brainHome; + + expect(wireCodexPlugin({ codexDir: home, codexHome: home, announce: false })).toMatchObject({ + action: 'marketplace-prepare-failed', + }); + expect(fs.readFileSync(configPath, 'utf8')).toBe(before); + } finally { + if (priorBrainHome === undefined) delete process.env.RUVNET_BRAIN_HOME; + else process.env.RUVNET_BRAIN_HOME = priorBrainHome; + fs.rmSync(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + } + }); }); diff --git a/tests/integration/stale-install-trap.test.mjs b/tests/integration/stale-install-trap.test.mjs index b6ece472..4144c458 100644 --- a/tests/integration/stale-install-trap.test.mjs +++ b/tests/integration/stale-install-trap.test.mjs @@ -62,12 +62,17 @@ function runInstaller({ breakLookup = false, latestTag } = {}) { script = path.join(work, 'bin', 'install.mjs'); fs.mkdirSync(path.dirname(script), { recursive: true }); fs.mkdirSync(path.join(work, 'kb'), { recursive: true }); + fs.mkdirSync(path.join(work, 'scripts'), { recursive: true }); const src = fs.readFileSync(INSTALLER, 'utf8') .replace("const REPO = 'stuinfla/ruvnet-brain';", "const REPO = 'stuinfla/definitely-not-a-real-repo-xyz';"); fs.writeFileSync(script, src); for (const sibling of ['brain-profile.mjs', 'model-requirements.mjs']) { fs.copyFileSync(path.join(ROOT, 'kb', sibling), path.join(work, 'kb', sibling)); } + fs.copyFileSync( + path.join(ROOT, 'scripts', 'model-router-catalog.mjs'), + path.join(work, 'scripts', 'model-router-catalog.mjs'), + ); } const res = spawnSync(process.execPath, [script, '--no-verify'], { encoding: 'utf8', diff --git a/tests/mesh/coexistence.test.mjs b/tests/mesh/coexistence.test.mjs index 2b3c2859..c79d26d7 100644 --- a/tests/mesh/coexistence.test.mjs +++ b/tests/mesh/coexistence.test.mjs @@ -469,13 +469,15 @@ describe('§2b byte-equivalence — ~/.claude/settings.json', () => { mutantDirs.push(dir); const binDir = path.join(dir, 'bin'); const kbDir = path.join(dir, 'kb'); + const scriptsDir = path.join(dir, 'scripts'); fs.mkdirSync(binDir); fs.mkdirSync(kbDir); + fs.cpSync(path.join(REPO_ROOT, 'scripts'), scriptsDir, { recursive: true }); const file = path.join(binDir, 'install-mutant.mjs'); fs.writeFileSync(file, source.replace(find, replace)); // .replace (no /g) hits ONLY the FIRST // occurrence — verified below to be the one inside writeSettingsStatusLine, not the removal path. - // Preserve the installer's real module shape. Copying only install.mjs made the mutant crash on - // its legitimate ../kb sibling imports before the mutation could be exercised. + // Preserve the installer's real module shape. The mutant changes only install.mjs; its runtime + // dependencies must remain byte-identical so the test exercises the mutation, not packaging. for (const sibling of ['brain-profile.mjs', 'model-requirements.mjs']) { fs.copyFileSync(path.join(REPO_ROOT, 'kb', sibling), path.join(kbDir, sibling)); } diff --git a/tests/mutation/install-selfcheck-consumption-mutation.test.mjs b/tests/mutation/install-selfcheck-consumption-mutation.test.mjs index 3aa14e37..0a30b957 100644 --- a/tests/mutation/install-selfcheck-consumption-mutation.test.mjs +++ b/tests/mutation/install-selfcheck-consumption-mutation.test.mjs @@ -81,7 +81,7 @@ function buildFixtureDir({ includeRvf }) { function buildScratchRoot({ mutateTo, includeRvf }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'mutant-installer-')); scratchDirs.push(root); - for (const d of ['bin', 'scripts', 'kb', 'dist', 'console', 'plugin']) fs.mkdirSync(path.join(root, d), { recursive: true }); + for (const d of ['bin', 'kb', 'dist', 'plugin', 'data']) fs.mkdirSync(path.join(root, d), { recursive: true }); const source = fs.readFileSync(REAL_INSTALLER, 'utf8'); expect(source.includes(ANCHOR), 'mutation anchor not found in bin/install.mjs — the target moved').toBe(true); @@ -89,11 +89,9 @@ function buildScratchRoot({ mutateTo, includeRvf }) { if (mutateTo !== undefined) expect(written, 'mutation changed nothing — it would silently run the unmutated file').not.toBe(source); fs.writeFileSync(path.join(root, 'bin', 'install.mjs'), written); - // The sibling modules bin/install.mjs's OWN dynamic imports need — scripts/selfcheck.mjs above all, - // since it owns the exact `.exitCode` verdict the mutated line does or doesn't consume. - for (const rel of ['scripts/selfcheck.mjs', 'scripts/hook-registry.mjs', 'scripts/install-scope.mjs', 'scripts/upgrade-notice.mjs', 'scripts/user-settings.mjs', 'scripts/onboarding-console.mjs']) { - fs.copyFileSync(path.join(REPO, rel), path.join(root, rel)); - } + // Use the real runtime directory instead of maintaining a second, partial import list. + // Only bin/install.mjs is mutated; every dependency remains byte-identical to the candidate. + fs.cpSync(path.join(REPO, 'scripts'), path.join(root, 'scripts'), { recursive: true }); for (const rel of ['kb/verify-citation.mjs', 'kb/brain-profile.mjs', 'kb/model-requirements.mjs']) { fs.copyFileSync(path.join(REPO, rel), path.join(root, rel)); } @@ -102,6 +100,7 @@ function buildScratchRoot({ mutateTo, includeRvf }) { // it is designed to test instead of failing earlier on an incomplete fake package. fs.cpSync(path.join(REPO, 'console'), path.join(root, 'console'), { recursive: true }); fs.cpSync(path.join(REPO, 'plugin', 'scripts'), path.join(root, 'plugin', 'scripts'), { recursive: true }); + fs.copyFileSync(path.join(REPO, 'data', 'model-catalog.json'), path.join(root, 'data', 'model-catalog.json')); fs.copyFileSync(path.join(REPO, 'package.json'), path.join(root, 'package.json')); fs.cpSync(buildFixtureDir({ includeRvf }), path.join(root, 'dist', 'ruvnet-brain'), { diff --git a/tests/qe/release/packed-clean-install.test.mjs b/tests/qe/release/packed-clean-install.test.mjs index 135e63ee..842eb692 100644 --- a/tests/qe/release/packed-clean-install.test.mjs +++ b/tests/qe/release/packed-clean-install.test.mjs @@ -12,13 +12,27 @@ let artifact; let install; beforeAll(async () => { - const raw = execFileSync('npm', ['pack', '--json', '--pack-destination', temp], { - cwd: ROOT, - encoding: 'utf8', - shell: process.platform === 'win32', - }); - packed = JSON.parse(raw.slice(raw.indexOf('[')))[0]; - execFileSync('tar', ['-xzf', path.join(temp, packed.filename), '-C', temp]); + const sealed = process.env.RUVNET_SEALED_PACKAGE; + if (sealed) { + const packageManifest = JSON.parse(execFileSync('tar', ['-xOf', sealed, 'package/package.json'], { encoding: 'utf8' })); + packed = { + filename: path.basename(sealed), + version: packageManifest.version, + files: execFileSync('tar', ['-tzf', sealed], { encoding: 'utf8' }) + .trim().split('\n').map((entry) => ({ path: entry.replace(/^package\//, '').replace(/\/$/, '') })), + }; + execFileSync('tar', ['-xzf', sealed, '-C', temp]); + } else { + // Local focused runs remain self-contained. CI always supplies RUVNET_SEALED_PACKAGE, + // making the release-QE fleet consume the single artifact later published byte-for-byte. + const raw = execFileSync('npm', ['pack', '--json', '--pack-destination', temp], { + cwd: ROOT, + encoding: 'utf8', + shell: process.platform === 'win32', + }); + packed = JSON.parse(raw.slice(raw.indexOf('[')))[0]; + execFileSync('tar', ['-xzf', path.join(temp, packed.filename), '-C', temp]); + } artifact = path.join(temp, 'package'); process.env.RUVNET_BRAIN_IMPORT_ONLY = '1'; install = await import(pathToFileURL(path.join(artifact, 'bin/install.mjs')).href); diff --git a/tests/qe/release/release-publish-contract.test.mjs b/tests/qe/release/release-publish-contract.test.mjs index f78dbbe6..ec2c05b5 100644 --- a/tests/qe/release/release-publish-contract.test.mjs +++ b/tests/qe/release/release-publish-contract.test.mjs @@ -3,92 +3,89 @@ import fs from 'node:fs'; import path from 'node:path'; const ROOT = path.resolve(import.meta.dirname, '../../..'); -const source = fs.readFileSync(path.join(ROOT, 'scripts/release.mjs'), 'utf8'); -const bundleSource = fs.readFileSync(path.join(ROOT, 'scripts/build-bundle.mjs'), 'utf8'); +const release = fs.readFileSync(path.join(ROOT, 'scripts/release.mjs'), 'utf8'); +const transaction = fs.readFileSync(path.join(ROOT, 'scripts/release-transaction.mjs'), 'utf8'); +const provider = fs.readFileSync(path.join(ROOT, 'scripts/release-transaction-provider.mjs'), 'utf8'); +const bundle = fs.readFileSync(path.join(ROOT, 'scripts/build-bundle.mjs'), 'utf8'); -const position = (needle) => { +const position = (source, needle) => { const found = source.indexOf(needle); expect(found, `missing release operation: ${needle}`).toBeGreaterThanOrEqual(0); return found; }; -describe('release publication is bound to one candidate', () => { - it('targets the exact HEAD and verifies the resulting remote tag before npm changes', () => { - expect(source).toContain("const head = execFileSync('git', ['-C', ROOT, 'rev-parse', 'HEAD']"); - expect(source).toContain("'--target', head"); - expect(source).toContain('release tag already identifies different bytes'); - expect(source).toContain('published GitHub Release tag is not candidate HEAD'); - expect(position('publishedTagSha !== head')).toBeLessThan(position("runOrDie('npm publish'")); +describe('release publication is one remote durable staged transaction', () => { + it('binds signed append-only receipts to exact candidate and artifact identity', () => { + expect(transaction).toContain('transactionIdFor'); + expect(transaction).toContain('candidateSha: identity.candidateSha'); + expect(transaction).toContain('packageIntegrity: identity.packageIntegrity'); + expect(transaction).toContain('bundleSha256: identity.bundleSha256'); + expect(transaction).toContain('crypto.sign'); + expect(transaction).toContain('previousReceiptDigest'); + expect(provider).toContain("command('gh', ['release', 'upload', anchor.tag, file, '--repo', REPO])"); + expect(provider).toContain('refusing to replace staged asset with different bytes'); + expect(provider).not.toContain("'--clobber'"); + expect(provider).toContain("'pack', `${PACKAGE}@candidate-v${identity.version}`"); + expect(provider).toContain('staged npm package integrity mismatch'); }); - it('supports annotated and lightweight tags without accepting a collision', () => { - expect(source).toContain('`refs/tags/${tag}^{}`'); - expect(source).toContain("ref?.endsWith('^{}')"); - expect(source).toMatch(/remoteTagSha && remoteTagSha !== head/); + it('creates a remote draft before the first externally visible candidate mutation', () => { + expect(position(transaction, "append('remote-prepared'")) + .toBeLessThan(position(transaction, "intend('npm-stage-intent'")); + expect(position(transaction, "intend('npm-stage-intent'")) + .toBeLessThan(position(transaction, 'adapter.stageNpm')); + expect(provider).toContain("'-F', 'draft=true'"); }); - it('fails closed in build-sign-release-npm-verify order', () => { + it('stages npm and GitHub non-latest before changing either default', () => { const operations = [ - "runOrDie('build release bundle'", - "runOrDie('sign release bundle'", - "runOrDie('create signed GitHub Release'", - "runOrDie('npm publish'", - "runOrDie('npm dist-tag latest'", - "runOrDie('verify-channels'", - ].map(position); + 'adapter.stageNpm', + 'adapter.publishDraftNonLatest', + 'adapter.promoteNpm', + 'adapter.makeGithubLatest', + "append('channels-converged'", + ].map((needle) => position(transaction, needle)); expect(operations).toEqual([...operations].sort((a, b) => a - b)); + expect(provider).toContain("'make_latest=false'"); + expect(provider).toContain("'make_latest=true'"); }); - it('requires the bundle, signature, digest, and sealed package as one release asset set', () => { - expect(source).toContain('const assets = [zip, `${zip}.sig`, `${zip}.sha256`, sealedPackageArtifact]'); - expect(source).toContain('signed release asset missing'); - expect(source).toContain("'release', 'create', tag, ...assets"); - expect(source).toContain("runOrDie('npm publish', 'npm', ['publish', sealedPackageArtifact, '--tag', 'latest'])"); + it('requires bundle, signature, digest, and sealed package as one staged asset set', () => { + expect(release).toContain('const assets = {'); + expect(release).toContain('bundleSignaturePath: `${zip}.sig`'); + expect(release).toContain('bundleDigestPath: `${zip}.sha256`'); + expect(release).toContain('packagePath: sealedPackageArtifact'); + expect(release).toContain('signed release asset missing'); + expect(provider).toContain('assets.packagePath'); }); - it('rebuilds and audits the exact extracted archive before the signer can run', () => { - expect(bundleSource).toContain('fs.rmSync(ZIP, { force: true })'); - expect(bundleSource).toContain("await import('../kb/zip-extract.mjs')"); - expect(bundleSource).toContain('const packagedAudit = await auditRvfIndexes(packagedRvfs)'); - expect(bundleSource).toContain('exact archive proof:'); - expect(position("runOrDie('build release bundle'")) - .toBeLessThan(position("runOrDie('sign release bundle'")); + it('rebuilds and audits the exact extracted archive before signing', () => { + expect(bundle).toContain('fs.rmSync(ZIP, { force: true })'); + expect(bundle).toContain("await import('../kb/zip-extract.mjs')"); + expect(bundle).toContain('const packagedAudit = await auditRvfIndexes(packagedRvfs)'); + expect(position(release, "runOrDie('build release bundle'")) + .toBeLessThan(position(release, "runOrDie('sign release bundle'")); }); - it('is retryable without moving an existing tag or duplicating assets', () => { - expect(source).toContain("'release', 'view', tag"); - expect(source).toContain("'release', 'upload', tag, ...assets, '--clobber'"); - expect(source).toContain('already on npm — skipping publish'); - expect(source).toContain("runOrDie('npm dist-tag latest'"); + it('fails closed on competing transactions and duplicate drafts', () => { + expect(transaction).toContain('pending release ${competing[0].transactionId} blocks'); + expect(transaction).toContain('duplicate matching drafts require reconciliation'); + expect(transaction).toContain('release receipt sequence gap or replay'); + expect(transaction).toContain('release receipt chain conflict'); }); - it('records a recoverable cross-channel transaction before the first remote mutation', () => { - const transaction = position('release-transaction'); - expect(transaction).toBeLessThan(position("runOrDie('create signed GitHub Release'")); - expect(source).toContain('github-published-npm-pending'); - expect(source).toContain('channels-converged'); + it('uses guarded compensation and preserves an explicit human-only abort terminal', () => { + expect(transaction).toContain("if (observed.version !== identity.version) throw new Error('npm latest changed during compensation')"); + expect(provider).toContain('refusing compensation: npm latest is'); + expect(transaction).toContain("if (!authorized) throw new Error('release abort requires explicit human authorization')"); }); - it('binds the transaction to signed artifact bytes across retries', () => { - expect(source).toContain('bundleSha256'); - expect(source).toContain('priorTxn.bundleSha256 === bundleSha256'); - expect(source).toContain('unfinished release transaction requires reconciliation'); - }); - - it('does not overwrite an unfinished transaction for another candidate', () => { - expect(source).toContain('unfinished release transaction'); - expect(position('unfinished release transaction')) - .toBeLessThan(position("recordReleaseTransaction('prepared'")); - }); - - it('resumes a pending candidate without rebuilding nondeterministic bundle bytes', () => { - expect(position('const priorTxn = readReleaseTransaction()')) - .toBeLessThan(position("runOrDie('build release bundle'")); - expect(source).toContain('resume existing signed release assets'); - }); - - it('claims channel convergence only after the live channel verifier succeeds', () => { - expect(position("recordReleaseTransaction('channels-converged'")) - .toBeGreaterThan(position("runOrDie('verify-channels'")); + it('creates final public evidence before appending channels-converged', () => { + expect(position(provider, "'scripts/publication-receipt.mjs'")) + .toBeLessThan(position(provider, "'scripts/release-proof.mjs'")); + expect(position(transaction, 'adapter.finalize')) + .toBeLessThan(position(transaction, "append('channels-converged'")); + expect(provider).toContain("'scripts/verify-channels.mjs'"); + expect(provider).toContain("'scripts/published-surface-probe.mjs', '--json'"); }); }); diff --git a/tests/qe/release/release-transaction-faults.test.mjs b/tests/qe/release/release-transaction-faults.test.mjs new file mode 100644 index 00000000..3b02dacc --- /dev/null +++ b/tests/qe/release/release-transaction-faults.test.mjs @@ -0,0 +1,118 @@ +import { describe, expect, it } from 'vitest'; +import { + execute, FakeReleaseProvider, identity, +} from '../../helpers/release-transaction-fixture.mjs'; + +const PREPARE_FAULTS = [ + 'createDraft', 'append:remote-prepared', 'readReceipt', 'append:asset-upload-intent', + 'uploadAssets', 'append:host-verification-intent', 'append:local-hosts-verified', + 'append:npm-stage-intent', 'stageNpm', 'observeNpmCandidate', 'append:npm-candidate-staged', + 'append:remote-host-verification-intent', 'materializeStagedAssets', 'cleanupStagedAssets', + 'append:prepared', 'append:github-promote-intent', 'publishDraftNonLatest', 'observeGithub', + 'append:github-promoted-nonlatest', 'append:npm-promote-intent', 'promoteNpm', +]; + +const RESUMABLE_PROMOTION_FAULTS = [ + 'observeNpmLatest', 'append:npm-promoted', 'append:github-latest-intent', + 'observeGithubLatest', 'append:defaults-promoted', 'append:finalize-intent', + 'finalize', 'append:channels-converged', +]; + +describe('issue #77 failure-first release QE', () => { + it.each(PREPARE_FAULTS)('fault at %s never advances a default channel', async (fault) => { + const provider = new FakeReleaseProvider({ fault }); + await expect(execute(provider)).rejects.toThrow(); + expect(provider.npmLatest).toBe('9.9.8'); + expect(provider.githubLatest).toBe('v9.9.8'); + expect(provider.receipts.some(({ state }) => state === 'channels-converged')).toBe(false); + }); + + it.each(RESUMABLE_PROMOTION_FAULTS)('fault at %s converges the same B on retry', async (fault) => { + const provider = new FakeReleaseProvider({ fault }); + await expect(execute(provider)).rejects.toThrow(); + provider.fault = null; + const final = await execute(provider); + expect(final.state).toBe('channels-converged'); + expect(provider.npmLatest).toBe(identity.version); + expect(provider.githubLatest).toBe(identity.tag); + }); + + it.each(['local', 'staged', 'final'])('host fixture failure at %s never records convergence', async (source) => { + const provider = new FakeReleaseProvider(); + const hosts = { + async verify(input) { + return input.source === source ? { verdict: 'FAIL' } : { verdict: 'PASS' }; + }, + }; + await expect(execute(provider, hosts)).rejects.toThrow(); + expect(provider.receipts.some(({ state }) => state === 'channels-converged')).toBe(false); + }); + + it('recovers the same B on a clean runner using remote receipts only', async () => { + const provider = new FakeReleaseProvider({ fault: 'promoteNpm' }); + await expect(execute(provider)).rejects.toThrow('npm promotion pending'); + const stagedCalls = provider.calls.filter((call) => call === 'stageNpm').length; + provider.fault = null; + provider.calls = []; + const final = await execute(provider); + expect(final.state).toBe('channels-converged'); + expect(provider.calls.filter((call) => call === 'stageNpm')).toHaveLength(0); + expect(stagedCalls).toBe(1); + }); + + it('compensates npm only when GitHub latest promotion fails after npm B', async () => { + const provider = new FakeReleaseProvider({ fault: 'makeGithubLatest' }); + await expect(execute(provider)).rejects.toThrow('npm compensated'); + expect(provider.npmLatest).toBe('9.9.8'); + expect(provider.githubLatest).toBe('v9.9.8'); + expect(provider.receipts.at(-1).state).toBe('compensated'); + }); + + it('re-promotes B before retrying GitHub after a compensated clean-runner recovery', async () => { + const provider = new FakeReleaseProvider({ fault: 'makeGithubLatest' }); + await expect(execute(provider)).rejects.toThrow('npm compensated'); + provider.fault = null; + provider.calls = []; + const final = await execute(provider); + expect(final.state).toBe('channels-converged'); + expect(provider.calls.indexOf('promoteNpm')).toBeLessThan(provider.calls.indexOf('makeGithubLatest')); + expect(provider.npmLatest).toBe(identity.version); + expect(provider.githubLatest).toBe(identity.tag); + }); + + it('does not overwrite a third-party npm latest during compensation', async () => { + const provider = new FakeReleaseProvider({ fault: 'makeGithubLatest' }); + const original = provider.makeGithubLatest.bind(provider); + provider.makeGithubLatest = async (...args) => { + provider.npmLatest = '10.0.0'; + return original(...args); + }; + await expect(execute(provider)).rejects.toThrow('changed during compensation'); + expect(provider.npmLatest).toBe('10.0.0'); + expect(provider.calls).not.toContain('restoreNpmLatest'); + }); + + it('fails closed on duplicate orphan drafts instead of guessing ownership', async () => { + const provider = new FakeReleaseProvider(); + provider.discover = async () => ({ + pending: [], receipts: [], prior: { npmLatest: '9.9.8' }, + matchingDrafts: [{ id: 1, tag: identity.tag }, { id: 2, tag: identity.tag }], + }); + await expect(execute(provider)).rejects.toThrow('duplicate matching drafts'); + }); + + it('a concurrent same-sequence writer loses the create-only receipt race', async () => { + const provider = new FakeReleaseProvider(); + const original = provider.appendReceipt.bind(provider); + let raced = false; + provider.appendReceipt = async (draft, receipt, name) => { + if (!raced) { + raced = true; + provider.receipts.push(structuredClone(receipt)); + } + return original(draft, receipt, name); + }; + await expect(execute(provider)).rejects.toThrow('duplicate receipt sequence'); + expect(provider.calls).not.toContain('stageNpm'); + }); +}); diff --git a/tests/unit/brain-off.test.mjs b/tests/unit/brain-off.test.mjs index 70ede2b6..167bd960 100644 --- a/tests/unit/brain-off.test.mjs +++ b/tests/unit/brain-off.test.mjs @@ -72,6 +72,7 @@ const SESSION = path.join(REPO, 'plugin/scripts/session-start.sh'); const SHIM = path.join(REPO, 'plugin/scripts/hook-shim.mjs'); const PROTECT = path.join(REPO, 'plugin/scripts/protect-brain-state.sh'); const ROUTE_DISPATCH = path.join(REPO, 'plugin/scripts/route-dispatch.sh'); +const RECEIPT_DIR = ['meta', 'harness'].join(''); const VERIFY_IFACE = path.join(REPO, 'plugin/scripts/verify-interface.sh'); const DESIGN_WALL = path.join(REPO, 'plugin/scripts/design-wall.sh'); const FORGE_MCP = path.join(REPO, 'kb/forge-mcp-all.mjs'); @@ -289,12 +290,14 @@ describe.skipIf(bashOnly)('ADR-054 gate 2 — off disarms the grounding gate and expect(r.stdout).toMatch(/off/i); }); - it('OFF does NOT disarm route-dispatch — the cost wall guards money, not retrieval', () => { + it('OFF does not disarm the route-dispatch audit', () => { optIn(); offNow(); const r = fireBash(ROUTE_DISPATCH, { tool_name: 'Task', tool_input: { description: 'sweep tests', subagent_type: 'general-purpose' } }); - expect(r.status).toBe(2); - expect(r.stderr).toMatch(/SUBAGENT DISPATCH BLOCKED/); + expect(r.status).toBe(0); + expect(r.stderr).toBe(''); + const receipt = JSON.parse(fs.readFileSync(path.join(tmp, '.claude', RECEIPT_DIR, 'dispatch-log.jsonl'), 'utf8').trim()); + expect(receipt).toMatchObject({ model: 'inherited', enforcement: 'advisory-host-timing' }); }); it('the verify-interface body remains nonblocking even if invoked directly while OFF', () => { @@ -344,12 +347,12 @@ describe.skipIf(bashOnly)('ADR-054 gate 2 — off disarms the grounding gate and }); expect(fire('ground-ruvnet').stdout).toMatch(/ADVERTISING/); // ON - expect(fire('route-dispatch').status).toBe(2); + expect(fire('route-dispatch').status).toBe(0); offNow(); expect(fire('ground-ruvnet').stdout).toBe(''); // OFF → silent, zero bytes expect(fire('ground-ruvnet').status).toBe(0); - expect(fire('route-dispatch').status).toBe(2); // OFF → the wall still stands + expect(fire('route-dispatch').status).toBe(0); // OFF → audit still runs, never blocks }); }); diff --git a/tests/unit/console-runtime-transaction.test.mjs b/tests/unit/console-runtime-transaction.test.mjs index 003dc5e4..285e81a9 100644 --- a/tests/unit/console-runtime-transaction.test.mjs +++ b/tests/unit/console-runtime-transaction.test.mjs @@ -23,7 +23,7 @@ function candidate(marker) { for (const relative of ['console', 'scripts', 'plugin/scripts']) { fs.cpSync(path.join(ROOT, relative), path.join(root, relative), { recursive: true }); } - for (const relative of ['kb/brain-profile.mjs', 'bin/install.mjs', 'package.json']) { + for (const relative of ['kb/brain-profile.mjs', 'bin/install.mjs', 'package.json', 'data/model-catalog.json']) { const target = path.join(root, relative); fs.mkdirSync(path.dirname(target), { recursive: true }); fs.copyFileSync(path.join(ROOT, relative), target); @@ -71,6 +71,16 @@ afterEach(() => { }); describe('issue #79 — Console runtime update transaction', () => { + it('stages and validates the provider catalog required by the Console', () => { + const cache = temporary('brain-console-cache-'); + install.installConsoleRuntime(cache, candidate('CATALOG-PRESENT')); + expect(JSON.parse(fs.readFileSync(path.join(cache, '.console-runtime', 'data', 'model-catalog.json'), 'utf8')).providers) + .toBeTruthy(); + + const missing = candidate('CATALOG-MISSING'); + fs.rmSync(path.join(missing, 'data', 'model-catalog.json')); + expect(() => install.beginConsoleRuntimeTransaction(cache, missing)).toThrow(/model-catalog|incomplete/i); + }); it('stages without changing A, then can roll activated B back to A', () => { const cache = temporary('brain-console-cache-'); install.installConsoleRuntime(cache, candidate('GENERATION-A')); diff --git a/tests/unit/fix-workstream-guidance.test.mjs b/tests/unit/fix-workstream-guidance.test.mjs index f42acfaf..e7e23645 100644 --- a/tests/unit/fix-workstream-guidance.test.mjs +++ b/tests/unit/fix-workstream-guidance.test.mjs @@ -37,7 +37,7 @@ describe('non-trivial fix delivery rail', () => { it('keeps the accepted living plan aligned with the enforced Brain behavior', () => { expect(ADR).toMatch(/status:\s*Accepted/i); - expect(ADR).toMatch(/updated:\s*2026-08-01/i); + expect(ADR).toMatch(/updated:\s*2026-08-02/i); expect(ADR).toMatch(/plugin\/skills\/ruvnet-brain\/SKILL\.md/); expect(ADR).toMatch(/tests\/unit\/fix-workstream-guidance\.test\.mjs/); }); diff --git a/tests/unit/hook-contract.test.mjs b/tests/unit/hook-contract.test.mjs index 6af02ab8..5bc84bbc 100644 --- a/tests/unit/hook-contract.test.mjs +++ b/tests/unit/hook-contract.test.mjs @@ -368,7 +368,7 @@ describe('registry hygiene', () => { * (anticipate + lesson-hooks) are spawned in candidate mode by `unprompted-runtime.mjs`, and the * ONLY thing hooks.json points at for them is `hook-shim.mjs unprompted-speech `, a * mode:'blocking' entry in the shim's table. So the opted-in lesson refusal (exit 2) still - * propagates — but through the runtime, exactly like route-dispatch's wall, which is why + * propagates — but through the runtime, unlike route-dispatch's host-limited audit, which is why * unprompted-speech joins the other three here and `lesson-hooks.sh` leaves (leaving it on the list * would be a STALE exemption — it is no longer registered — and the assertion below now proves that). * @@ -391,7 +391,12 @@ describe('registry hygiene', () => { * consent guard that switched itself off when the user switched the brain off would guard nothing * at the only moment it is needed. */ - const BLOCKING = Object.freeze(['route-dispatch', 'design-wall', 'unprompted-speech', 'protect-state']); + const BLOCKING = Object.freeze([ + 'design-wall', + 'unprompted-speech', + 'protect-state', + 'swarm-slot-recycler', + ]); const isBlocking = (cmd) => BLOCKING.some((b) => cmd.includes(b)); it('gives every ADVISORY hook a `|| true` failsafe — a hook error must never reach the user', () => { diff --git a/tests/unit/hook-hardening.test.mjs b/tests/unit/hook-hardening.test.mjs index 08fb41e3..6cdc3fee 100644 --- a/tests/unit/hook-hardening.test.mjs +++ b/tests/unit/hook-hardening.test.mjs @@ -298,13 +298,14 @@ describe.skipIf(bashOnly)('session-start: the once-per-session context cost is b expect(steady).toBeLessThanOrEqual(10_500); }, 60_000); - it('TEETH: the content two other gates depend on is present, so a future cut cannot silently take it', () => { + it('TEETH: SessionStart points to the playbook and the prompt gate carries its compact L4 contract', () => { const out = session().stdout; expect(out).toContain('RuvNet Brain active'); expect(out).toContain('standing build playbook'); + const promptGate = fs.readFileSync(path.join(SCRIPTS, 'ground-ruvnet.sh'), 'utf8'); for (const marker of ['take the wheel', 'SPARC', 'DDD', 'ADR', 'swarm', 'QA gate', '98', 'frontend-design', 'image generation', 'API key', 'PROVEN', 'PARALLEL']) { - expect(out.toLowerCase(), `L4 marker "${marker}" is gone`).toContain(marker.toLowerCase()); + expect(promptGate.toLowerCase(), `L4 marker "${marker}" is gone`).toContain(marker.toLowerCase()); } }, 30_000); }); diff --git a/tests/unit/hook-registry-lint.test.mjs b/tests/unit/hook-registry-lint.test.mjs index 70901ae8..1631ed3a 100644 --- a/tests/unit/hook-registry-lint.test.mjs +++ b/tests/unit/hook-registry-lint.test.mjs @@ -217,7 +217,7 @@ describe('the merged census — six registries, not one', () => { expect(hasFailsafe('node x.mjs')).toBe(false); }); - it('the shipped dispatch wall covers Task and Agent exactly, without catching TaskStop', () => { + it('the shipped dispatch audit covers Task and Agent exactly, without catching TaskStop', () => { const dispatch = mesh(repoReg.records).filter((r) => r.layer === 'plugin' && r.handler === 'route-dispatch.sh'); expect(dispatch).toHaveLength(1); expect(dispatch[0].matcher).toBe('^(Task|Agent)$'); @@ -454,7 +454,7 @@ describe.skipIf(MACHINE_SKIP_REASON)( expect(c.mesh, `merged mesh ${c.mesh} vs the ${plugin} hook-contract.test.mjs can see`).toBeGreaterThan(plugin * 2); }); - it('F3 — route-dispatch has exactly one blocking registration in the merged mesh', () => { + it('F3 — route-dispatch has exactly one registration in the merged mesh', () => { // F3 is closed and is no longer an expected-red Appendix-B condition. Keep the live // regression here because this merged-machine assertion catches a user-layer duplicate that // the repo-only invariant cannot see, but do not record an empty finding as stale debt. diff --git a/tests/unit/hook-shim.test.mjs b/tests/unit/hook-shim.test.mjs index 6773cb8d..141c5615 100644 --- a/tests/unit/hook-shim.test.mjs +++ b/tests/unit/hook-shim.test.mjs @@ -58,10 +58,10 @@ describe.skipIf(process.platform === 'win32')('hook-shim.mjs — restart-free ho expect(run('ground-ruvnet').stdout).toMatch(/FROM-GEN-2/); // next fire = new code. No restart. }); - it('BLOCKING mode propagates the exact exit code — route-dispatch\'s exit-2 wall survives', () => { + it('route-dispatch is advisory even when a stale body tries to return exit 2', () => { seedSpine('1.0.0', { 'route-dispatch.sh': '#!/bin/bash\necho BLOCKED >&2\nexit 2\n' }); const r = run('route-dispatch'); - expect(r.status).toBe(2); // not 0, not 1 — the CONTRACT code + expect(r.status).toBe(0); expect(r.stderr).toMatch(/BLOCKED/); }); diff --git a/tests/unit/lesson-seed-provenance.test.mjs b/tests/unit/lesson-seed-provenance.test.mjs new file mode 100644 index 00000000..24474a44 --- /dev/null +++ b/tests/unit/lesson-seed-provenance.test.mjs @@ -0,0 +1,68 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + BUNDLED_OWNER_SEED_IDS, + ORIGIN, + SOURCE_CLASS, + STATUS, + loadLessons, + makeLesson, + ratify, +} from '../../plugin/scripts/lesson-store.mjs'; +import { SEED } from '../../scripts/lesson-seed.mjs'; + +const temps = []; +const temporary = () => { + const value = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-lesson-provenance-')); + temps.push(value); + return value; +}; + +afterEach(() => { + for (const value of temps.splice(0)) fs.rmSync(value, { recursive: true, force: true }); +}); + +describe('issue #83 — bundled owner lesson provenance', () => { + it('a fresh personal store is empty and the installer has no seed mutation path', () => { + expect(loadLessons(path.join(temporary(), 'lessons.json'))).toEqual([]); + const installer = fs.readFileSync(path.resolve(import.meta.dirname, '../../bin/install.mjs'), 'utf8'); + expect(installer).not.toMatch(/lesson-seed\.mjs|saveLessons\(SEED/); + }); + + it('recognizes the exact legacy 12-row fingerprint and quarantines every imported owner row', () => { + const file = path.join(temporary(), 'lessons.json'); + fs.writeFileSync(file, `${JSON.stringify({ version: 1, lessons: SEED }, null, 2)}\n`); + + const loaded = loadLessons(file); + expect(new Set(loaded.map((lesson) => lesson.id))).toEqual(BUNDLED_OWNER_SEED_IDS); + expect(loaded).toHaveLength(12); + expect(loaded.every((lesson) => lesson.sourceClass === SOURCE_CLASS.IMPORTED_OWNER)).toBe(true); + expect(loaded.every((lesson) => lesson.origin === ORIGIN.IMPORTED && lesson.demoted)).toBe(true); + expect(loaded.every((lesson) => lesson.status === STATUS.CANDIDATE)).toBe(true); + }); + + it('cannot ratify a quarantined owner import as current-user policy', () => { + const imported = makeLesson({ + ...SEED[0], + origin: ORIGIN.IMPORTED, + sourceClass: SOURCE_CLASS.IMPORTED_OWNER, + demoted: true, + }); + expect(ratify(imported.id, [imported])).toEqual([imported]); + }); + + it('preserves ordinary current-user lessons outside the exact fingerprint', () => { + const current = makeLesson({ + ...SEED[0], + id: 'personal-rule', + origin: ORIGIN.USER_STATED, + sourceClass: SOURCE_CLASS.CURRENT_USER, + demoted: false, + }); + expect(current.origin).toBe(ORIGIN.USER_STATED); + expect(current.sourceClass).toBe(SOURCE_CLASS.CURRENT_USER); + expect(current.demoted).toBe(false); + }); +}); diff --git a/tests/unit/model-router-managed-catalog.test.mjs b/tests/unit/model-router-managed-catalog.test.mjs new file mode 100644 index 00000000..7449801d --- /dev/null +++ b/tests/unit/model-router-managed-catalog.test.mjs @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { mergeManagedCatalog } from '../../scripts/model-router-catalog.mjs'; + +const MANAGED = JSON.parse(fs.readFileSync( + path.resolve(import.meta.dirname, '../../config/model-router/catalog.template.json'), + 'utf8', +)); + +describe('issue #87 — managed catalog additions and user overlay preservation', () => { + it('adds Opus 5 to an old catalog while preserving overrides and never enabling a metered candidate', () => { + const existing = { + updated: '2026-07-12 (local)', + candidates: [ + { id: 'claude-opus-4-8', provider: 'anthropic', harness: [], subscription: [], tier: 'mid', disabled: true }, + { id: 'custom/local', provider: 'local', harness: ['claude-code'], subscription: [], tier: 'cheap' }, + ], + }; + const merged = mergeManagedCatalog(existing, MANAGED); + expect(merged.candidates.find((candidate) => candidate.id === 'claude-opus-4-8')).toEqual(existing.candidates[0]); + expect(merged.candidates).toContainEqual(existing.candidates[1]); + expect(merged.candidates.find((candidate) => candidate.id === 'claude-opus-5')).toMatchObject({ + provider: 'anthropic', harness: ['claude-code'], subscription: ['claude-code'], tier: 'frontier', + }); + expect(merged.candidates.filter((candidate) => candidate.provider === 'openrouter' && !existing.candidates.some((old) => old.id === candidate.id))) + .toEqual([]); + }); + + it('is idempotent', () => { + const once = mergeManagedCatalog({ candidates: [] }, MANAGED); + expect(mergeManagedCatalog(once, MANAGED)).toEqual(once); + }); + + it('the Console renders every launchable development model and marks receipts separately', () => { + const source = fs.readFileSync(path.resolve(import.meta.dirname, '../../console/app.js'), 'utf8'); + expect(source).toMatch(/const devRows = pool\s*\.filter/); + expect(source).toMatch(/recommended\?\.model === p\.id/); + expect(source).toContain('All ${devRows.length} launchable Claude Code models are shown'); + expect(source).not.toMatch(/const devRows = bestPerTier/); + }); +}); diff --git a/tests/unit/protected-release-invocation.test.mjs b/tests/unit/protected-release-invocation.test.mjs index a514d864..ad113973 100644 --- a/tests/unit/protected-release-invocation.test.mjs +++ b/tests/unit/protected-release-invocation.test.mjs @@ -107,12 +107,11 @@ describe('protected publish invocation guard', () => { it('is load-bearing before any remote mutation in the canonical publisher', () => { const source = fs.readFileSync(path.resolve(import.meta.dirname, '../../scripts/release.mjs'), 'utf8'); - const guard = source.indexOf('validateProtectedPublishInvocation'); - const push = source.indexOf("runOrDie('git push'"); - const publish = source.indexOf("runOrDie('npm publish'"); + const guard = source.indexOf('validateProtectedPublishInvocation({ root: ROOT })'); + const publish = source.indexOf('const finalReceipt = await runReleaseTransaction'); expect(guard).toBeGreaterThan(-1); - expect(guard).toBeLessThan(push); expect(guard).toBeLessThan(publish); + expect(source).not.toContain("runOrDie('git push'"); expect(source).toContain('PROTECTED RELEASE GATE FAILED'); }); }); diff --git a/tests/unit/protected-release-workflow.test.mjs b/tests/unit/protected-release-workflow.test.mjs index 0a46be72..df4041ca 100644 --- a/tests/unit/protected-release-workflow.test.mjs +++ b/tests/unit/protected-release-workflow.test.mjs @@ -32,6 +32,21 @@ describe('protected release rail', () => { expect(source).toContain("status === 'completed' && conclusion === 'success'"); }); + it('fails closed on maintainer-governed release blockers without letting unrelated issues wedge releases', () => { + const source = workflow(); + expect(source).toContain('issues: read'); + expect(source).toContain('Require zero maintainer-governed release blockers'); + expect(source).toContain('--state open --label release-blocker'); + expect(source).toContain('test "$blockers" -eq 0'); + }); + + it('serializes all versions through one publisher fence and preserves failure evidence', () => { + const source = workflow(); + expect(source).toContain('group: ruvnet-brain-release'); + expect(source).toContain('cancel-in-progress: false'); + expect(source).toContain('if: always()'); + }); + it('keeps generated evidence outside the checkout until source cleanliness is proven', () => { expect(ci()).toContain('$RUNNER_TEMP/release-evidence'); const source = workflow(); @@ -39,13 +54,14 @@ describe('protected release rail', () => { expect(gitignore()).toMatch(/^\/release-evidence\/$/m); }); - it('puts the sole publisher behind Production approval and preserves post-publication proof', () => { + it('puts the sole publisher behind the Production boundary without a manual-review dependency', () => { const source = workflow(); expect(source).toContain('environment: Production – ruvnet-brain'); expect(source.match(/node scripts\/release\.mjs --publish/g)).toHaveLength(1); expect(source).toContain('RUVNET_RELEASE_MODE: stabilization'); expect(source).toContain('--publication release-evidence/publication-receipt.json'); expect(source).not.toContain('continue-on-error: true'); + expect(source).not.toMatch(/reviewer|after approval/i); }); it('selects and opens a real RVF instead of macOS ZIP metadata', () => { diff --git a/tests/unit/provider-catalog-boundary.test.mjs b/tests/unit/provider-catalog-boundary.test.mjs new file mode 100644 index 00000000..8d45e34e --- /dev/null +++ b/tests/unit/provider-catalog-boundary.test.mjs @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { providerAvailability } from '../../scripts/provider-availability.mjs'; +import { gatherRouterEngine } from '../../scripts/onboarding-console.mjs'; + +describe('issue #86 — provider catalog boundary', () => { + it('detects OpenAI and both Google aliases without returning credential values', () => { + const catalog = { + providers: { + openai: { detect_env: ['OPENAI_API_KEY'] }, + google: { detect_env: ['GOOGLE_API_KEY', 'GEMINI_API_KEY'] }, + }, + }; + const result = providerAvailability(catalog, {}, { + OPENAI_API_KEY: 'dummy-openai-secret', + GEMINI_API_KEY: 'dummy-gemini-secret', + }); + expect(result).toEqual({ openai: true, google: true }); + expect(JSON.stringify(result)).not.toContain('dummy'); + }); + + it('uses boolean native subscription detections in degraded mode and keeps negative controls false', () => { + expect(providerAvailability(null, { + openai: { apiKey: true }, + google: { apiKey: true }, + anthropic: { subscription: true }, + }, {})).toEqual({ openai: true, google: true, anthropic: false }); + expect(providerAvailability({ providers: { openai: { detect_env: ['OPENAI_API_KEY'] } } }, {}, {})) + .toEqual({ openai: false }); + }); + + it('the Console API read model exposes degraded catalog health without false-negative keys', () => { + const before = { + catalog: process.env.RUVNET_MODEL_CATALOG, + openai: process.env.OPENAI_API_KEY, + google: process.env.GOOGLE_API_KEY, + }; + process.env.RUVNET_MODEL_CATALOG = '/definitely/missing/model-catalog.json'; + process.env.OPENAI_API_KEY = 'dummy-openai'; + process.env.GOOGLE_API_KEY = 'dummy-google'; + try { + const result = gatherRouterEngine(); + expect(result.providerCatalog.status).toBe('degraded'); + expect(result.keys).toMatchObject({ openai: true, google: true }); + expect(JSON.stringify(result)).not.toContain('dummy-openai'); + expect(JSON.stringify(result)).not.toContain('dummy-google'); + } finally { + if (before.catalog === undefined) delete process.env.RUVNET_MODEL_CATALOG; else process.env.RUVNET_MODEL_CATALOG = before.catalog; + if (before.openai === undefined) delete process.env.OPENAI_API_KEY; else process.env.OPENAI_API_KEY = before.openai; + if (before.google === undefined) delete process.env.GOOGLE_API_KEY; else process.env.GOOGLE_API_KEY = before.google; + } + }); +}); diff --git a/tests/unit/publication-receipt-wiring.test.mjs b/tests/unit/publication-receipt-wiring.test.mjs index d2cc5c53..3979f27e 100644 --- a/tests/unit/publication-receipt-wiring.test.mjs +++ b/tests/unit/publication-receipt-wiring.test.mjs @@ -4,6 +4,8 @@ import path from 'node:path'; const ROOT = path.resolve(import.meta.dirname, '../..'); const release = fs.readFileSync(path.join(ROOT, 'scripts/release.mjs'), 'utf8'); +const transaction = fs.readFileSync(path.join(ROOT, 'scripts/release-transaction.mjs'), 'utf8'); +const provider = fs.readFileSync(path.join(ROOT, 'scripts/release-transaction-provider.mjs'), 'utf8'); const workflow = fs.readFileSync(path.join(ROOT, '.github/workflows/protected-release.yml'), 'utf8'); const producer = fs.readFileSync(path.join(ROOT, 'scripts/publication-receipt.mjs'), 'utf8'); @@ -14,18 +16,21 @@ const position = (source, needle) => { }; describe('publication receipt wiring', () => { - it('publishes the exact sealed candidate tarball to npm and GitHub', () => { + it('passes the exact sealed candidate tarball into one provider', () => { expect(release).toContain("sealedPackageArtifact = path.resolve(ROOT, protectedCandidate.artifact.path)"); - expect(release).toContain('const assets = [zip, `${zip}.sig`, `${zip}.sha256`, sealedPackageArtifact]'); - expect(release).toContain("runOrDie('npm publish', 'npm', ['publish', sealedPackageArtifact, '--tag', 'latest'])"); + expect(release).toContain('packagePath: sealedPackageArtifact'); + expect(provider).toContain("command('npm', ['publish', packagePath, '--tag', `candidate-v${identity.version}`])"); + expect(provider).toContain("command('gh', ['release', 'upload', draft.tag, file, '--repo', REPO])"); }); - it('generates and validates publication evidence before SHIPPED or channel convergence', () => { - const producer = position(release, "runOrDie('publication receipt'"); - expect(producer).toBeGreaterThan(position(release, "runOrDie('verify-channels'")); - expect(producer).toBeLessThan(position(release, "recordReleaseTransaction('channels-converged'")); - expect(producer).toBeLessThan(position(release, '✓✓✓ SHIPPED')); - expect(release).toContain("'scripts/publication-receipt.mjs', '--candidate'"); + it('generates and validates publication evidence before remote convergence', () => { + expect(position(provider, "'scripts/publication-receipt.mjs'")) + .toBeLessThan(position(provider, "'scripts/release-proof.mjs'")); + expect(position(transaction, 'adapter.finalize')) + .toBeLessThan(position(transaction, "append('channels-converged'")); + expect(provider).toContain("'scripts/verify-channels.mjs'"); + expect(position(transaction, "append('channels-converged'")) + .toBeLessThan(position(release, '✓✓✓ SHIPPED')); }); it('provisions virgin host CLIs before the protected publisher and gives the producer read-only GitHub access', () => { @@ -43,14 +48,14 @@ describe('publication receipt wiring', () => { }); it('MUTANT: checkout publication cannot replace the sealed artifact command', () => { - expect(release).not.toContain("runOrDie('npm publish', 'npm', ['publish', '--tag', 'latest'])"); + expect(provider).not.toContain("command('npm', ['publish', '--tag', 'latest'])"); }); it.each([ - ['MUTANT: omit publication producer', /runOrDie\('publication receipt'/g], - ['MUTANT: omit sealed artifact from release assets', /, sealedPackageArtifact/g], + ['MUTANT: omit publication producer', /publication-receipt\.mjs/g], + ['MUTANT: omit sealed artifact', /assets\.packagePath/g], ])('%s', (_name, guard) => { - expect(release.replace(guard, '')).not.toMatch(guard); - expect(release).toMatch(guard); + expect(provider.replace(guard, '')).not.toMatch(guard); + expect(provider).toMatch(guard); }); }); diff --git a/tests/unit/release-channel-contract.test.mjs b/tests/unit/release-channel-contract.test.mjs index 602b493b..c6d03efd 100644 --- a/tests/unit/release-channel-contract.test.mjs +++ b/tests/unit/release-channel-contract.test.mjs @@ -7,29 +7,36 @@ import { spawnSync } from 'node:child_process'; const ROOT = path.resolve(import.meta.dirname, '../..'); const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); -describe('manual release channel contract', () => { - it('builds and signs before creating the GitHub Release, then publishes npm, then verifies', () => { - const src = read('scripts/release.mjs'); - const build = src.indexOf("runOrDie('build release bundle'"); - const sign = src.indexOf("runOrDie('sign release bundle'"); - const github = src.indexOf("runOrDie('create signed GitHub Release'"); - const npm = src.indexOf("runOrDie('npm publish'"); - const verify = src.indexOf("runOrDie('verify-channels'"); +describe('protected release channel contract', () => { + it('builds and signs before the staged transaction promotes GitHub, npm, then verifies', () => { + const release = read('scripts/release.mjs'); + const transaction = read('scripts/release-transaction.mjs'); + const build = release.indexOf("runOrDie('build release bundle'"); + const sign = release.indexOf("runOrDie('sign release bundle'"); + const transactionStart = release.indexOf('const finalReceipt = await runReleaseTransaction'); + const github = transaction.indexOf('adapter.publishDraftNonLatest'); + const npm = transaction.indexOf('adapter.promoteNpm'); + const verify = transaction.indexOf('adapter.finalize'); expect(build).toBeGreaterThanOrEqual(0); expect(sign).toBeGreaterThan(build); - expect(github).toBeGreaterThan(sign); + expect(transactionStart).toBeGreaterThan(sign); + expect(github).toBeGreaterThanOrEqual(0); expect(npm).toBeGreaterThan(github); expect(verify).toBeGreaterThan(npm); }); it('fails closed on tag/SHA mismatch and requires all signed assets', () => { - const src = read('scripts/release.mjs'); - expect(src).toContain('release tag already identifies different bytes'); - expect(src).toContain('published GitHub Release tag is not candidate HEAD'); - expect(src).toContain('`${zip}.sig`'); - expect(src).toContain('`${zip}.sha256`'); - expect(src).toContain("'release', 'upload', tag, ...assets, '--clobber'"); + const release = read('scripts/release.mjs'); + const transaction = read('scripts/release-transaction.mjs'); + const provider = read('scripts/release-transaction-provider.mjs'); + expect(transaction).toContain("exact(github.sha, identity.candidateSha, 'GitHub candidate SHA')"); + expect(provider).toContain('refusing to replace staged asset with different bytes'); + expect(transaction).toContain('pending release ${competing[0].transactionId} blocks'); + expect(transaction).toContain('release receipt chain conflict'); + expect(release).toContain('`${zip}.sig`'); + expect(release).toContain('`${zip}.sha256`'); + expect(provider).not.toContain("'--clobber'"); }); }); diff --git a/tests/unit/release-evidence-dag.test.mjs b/tests/unit/release-evidence-dag.test.mjs new file mode 100644 index 00000000..8fa1a3c4 --- /dev/null +++ b/tests/unit/release-evidence-dag.test.mjs @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; + +const ROOT = path.resolve(import.meta.dirname, '../..'); +const read = (file) => fs.readFileSync(path.join(ROOT, file), 'utf8'); + +describe('release evidence DAG', () => { + it('builds one npm candidate and fans those exact bytes into release QE and stranger hosts', () => { + const ci = read('.github/workflows/ci.yml'); + const stranger = read('.github/workflows/stranger-matrix.yml'); + expect(ci.match(/npm pack --json/g)).toHaveLength(1); + expect(ci).toContain('RUVNET_SEALED_PACKAGE=$artifact'); + expect(ci.indexOf('Build the immutable npm candidate exactly once')) + .toBeLessThan(ci.indexOf('Exact-artifact release QE')); + expect(stranger).not.toMatch(/^\s*run:\s*npm pack/m); + expect(stranger).toContain('actions/download-artifact@v4'); + expect(stranger).toContain('release-evidence-${{ env.CANDIDATE_SHA }}'); + }); + + it('keeps source gates out of the protected publication branch', () => { + const release = read('scripts/release.mjs'); + const checkOnly = release.indexOf('if (!PUBLISH) {'); + const transaction = release.indexOf('if (PUBLISH) {', checkOnly + 1); + expect(checkOnly).toBeGreaterThan(-1); + expect(transaction).toBeGreaterThan(checkOnly); + const sourceGates = release.slice(checkOnly, transaction); + expect(sourceGates).toContain("runOrDie('npm test'"); + expect(sourceGates).toContain("runOrDie('vitest unit'"); + expect(sourceGates).toContain("runOrDie('version sync'"); + expect(release).not.toContain("runOrDie('git push'"); + expect(release).not.toContain('fetchLatestCiVerdict'); + }); + + it('verifies public channels once per publish through transaction finalization', () => { + const release = read('scripts/release.mjs'); + const provider = read('scripts/release-transaction-provider.mjs'); + expect(provider).toContain("'scripts/verify-channels.mjs'"); + expect(release).toContain('if (!PUBLISH) {\n step(\'E\''); + }); +}); diff --git a/tests/unit/release-lineage.test.mjs b/tests/unit/release-lineage.test.mjs index 23e728e3..701ca238 100644 --- a/tests/unit/release-lineage.test.mjs +++ b/tests/unit/release-lineage.test.mjs @@ -34,8 +34,11 @@ describe('release command wording', () => { it('publishes only through the canonical release path with an explicit npm tag', () => { const release = fs.readFileSync(path.join(ROOT, 'scripts/release.mjs'), 'utf8'); + const provider = fs.readFileSync(path.join(ROOT, 'scripts/release-transaction-provider.mjs'), 'utf8'); const nightly = fs.readFileSync(path.join(ROOT, 'scripts/self-update.mjs'), 'utf8'); - expect(release).toContain("runOrDie('npm publish', 'npm', ['publish', sealedPackageArtifact, '--tag', 'latest'])"); + expect(release).toContain('await runReleaseTransaction'); + expect(provider).toContain("command('npm', ['publish', packagePath, '--tag', `candidate-v${identity.version}`])"); + expect(provider).toContain("command('npm', ['dist-tag', 'add', `${PACKAGE}@${identity.version}`, 'latest'])"); expect(nightly).not.toMatch(/execFileSync\(['"]npm['"],\s*\[['"]publish['"]/); expect(nightly).toContain('self-update is rebuild-only'); }); diff --git a/tests/unit/release-transaction.test.mjs b/tests/unit/release-transaction.test.mjs new file mode 100644 index 00000000..0a3cd4b2 --- /dev/null +++ b/tests/unit/release-transaction.test.mjs @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest'; +import { + abortReleaseTransaction, + signReceipt, + validateReceiptChain, +} from '../../scripts/release-transaction.mjs'; +import { + execute, FakeReleaseProvider, identity, keys, transactionId, +} from '../helpers/release-transaction-fixture.mjs'; + +describe('remote durable release transaction', () => { + it('stages both providers before advancing defaults and finishes with a signed remote receipt', async () => { + const provider = new FakeReleaseProvider(); + const final = await execute(provider); + expect(final.state).toBe('channels-converged'); + expect(final.transactionId).toBe(transactionId); + expect(provider.calls.indexOf('stageNpm')).toBeLessThan(provider.calls.indexOf('publishDraftNonLatest')); + expect(provider.calls.indexOf('publishDraftNonLatest')).toBeLessThan(provider.calls.indexOf('promoteNpm')); + expect(provider.calls.indexOf('promoteNpm')).toBeLessThan(provider.calls.indexOf('makeGithubLatest')); + expect(validateReceiptChain(provider.receipts, identity, keys.publicKey).at(-1).state) + .toBe('channels-converged'); + }); + + it('rejects a competing pending candidate before creating a draft', async () => { + const provider = new FakeReleaseProvider(); + provider.pending = [{ transactionId: 'c'.repeat(64) }]; + await expect(execute(provider)).rejects.toThrow('blocks'); + expect(provider.calls).not.toContain('createDraft'); + }); + + it('rejects hostile receipt mutation and sequence replay', () => { + const receipt = signReceipt({ + schemaVersion: 1, transactionId, sequence: 0, previousReceiptDigest: null, + state: 'remote-prepared', fence: 'owner', identity, observation: {}, createdAt: 'now', + }, keys.privateKey); + expect(() => validateReceiptChain([{ ...receipt, state: 'prepared' }], identity, keys.publicKey)) + .toThrow('digest'); + expect(() => validateReceiptChain([receipt, { ...receipt, sequence: 1 }], identity, keys.publicKey)) + .toThrow(); + }); + + it('requires explicit human authorization to burn a poisoned immutable version', async () => { + const provider = new FakeReleaseProvider({ fault: 'stageNpm' }); + await expect(execute(provider)).rejects.toThrow('injected stageNpm'); + await expect(abortReleaseTransaction({ + identity, receipts: provider.receipts, reason: 'poisoned version', authorized: false, + adapter: provider, privateKey: keys.privateKey, publicKey: keys.publicKey, + })).rejects.toThrow('explicit human authorization'); + const aborted = await abortReleaseTransaction({ + identity, receipts: provider.receipts, reason: 'poisoned version', authorized: true, + adapter: provider, privateKey: keys.privateKey, publicKey: keys.publicKey, + }); + expect(aborted.state).toBe('aborted'); + }); +}); diff --git a/tests/unit/route-dispatch-host-timing.test.mjs b/tests/unit/route-dispatch-host-timing.test.mjs new file mode 100644 index 00000000..aee7fe27 --- /dev/null +++ b/tests/unit/route-dispatch-host-timing.test.mjs @@ -0,0 +1,170 @@ +import { describe, expect, it } from 'vitest'; +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +const REPO = path.resolve(import.meta.dirname, '../..'); +const HOOKS_PATH = path.join(REPO, 'plugin/hooks/hooks.json'); +const ROUTE_DISPATCH = path.join(REPO, 'plugin/scripts/route-dispatch.sh'); +const RECEIPT_DIR = ['meta', 'harness'].join(''); + +function optedInHome() { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'route-timing-')); + fs.mkdirSync(path.join(home, '.claude/model-router'), { recursive: true }); + fs.writeFileSync(path.join(home, '.claude/model-router/profile.json'), '{"harnesses":{}}'); + return home; +} + +function missingModelPayload() { + return JSON.stringify({ + hook_event_name: 'PreToolUse', + tool_name: 'Agent', + tool_use_id: 'toolu_issue_84', + session_id: 'session_issue_84', + tool_input: { description: 'bounded timing probe', subagent_type: 'general-purpose' }, + }); +} + +function registeredRouteHook() { + const hooks = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8')).hooks.PreToolUse; + const matches = hooks.flatMap((group, groupIndex) => + (group.hooks || []).map((hook, hookIndex) => ({ group, hook, groupIndex, hookIndex })), + ).filter(({ hook }) => hook.command.includes('hook-shim.mjs\" route-dispatch')); + expect(matches).toHaveLength(1); + return matches[0]; +} + +function hookEnv(home) { + return { + ...process.env, + HOME: home, + CLAUDE_PLUGIN_ROOT: path.join(REPO, 'plugin'), + RUVNET_BRAIN_HOME: path.join(home, '.cache/ruvnet-brain'), + }; +} + +function runRegistered(input, home) { + const { hook } = registeredRouteHook(); + return spawnSync('/bin/sh', ['-c', hook.command], { + cwd: REPO, + input, + encoding: 'utf8', + timeout: hook.timeout * 1_000, + env: hookEnv(home), + }); +} + +function spawnRegistered(input, home, { closeInput = true } = {}) { + const { hook } = registeredRouteHook(); + const child = spawn('/bin/sh', ['-c', hook.command], { + cwd: REPO, + stdio: ['pipe', 'pipe', 'pipe'], + env: hookEnv(home), + }); + if (closeInput) child.stdin.end(input); + else child.stdin.write(input); + return child; +} + +function waitFor(child) { + return new Promise((resolve, reject) => { + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (status, signal) => resolve({ status, signal, stdout, stderr })); + }); +} + +describe.skipIf(process.platform === 'win32')('issue #84 — Agent/Task host timing contract', () => { + it('registers route-dispatch as advisory without changing adjacent hook registrations', () => { + const hooks = JSON.parse(fs.readFileSync(HOOKS_PATH, 'utf8')).hooks.PreToolUse; + const { group, hook, groupIndex } = registeredRouteHook(); + + expect(group.matcher).toBe('^(Task|Agent)$'); + expect(hook.command).toMatch(/route-dispatch \|\| true$/); + expect(hook.timeout).toBe(5); + expect(hooks[groupIndex - 1].hooks[0].command).toContain('hijack-ruvnet'); + expect(hooks[groupIndex + 1].hooks.map((entry) => entry.command)).toEqual([ + 'node "${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs" ground-before-write || true', + 'node "${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs" protect-state', + ]); + }); + + it('returns before its declared timeout and never claims a late block', () => { + const home = optedInHome(); + const started = performance.now(); + const result = runRegistered(missingModelPayload(), home); + const elapsedMs = performance.now() - started; + + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + expect(elapsedMs).toBeLessThan(1_000); + + const receipt = JSON.parse(fs.readFileSync( + path.join(home, '.claude', RECEIPT_DIR, 'dispatch-log.jsonl'), + 'utf8', + ).trim()); + expect(receipt).toMatchObject({ + event: 'dispatch', + model: 'inherited', + enforcement: 'advisory-host-timing', + toolUseId: 'toolu_issue_84', + sessionId: 'session_issue_84', + }); + }); + + it('cannot delay tool completion or consume foreign hook processes', async () => { + const home = optedInHome(); + const foreign = spawn(process.execPath, ['-e', 'setTimeout(() => process.stdout.write("foreign-ok"), 80)']); + const ours = spawnRegistered(missingModelPayload(), home); + const tool = spawn(process.execPath, ['-e', 'setTimeout(() => process.stdout.write("tool-complete"), 20)']); + + // Attach every close listener immediately. Short-lived siblings may exit while the + // tool promise is awaited; attaching later loses their close event and creates a false timeout. + const foreignDone = waitFor(foreign); + const oursDone = waitFor(ours); + const toolResult = await waitFor(tool); + const toolEndedAt = performance.now(); + const [ourResult, foreignResult] = await Promise.all([oursDone, foreignDone]); + const hooksCheckedAt = performance.now(); + + expect(toolResult).toMatchObject({ status: 0, stdout: 'tool-complete', stderr: '' }); + expect(ourResult).toMatchObject({ status: 0, stdout: '', stderr: '' }); + expect(foreignResult).toMatchObject({ status: 0, stdout: 'foreign-ok', stderr: '' }); + expect(toolEndedAt).toBeLessThan(hooksCheckedAt); + }); + + it('finishes with a held-open stdin stream instead of reaching the host timeout', async () => { + const home = optedInHome(); + const child = spawnRegistered(missingModelPayload(), home, { closeInput: false }); + + const watchdog = new Promise((_, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('registered route-dispatch reached the 1s compatibility watchdog')); + }, 1_000); + timer.unref(); + }); + const result = await Promise.race([waitFor(child), watchdog]); + + expect(result).toMatchObject({ status: 0, stdout: '', stderr: '' }); + }); + + it('the script itself is advisory when invoked without the shim', () => { + const home = optedInHome(); + const result = spawnSync('bash', [ROUTE_DISPATCH], { + input: missingModelPayload(), + encoding: 'utf8', + timeout: 5_000, + env: { ...process.env, HOME: home }, + }); + expect(result.status, result.stderr).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + }); +}); diff --git a/tests/unit/route-dispatch.test.mjs b/tests/unit/route-dispatch.test.mjs index 4a6fb3dd..544681c5 100644 --- a/tests/unit/route-dispatch.test.mjs +++ b/tests/unit/route-dispatch.test.mjs @@ -1,11 +1,11 @@ -// tests/unit/route-dispatch.test.mjs — the wall that ends model-inheritance, and the adversary that +// tests/unit/route-dispatch.test.mjs — the audit that measures model inheritance, and the adversary that // asks Stuart's questions before he has to. // // THE LEAK (2026-07-13). Stuart: "What happens when I'm right here in Opus 4.8 and it has 10 things to // run? Is it going to just run them as Opus 4.8?" — YES. A subagent INHERITS the main-loop model unless // `model` is passed. Ten agents on a Fable session = ten agents at $10/$50 per Mtok, ~10x Haiku for -// identical mechanical work. The router existed; the rule to use it existed; the router's ENTIRE -// LIFETIME OUTPUT was 3 test pings and $0.018 saved. Advisory rules get ignored. So: a wall. +// identical mechanical work. The host currently provides no synchronous Agent/Task decision seam, +// so this hook records the leak without pretending a late exit code can stop it. // // THE DEEPER DEFECT (falsify.mjs). Stuart: "Why do you still keep needing me to call you on these // things? Ru would not miss stuff like this." Correct, and it is mechanical: I verify WHAT I BUILT, @@ -20,6 +20,7 @@ import { runAll, CHECKS } from '../../scripts/falsify.mjs'; const REPO = path.resolve(import.meta.dirname, '../..'); const GATE = path.join(REPO, 'plugin/scripts/route-dispatch.sh'); +const RECEIPT_DIR = ['meta', 'harness'].join(''); const hasBash = spawnSync('bash', ['-c', 'exit 0']).status === 0; /** @@ -50,7 +51,7 @@ function dispatch( return { status: r.status, stderr: r.stderr || '', home }; } -describe.skipIf(!hasBash || process.platform === 'win32')('route-dispatch.sh — a subagent cannot inherit the session model by omission', () => { +describe.skipIf(!hasBash || process.platform === 'win32')('route-dispatch.sh — subagent model selection audit', () => { it('NEVER touches a user who did not opt in — consent is the default', () => { // The defect I shipped and caught minutes later: this hook goes to EVERY plugin user. Hard-blocking // the Task tool for people who never asked for routing would break strangers' workflows. @@ -82,7 +83,7 @@ describe.skipIf(!hasBash || process.platform === 'win32')('route-dispatch.sh — it.each([ ['newline-terminated profile', '{"harnesses":{}}\n'], ['profile without a final newline', '{"harnesses":{}}'], - ])('BLOCKS a dispatch with no model for a %s', (_label, profileContent) => { + ])('records inherited-model use without claiming a late block for a %s', (_label, profileContent) => { const r = dispatch( { description: 'sweep tests', subagent_type: 'general-purpose' }, 'Task', @@ -90,17 +91,10 @@ describe.skipIf(!hasBash || process.platform === 'win32')('route-dispatch.sh — true, profileContent, ); - expect(r.status).toBe(2); // exit 2 = block, and stderr is fed back to the model as the reason - expect(r.stderr).toMatch(/SUBAGENT DISPATCH BLOCKED/); - expect(r.stderr).toMatch(/INHERITS this session's model/); - }); - - it('the block TEACHES rather than merely punishing — it names the tier for each kind of work', () => { - const { stderr } = dispatch({ description: 'x', subagent_type: 'general-purpose' }); - expect(stderr).toMatch(/haiku.*mechanical/is); - expect(stderr).toMatch(/sonnet.*analytical/is); - expect(stderr).toMatch(/opus.*judgment/is); - expect(stderr).toMatch(/model-router-engine/); // and points at the real router for the hard calls + expect(r.status).toBe(0); + expect(r.stderr).toBe(''); + const receipt = JSON.parse(fs.readFileSync(path.join(r.home, '.claude', RECEIPT_DIR, 'dispatch-log.jsonl'), 'utf8').trim()); + expect(receipt).toMatchObject({ model: 'inherited', enforcement: 'advisory-host-timing' }); }); it('ALLOWS a dispatch that declares its model, and LOGS it so routing is auditable', () => { diff --git a/tests/unit/session-snapshot-health.test.mjs b/tests/unit/session-snapshot-health.test.mjs new file mode 100644 index 00000000..0afbb369 --- /dev/null +++ b/tests/unit/session-snapshot-health.test.mjs @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { + SNAPSHOT_SCHEMA, + SNAPSHOT_VERSION, + createSessionSnapshot, + inspectSessionSnapshots, +} from '../../scripts/session-snapshot-contract.mjs'; +import { writeSessionSnapshot } from '../../plugin/scripts/session-snapshot-hook.mjs'; +import { probeMemory } from '../../scripts/onboarding-console.mjs'; + +const temps = []; +const temporary = () => { + const value = fs.mkdtempSync(path.join(os.tmpdir(), 'brain-snapshot-health-')); + temps.push(value); + return value; +}; +const NOW = Date.parse('2026-08-02T18:00:00.000Z'); + +afterEach(() => { + for (const value of temps.splice(0)) fs.rmSync(value, { recursive: true, force: true }); +}); + +describe('issue #85 — versioned compaction snapshot contract', () => { + it('ships a PreCompact producer that writes the same canonical receipt the Console validates', () => { + const project = temporary(); + expect(writeSessionSnapshot(project, 'PreCompact')).toBe(true); + expect(inspectSessionSnapshots(project)).toMatchObject({ kind: 'canonical', fresh: true }); + + const hooks = JSON.parse(fs.readFileSync(path.resolve('plugin/hooks/hooks.json'), 'utf8')).hooks; + const command = hooks.PreCompact.flatMap((group) => group.hooks) + .find((hook) => hook.command.includes('session-snapshot PreCompact')); + expect(command?.command).toMatch(/\|\| true$/); + }); + + it('accepts a fresh canonical receipt with the documented schema', () => { + const project = temporary(); + fs.mkdirSync(path.join(project, '.swarm')); + const receipt = createSessionSnapshot({ event: 'PreCompact', capturedAt: new Date(NOW).toISOString() }); + fs.writeFileSync(path.join(project, '.swarm', 'agentdb-sessions.jsonl'), `${JSON.stringify(receipt)}\n`); + + expect(receipt).toMatchObject({ schema: SNAPSHOT_SCHEMA, version: SNAPSHOT_VERSION }); + expect(inspectSessionSnapshots(project, { now: NOW })).toMatchObject({ kind: 'canonical', fresh: true }); + }); + + it.each(['.claude', '.claude-flow'])('accepts a fresh structurally valid %s legacy Ruflo session', (root) => { + const project = temporary(); + const dir = path.join(project, root, 'sessions'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'session-ok.json'), JSON.stringify({ + id: 'session-ok', + startedAt: new Date(NOW - 1000).toISOString(), + endedAt: new Date(NOW).toISOString(), + context: { project: 'fixture' }, + metrics: { tasks: 1 }, + })); + expect(inspectSessionSnapshots(project, { now: NOW })).toMatchObject({ kind: 'legacy', fresh: true }); + expect(probeMemory(project).compactionSurvival).toMatchObject({ status: 'ok', artifact: 'legacy' }); + }); + + it('reports absent, malformed, and stale artifacts without false ok', () => { + const absent = temporary(); + expect(inspectSessionSnapshots(absent, { now: NOW })).toMatchObject({ kind: 'absent', fresh: false }); + + const malformed = temporary(); + fs.mkdirSync(path.join(malformed, '.claude', 'sessions'), { recursive: true }); + fs.writeFileSync(path.join(malformed, '.claude', 'sessions', 'session-bad.json'), '{}'); + expect(inspectSessionSnapshots(malformed, { now: NOW })).toMatchObject({ kind: 'malformed', fresh: false }); + + const stale = temporary(); + fs.mkdirSync(path.join(stale, '.swarm')); + const receipt = createSessionSnapshot({ event: 'PreCompact', capturedAt: '2026-06-01T00:00:00.000Z' }); + fs.writeFileSync(path.join(stale, '.swarm', 'agentdb-sessions.jsonl'), `${JSON.stringify(receipt)}\n`); + expect(inspectSessionSnapshots(stale, { now: NOW })).toMatchObject({ kind: 'canonical', fresh: false }); + }); +}); diff --git a/tests/unit/session-start-core-parity.test.mjs b/tests/unit/session-start-core-parity.test.mjs index 33f7086f..47225011 100644 --- a/tests/unit/session-start-core-parity.test.mjs +++ b/tests/unit/session-start-core-parity.test.mjs @@ -3,6 +3,7 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { maintainerIssueEntitlement } from '../../plugin/scripts/session-start-core.mjs'; const ROOT = path.resolve(import.meta.dirname, '../..'); const SOURCE_SCRIPTS = path.join(ROOT, 'plugin/scripts'); @@ -205,8 +206,14 @@ describe.skipIf(process.platform === 'win32')('host-neutral SessionStart core pa write(path.join(f.cache, 'health.json'), { status: 'down', error: 'reader failed' }); write(path.join(f.cache, 'open-issues.json'), { at: now, + repo: 'stuinfla/ruvnet-brain', issues: [{ number: 77, title: 'search is red', ageHours: 4, breach: false }], }); + write(path.join(f.state, 'maintainer-issues.json'), { + enabled: true, + repos: ['stuinfla/ruvnet-brain'], + }); + fs.chmodSync(path.join(f.state, 'maintainer-issues.json'), 0o600); write(path.join(f.cache, 'external-signals/pending.jsonl'), '{"key":"x"}\n'); write(path.join(f.cache, 'external-signals/ci-status.json'), { 'repo@deadbeef': { @@ -230,6 +237,104 @@ describe.skipIf(process.platform === 'win32')('host-neutral SessionStart core pa expect(result.state.detach.join('\n')).toContain('host-update.mjs'); }); + it('never exposes maintainer issue counts to a normal end user', () => { + const result = parity((f) => { + warmed(f); + write(path.join(f.cache, 'open-issues.json'), { + at: new Date().toISOString(), + repo: 'stuinfla/ruvnet-brain', + issues: [{ number: 87, title: 'private maintainer signal', ageHours: 2, breach: false }], + }); + }); + expect(result.output).not.toMatch(/open issue/i); + expect(result.output).not.toContain('#87'); + expect(result.output).not.toContain('private maintainer signal'); + }); + + it('requires an owner-only, repo-scoped local entitlement before surfacing issue counts', () => { + const result = parity((f) => { + warmed(f); + write(path.join(f.cache, 'open-issues.json'), { + at: new Date().toISOString(), + repo: 'stuinfla/ruvnet-brain', + issues: [{ number: 87, title: 'maintainer signal', ageHours: 2, breach: false }], + }); + const entitlement = path.join(f.state, 'maintainer-issues.json'); + write(entitlement, { enabled: true, repos: ['stuinfla/ruvnet-brain'] }); + fs.chmodSync(entitlement, 0o600); + }); + expect(result.output).toContain('1 open issue(s) on stuinfla/ruvnet-brain'); + expect(result.output).toContain('#87'); + }); + + it('rejects a wrong-repository maintainer entitlement', () => { + const result = parity((f) => { + warmed(f); + write(path.join(f.cache, 'open-issues.json'), { + at: new Date().toISOString(), + repo: 'stuinfla/ruvnet-brain', + issues: [{ number: 87, title: 'must stay private', ageHours: 2, breach: false }], + }); + const entitlement = path.join(f.state, 'maintainer-issues.json'); + write(entitlement, { enabled: true, repos: ['someone/else'] }); + fs.chmodSync(entitlement, 0o600); + }); + expect(result.output).not.toMatch(/open issue/i); + expect(result.output).not.toContain('#87'); + }); + + it('rejects a group/world-readable maintainer entitlement', () => { + const result = parity((f) => { + warmed(f); + write(path.join(f.cache, 'open-issues.json'), { + at: new Date().toISOString(), + repo: 'stuinfla/ruvnet-brain', + issues: [{ number: 87, title: 'must stay owner-only', ageHours: 2, breach: false }], + }); + const entitlement = path.join(f.state, 'maintainer-issues.json'); + write(entitlement, { enabled: true, repos: ['stuinfla/ruvnet-brain'] }); + fs.chmodSync(entitlement, 0o644); + }); + expect(result.output).not.toMatch(/open issue/i); + expect(result.output).not.toContain('#87'); + }); + + it('fails closed for maintainer issue visibility on Windows', () => { + const f = makeFixture(); + const entitlement = path.join(f.state, 'maintainer-issues.json'); + write(entitlement, { enabled: true, repos: ['stuinfla/ruvnet-brain'] }); + fs.chmodSync(entitlement, 0o600); + expect(maintainerIssueEntitlement({ RUVNET_BRAIN_MAINTAINER_ISSUES_FILE: entitlement }, f.home, 'stuinfla/ruvnet-brain', 'win32')).toBe(false); + }); + + it('rejects a symlinked maintainer entitlement', () => { + const f = makeFixture(); + const target = path.join(f.state, 'maintainer-issues-target.json'); + const link = path.join(f.state, 'maintainer-issues.json'); + write(target, { enabled: true, repos: ['stuinfla/ruvnet-brain'] }); + fs.chmodSync(target, 0o600); + fs.symlinkSync(target, link); + expect(maintainerIssueEntitlement({ RUVNET_BRAIN_MAINTAINER_ISSUES_FILE: link }, f.home, 'stuinfla/ruvnet-brain', process.platform)).toBe(false); + }); + + it('rejects invalid and future-dated issue observations', () => { + for (const at of ['not-a-date', new Date(Date.now() + 10 * 60_000).toISOString()]) { + const result = parity((f) => { + warmed(f); + write(path.join(f.cache, 'open-issues.json'), { + at, + repo: 'stuinfla/ruvnet-brain', + issues: [{ number: 87, title: 'invalid observation', ageHours: 2, breach: false }], + }); + const entitlement = path.join(f.state, 'maintainer-issues.json'); + write(entitlement, { enabled: true, repos: ['stuinfla/ruvnet-brain'] }); + fs.chmodSync(entitlement, 0o600); + }); + expect(result.output).not.toMatch(/open issue/i); + expect(result.output).not.toContain('#87'); + } + }); + it('matches OFF behavior with an absent KB: one state line, no advertising, offers unconsumed', () => { const result = parity((f) => { write(path.join(f.state, 'brain-off'), { since: '2026-07-30', reason: 'pause' }); diff --git a/tests/unit/staged-host-verifier.test.mjs b/tests/unit/staged-host-verifier.test.mjs new file mode 100644 index 00000000..ab44802f --- /dev/null +++ b/tests/unit/staged-host-verifier.test.mjs @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { classifyDoctorResult } from '../../scripts/staged-host-verifier.mjs'; + +describe('staged host doctor classification', () => { + it('accepts a clean doctor verdict', () => { + expect(classifyDoctorResult({ status: 0, stdout: 'Healthy' })).toMatchObject({ + accepted: true, status: 'PASS', + }); + }); + + it('preserves explicit Codex hook trust as pending review', () => { + const stdout = 'Grounding PROVEN\nCodex installed the Brain, but 17 lifecycle hooks await review.'; + expect(classifyDoctorResult({ status: 1, stdout })).toMatchObject({ + accepted: true, status: 'PENDING_REVIEW', + }); + }); + + it.each([ + ['generic failure', 'Grounding PROVEN\nother failure'], + ['missing reader', 'Grounding PROVEN\nCodex installed the Brain, but 17 lifecycle hooks await review.\nreader MISSING'], + ['unproven grounding', 'Codex installed the Brain, but 17 lifecycle hooks await review.'], + ])('rejects %s instead of hiding it behind pending trust', (_name, stdout) => { + expect(classifyDoctorResult({ status: 1, stdout })).toMatchObject({ + accepted: false, status: 'FAIL', + }); + }); +}); diff --git a/tests/unit/swarm-slot-recycler.test.mjs b/tests/unit/swarm-slot-recycler.test.mjs new file mode 100644 index 00000000..c1cf8d8e --- /dev/null +++ b/tests/unit/swarm-slot-recycler.test.mjs @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; + +const REPO = path.resolve(import.meta.dirname, '../..'); +const SCRIPT = path.join(REPO, 'plugin/scripts/swarm-slot-recycler.mjs'); +const HOOKS = path.join(REPO, 'plugin/hooks/hooks.json'); +const SHIM = path.join(REPO, 'plugin/scripts/hook-shim.mjs'); +const PLAYBOOK = path.join(REPO, 'plugin/skills/ruvnet-brain/PLAYBOOK.md'); +const CODEX_HOOKS = path.join(REPO, 'plugin/hooks/codex-hooks.json'); + +let home; +let tasksRoot; + +function task(team, id, values = {}) { + const dir = path.join(tasksRoot, team); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, `${id}.json`), JSON.stringify({ + id: String(id), + subject: `Task ${id}`, + status: 'pending', + blocks: [], + blockedBy: [], + ...values, + })); +} + +function payload(values = {}) { + return JSON.stringify({ + hook_event_name: 'TeammateIdle', + session_id: 'session-recycle', + cwd: REPO, + teammate_name: 'worker-a', + team_name: 'team-a', + ...values, + }); +} + +function run(input = payload()) { + return spawnSync(process.execPath, [SCRIPT], { + input, + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + RUVNET_CLAUDE_TASKS_DIR: tasksRoot, + }, + }); +} + +function recyclerRegistration() { + const registry = JSON.parse(fs.readFileSync(HOOKS, 'utf8')); + const groups = registry.hooks.TeammateIdle || []; + const hooks = groups.flatMap((group) => group.hooks || []); + expect(hooks).toHaveLength(1); + return { groups, hook: hooks[0], registry }; +} + +beforeEach(() => { + home = fs.mkdtempSync(path.join(os.tmpdir(), 'swarm-recycler-home-')); + tasksRoot = path.join(home, '.claude', 'tasks'); + fs.mkdirSync(tasksRoot, { recursive: true }); +}); + +afterEach(() => fs.rmSync(home, { recursive: true, force: true })); + +describe('automatic swarm slot recycling', () => { + it('refuses idle when the shared ledger has ready unassigned work', () => { + task('team-a', 1, { subject: 'Implement parser' }); + + const result = run(); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toMatch(/claim.*task 1.*Implement parser/is); + expect(result.stderr).toMatch(/TaskUpdate/is); + }); + + it('chooses the first ready task deterministically and ignores owned work', () => { + task('team-a', 9, { subject: 'Already assigned', owner: 'worker-b' }); + task('team-a', 12, { subject: 'Second ready task' }); + task('team-a', 3, { subject: 'First ready task' }); + + const result = run(); + + expect(result.status).toBe(2); + expect(result.stderr).toMatch(/task 3.*First ready task/is); + expect(result.stderr).not.toContain('Already assigned'); + }); + + it('allows idle when remaining work is dependency-blocked', () => { + task('team-a', 1, { status: 'in_progress', owner: 'worker-b' }); + task('team-a', 2, { subject: 'Integrate', blockedBy: ['1'] }); + + const result = run(); + + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + }); + + it('recycles as soon as a dependency is complete', () => { + task('team-a', 1, { status: 'completed', owner: 'worker-b' }); + task('team-a', 2, { subject: 'Integrate', blockedBy: ['1'] }); + + const result = run(); + + expect(result.status).toBe(2); + expect(result.stderr).toMatch(/task 2.*Integrate/is); + }); + + it('allows idle when all work is complete', () => { + task('team-a', 1, { status: 'completed', owner: 'worker-a' }); + expect(run().status).toBe(0); + }); + + it.each([ + ['', 'empty input'], + ['not-json', 'malformed input'], + [payload({ hook_event_name: 'Stop' }), 'wrong event'], + [payload({ team_name: '../escape' }), 'path traversal'], + ])('fails open with no bytes for %s (%s)', (input) => { + const result = run(input); + expect(result.status).toBe(0); + expect(result.stdout).toBe(''); + expect(result.stderr).toBe(''); + }); + + it('does not modify the host-owned task ledger', () => { + task('team-a', 1, { subject: 'Immutable queue entry' }); + const file = path.join(tasksRoot, 'team-a', '1.json'); + const before = fs.readFileSync(file); + const beforeStat = fs.statSync(file); + + expect(run().status).toBe(2); + + expect(fs.readFileSync(file).equals(before)).toBe(true); + expect(fs.statSync(file).mtimeMs).toBe(beforeStat.mtimeMs); + }); + + it('does not hang when a host writes the envelope but keeps stdin open', async () => { + task('team-a', 1, { subject: 'Ready despite held-open stdin' }); + const child = spawn(process.execPath, [SCRIPT], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, HOME: home, RUVNET_CLAUDE_TASKS_DIR: tasksRoot }, + }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.stdin.write(payload()); + + const result = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGKILL'); + reject(new Error('recycler waited for EOF instead of its bounded idle deadline')); + }, 1_000); + child.once('close', (status) => { + clearTimeout(timer); + resolve({ status, stdout, stderr }); + }); + }); + + expect(result.status).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toMatch(/Ready despite held-open stdin/); + }); + + it('registers exactly one synchronous TeammateIdle hook through the stable spine', () => { + const { groups, hook } = recyclerRegistration(); + expect(groups).toHaveLength(1); + expect(groups[0].matcher).toBeUndefined(); + expect(hook).toEqual({ + type: 'command', + command: 'node "${CLAUDE_PLUGIN_ROOT}/scripts/hook-shim.mjs" swarm-slot-recycler', + timeout: 5, + }); + + const shim = fs.readFileSync(SHIM, 'utf8'); + expect(shim).toMatch(/'swarm-slot-recycler':\s*\{[^}]*file:\s*'swarm-slot-recycler\.mjs'[^}]*mode:\s*'blocking'/s); + }); + + it('preserves the recycler refusal through its registered stable-spine command', () => { + task('team-a', 1, { subject: 'Registered path task' }); + const { hook } = recyclerRegistration(); + const result = spawnSync(process.execPath, [SHIM, 'swarm-slot-recycler'], { + cwd: REPO, + input: payload(), + encoding: 'utf8', + env: { + ...process.env, + HOME: home, + CLAUDE_PLUGIN_ROOT: path.join(REPO, 'plugin'), + RUVNET_BRAIN_HOME: path.join(home, '.cache', 'ruvnet-brain'), + RUVNET_CLAUDE_TASKS_DIR: tasksRoot, + }, + }); + expect(result.status, result.stderr).toBe(2); + expect(result.stdout).toBe(''); + expect(result.stderr).toMatch(/Registered path task/); + }); + + it('preserves every pre-existing hook group byte-for-byte except the new event', () => { + const current = JSON.parse(fs.readFileSync(HOOKS, 'utf8')); + delete current.hooks.TeammateIdle; + const digest = crypto.createHash('sha256').update(JSON.stringify(current)).digest('hex'); + expect(digest).toBe('084f7c2bfe547e5903d6a0356192687a2479dcb306ae2e49e5a40e0832c5cf05'); + }); + + it('teaches deterministic initial saturation and the completion-to-next-task transition', () => { + const playbook = fs.readFileSync(PLAYBOOK, 'utf8'); + expect(playbook).toMatch(/before the first spawn.*create the complete shared task (list|ledger)/is); + expect(playbook).toMatch(/fill.*available.*slots.*without asking/is); + expect(playbook).toMatch(/unassigned.*unblocked.*pending task/is); + expect(playbook).toMatch(/one writer.*worktree/is); + expect(playbook).toMatch(/Ruflo.*coordinat.*native host.*execut/is); + }); + + it('reports the host boundary honestly: Claude enforces recycling; Codex is guidance-only', () => { + const playbook = fs.readFileSync(PLAYBOOK, 'utf8'); + const codex = JSON.parse(fs.readFileSync(CODEX_HOOKS, 'utf8')); + + expect(playbook).toMatch(/Claude Code.*TeammateIdle.*enforc/is); + expect(playbook).toMatch(/Codex.*no.*TeammateIdle.*TaskCompleted.*hook.*guidance/is); + expect(codex.hooks.TeammateIdle).toBeUndefined(); + expect(codex.hooks.TaskCompleted).toBeUndefined(); + }); +}); diff --git a/tests/ux/render-probe.mjs b/tests/ux/render-probe.mjs index bc0fdbd0..a6a605d4 100644 --- a/tests/ux/render-probe.mjs +++ b/tests/ux/render-probe.mjs @@ -278,7 +278,21 @@ export async function runRenderProbe() { const settingsStarted = Date.now(); await consolePage.locator('#field-provider input[value="codex"]').check(); + // This disposable render fixture intentionally has no installed KB updater. Keep its unrelated + // nightly preference off so the provider persistence check does not ask that absent fixture + // dependency to install a schedule as a side effect of submitting the complete settings form. + const nightlyToggle = consolePage.locator('#field-nightly input[type="checkbox"]'); + if (await nightlyToggle.count()) await nightlyToggle.uncheck(); + const providerSaveResponsePromise = consolePage.waitForResponse((response) => ( + response.request().method() === 'POST' + && new URL(response.url()).pathname === '/api/save-config' + )); await consolePage.locator('form:has(#field-provider) button[type="submit"]').click(); + const providerSaveResponse = await providerSaveResponsePromise; + const providerSaveBody = await providerSaveResponse.json(); + if (!providerSaveResponse.ok() || !providerSaveBody?.ok) { + throw new Error(`provider save returned ${providerSaveResponse.status()}: ${JSON.stringify(providerSaveBody)}`); + } await consolePage.locator('form:has(#field-provider) .form-note.n-ok').waitFor(); await consolePage.locator('#field-advocacy input[value="5"]').check(); await consolePage.locator('form:has(#field-advocacy) button[type="submit"]').click(); diff --git a/vitest.config.mjs b/vitest.config.mjs index 8579428e..3ec95df6 100644 --- a/vitest.config.mjs +++ b/vitest.config.mjs @@ -20,6 +20,9 @@ export default defineConfig({ // tests/qe but is absent from this include list is silently ignored even when its path is // supplied on the command line — the exact vacuous-green failure this suite is meant to stop. 'tests/qe/**/*.test.mjs', + // Real-host closure for reopened GitHub issues. These tests exercise installed Codex and + // browser boundaries and must not become invisible merely because they live outside unit/QE. + 'tests/acceptance/*.test.mjs', // ADR-058 D5: the coexistence suite. Same lesson, stated twice on one file in one night — // a directory absent from `include` is invisible to `vitest run` no matter what any npm // script or CI step claims to run.